From fecd5f062c9d25d6adf57f312ab69daf5b2a34fb Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Tue, 17 Dec 2024 18:46:20 +0100 Subject: [PATCH 01/36] Code from old PR --- client/eslint.config.mjs | 6 +- client/jest.config.json | 96 +++--- client/package.json | 273 +++++++++--------- client/playwright.config.ts | 2 +- client/src/page/LayoutPublic.tsx | 12 +- client/src/route/auth/ConfigItems.tsx | 85 ++++++ client/src/route/auth/Signin.tsx | 158 +++++++--- client/src/route/auth/VerifyConfig.tsx | 201 +++++++++++++ client/src/routes.tsx | 9 + client/test/__mocks__/global_mocks.ts | 5 + client/test/e2e/tests/Auth.test.ts | 2 +- client/test/e2e/tests/Menu.test.ts | 2 +- client/test/e2e/tests/verify.route.test.ts | 28 ++ .../integration/Auth/WaitAndNavigate.test.tsx | 4 + .../test/integration/Auth/authRedux.test.tsx | 36 ++- .../test/integration/Routes/Signin.test.tsx | 13 +- client/test/integration/jest.setup.ts | 19 +- client/test/unit/jest.setup.ts | 19 ++ client/test/unit/routes/SignIn.test.tsx | 97 ++++++- client/yarn.lock | 10 + 20 files changed, 804 insertions(+), 273 deletions(-) create mode 100644 client/src/route/auth/ConfigItems.tsx create mode 100644 client/src/route/auth/VerifyConfig.tsx create mode 100644 client/test/e2e/tests/verify.route.test.ts 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== From e689979b8a1ea52b6235c2ce3e30677518227319 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Tue, 17 Dec 2024 19:18:19 +0100 Subject: [PATCH 02/36] Add test env to preview test setup --- client/package.json | 2 +- client/test/preview/integration/jest.setup.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/client/package.json b/client/package.json index edcf27072..ee9fcd2de 100644 --- a/client/package.json +++ b/client/package.json @@ -29,7 +29,7 @@ "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:all": "yarn test:unit && yarn test:int && yarn test:e2e && yarn test:preview:unit && yarn test:preview:int", "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", diff --git a/client/test/preview/integration/jest.setup.ts b/client/test/preview/integration/jest.setup.ts index 7bb13bd15..bf729a7b8 100644 --- a/client/test/preview/integration/jest.setup.ts +++ b/client/test/preview/integration/jest.setup.ts @@ -5,3 +5,22 @@ import 'test/preview/__mocks__/global_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', +}; From 9ddb439c0f3c7c91b84213f65807561b544f62a6 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Wed, 18 Dec 2024 00:35:25 +0100 Subject: [PATCH 03/36] Refactor --- client/package.json | 1 + client/playwright.config.ts | 123 ++++++----- client/src/route/auth/ConfigItems.tsx | 123 +++++------ client/src/route/auth/Signin.tsx | 199 +++++++++-------- client/src/route/auth/VerifyConfig.tsx | 201 ----------------- client/src/routes.tsx | 2 +- client/src/util/config.tsx | 208 ++++++++++++++++++ client/test/__mocks__/global_mocks.ts | 4 +- client/test/e2e/tests/verify.route.test.ts | 36 +-- .../integration/Auth/WaitAndNavigate.test.tsx | 40 ++-- .../test/integration/Auth/authRedux.test.tsx | 130 +++++------ .../test/integration/Routes/Signin.test.tsx | 26 +-- client/test/unit/routes/SignIn.test.tsx | 186 ++++++++-------- client/yarn.lock | 5 + 14 files changed, 638 insertions(+), 646 deletions(-) delete mode 100644 client/src/route/auth/VerifyConfig.tsx create mode 100644 client/src/util/config.tsx diff --git a/client/package.json b/client/package.json index ee9fcd2de..10225f034 100644 --- a/client/package.json +++ b/client/package.json @@ -73,6 +73,7 @@ "eslint-plugin-jsx-a11y": "^6.8.0", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^5.1.0", + "http-status-codes": "^2.3.0", "jest-fetch-mock": "^3.0.3", "katex": "^0.16.11", "markdown-it-katex": "^2.0.3", diff --git a/client/playwright.config.ts b/client/playwright.config.ts index 3703bfe63..ff1d74873 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -16,66 +16,67 @@ dotenv.config({ path: './test/.env' }); const BASE_URI = process.env.REACT_APP_URL ?? 'http://localhost:4000/'; export default defineConfig({ - webServer: useExtServer - ? undefined - : { - command: 'yarn start', - }, - retries: process.env.CI ? 0 : 1, // Disable retries on Github actions for now as setup always fails - timeout: 120 * 1000, - globalTimeout: 10 * 60 * 1000, - testDir: './test/e2e/tests', - testMatch: /.*\.test\.ts/, - reporter: [ - [ - 'html', - { - outputFile: 'playwright-report/index.html', - }, - ], - ['list'], - [ - 'junit', - { - outputFile: 'playwright-report/results.xml', - }, - ], - [ - 'json', - { - outputFile: 'playwright-report/results.json', - }, - ], - ], // Codecov handled through Monocart-Reporter https://github.com/cenfun/monocart-reporter - use: { - baseURL: BASE_URI, - trace: 'on-first-retry', // Wil not record trace on Github actions because of no retries - }, - projects: [ - // Setup project - { - name: 'setup', - testMatch: /.*\.setup\.ts/, - use: { browserName: 'chromium' }, + webServer: useExtServer + ? undefined + : { + command: 'yarn start', + }, + retries: process.env.CI ? 0 : 1, // Disable retries on Github actions for now as setup always fails + timeout: 120 * 1000, + globalTimeout: 10 * 60 * 1000, + testDir: './test/e2e/tests', + testMatch: /.*\.test\.ts/, + reporter: [ + [ + 'html', + { + outputFile: 'playwright-report/index.html', + }, + ], + ['list'], + [ + 'junit', + { + outputFile: 'playwright-report/results.xml', + }, + ], + [ + 'json', + { + outputFile: 'playwright-report/results.json', + }, + ], + ], // Codecov handled through Monocart-Reporter https://github.com/cenfun/monocart-reporter + use: { + baseURL: BASE_URI, + trace: 'on-first-retry', // Wil not record trace on Github actions because of no retries + // headless: false }, - { - name: 'chromium', - use: { - ...devices['Desktop Chrome'], - storageState: 'playwright/.auth/user.json', - }, - dependencies: ['setup'], - }, - { - name: 'firefox', - use: { - ...devices['Desktop Firefox'], - // Use prepared auth state. - storageState: 'playwright/.auth/user.json', - }, - dependencies: ['setup'], - }, - ], - globalSetup: 'test/e2e/setup/global.setup.ts', - globalTeardown: 'test/e2e/setup/global-teardown.ts', + projects: [ + // Setup project + { + name: 'setup', + testMatch: /.*\.setup\.ts/, + use: { browserName: 'chromium' }, + }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/user.json', + }, + dependencies: ['setup'], + }, + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + // Use prepared auth state. + storageState: 'playwright/.auth/user.json', + }, + dependencies: ['setup'], + }, + ], + globalSetup: 'test/e2e/setup/global.setup.ts', + globalTeardown: 'test/e2e/setup/global-teardown.ts', }); diff --git a/client/src/route/auth/ConfigItems.tsx b/client/src/route/auth/ConfigItems.tsx index 1b24a3e44..99b1524d7 100644 --- a/client/src/route/auth/ConfigItems.tsx +++ b/client/src/route/auth/ConfigItems.tsx @@ -2,84 +2,65 @@ 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'; +import { validationType } from 'util/config'; +import { StatusCodes } from 'http-status-codes'; const ConfigIcon = (toolTipTitle: string, icon: JSX.Element): JSX.Element => ( - - {icon} - + + {icon} + ); export const getConfigIcon = ( - validation: validationType, - label: string, + 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); + 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! === StatusCodes.OK; + 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: 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, -}; +
+ {getConfigIcon(validation, label)} +
+ {label}: {value} +
+
+ ); +ConfigItem.displayName = 'ConfigItem'; \ No newline at end of file diff --git a/client/src/route/auth/Signin.tsx b/client/src/route/auth/Signin.tsx index b3de581d1..87bfe541f 100644 --- a/client/src/route/auth/Signin.tsx +++ b/client/src/route/auth/Signin.tsx @@ -6,126 +6,123 @@ 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'; +import VerifyConfig, { getValidationResults, validationType } from 'util/config'; function SignIn() { - const auth = useAuth(); - const [validationResults, setValidationResults] = useState<{ - [key: string]: validationType; - }>({}); - const [isLoading, setIsLoading] = useState(true); + 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', - ]; + const configsToVerify = [ + 'REACT_APP_URL', + 'REACT_APP_AUTH_AUTHORITY', + 'REACT_APP_REDIRECT_URI', + 'REACT_APP_LOGOUT_REDIRECT_URI', + ]; - useEffect(() => { - const fetchValidationResults = async () => { - const results = await getValidationResults(configsToVerify); - setValidationResults(results); - setIsLoading(false); - }; - fetchValidationResults(); - }); + useEffect(() => { + const fetchValidationResults = async () => { + const results = await getValidationResults(configsToVerify); + setValidationResults(results); + setIsLoading(false); + }; + fetchValidationResults(); + }); - const startAuthProcess = () => { - auth.signinRedirect(); - }; + const startAuthProcess = () => { + auth.signinRedirect(); + }; - const loading = loadingComponent(); - const signin = signInComponent(startAuthProcess); - const verifyConfig = verifyConfigComponent(configsToVerify); + const loading = loadingComponent(); + const signin = signInComponent(startAuthProcess); + const verifyConfig = verifyConfigComponent(configsToVerify); - let displayedComponent = loading; - const hasConfigErrors = configsToVerify.some( - (key) => validationResults[key]?.error !== undefined, - ); + let displayedComponent = loading; + const hasConfigErrors = configsToVerify.some( + (key) => validationResults[key]?.error !== undefined, + ); - if (!isLoading) { - // Show signin if config is ready and good, otherwise show problems - displayedComponent = signin; - if (hasConfigErrors) { - displayedComponent = verifyConfig; + if (!isLoading) { + // Show signin if config is ready and good, otherwise show problems + displayedComponent = signin; + if (hasConfigErrors) { + displayedComponent = verifyConfig; + } } - } - return displayedComponent; + return displayedComponent; } const verifyConfigComponent = (configsToVerify: string[]): React.ReactNode => - VerifyConfig({ - keys: configsToVerify, - title: 'Config validation failed', - }); + VerifyConfig({ + keys: configsToVerify, + title: 'Config validation failed', + }); const loadingComponent = (): React.ReactNode => ( - - Verifying configuration - - + + Verifying configuration + + ); const signInComponent = (startAuthProcess: () => void): React.ReactNode => ( - - - - - - + + + + +
); export default SignIn; diff --git a/client/src/route/auth/VerifyConfig.tsx b/client/src/route/auth/VerifyConfig.tsx deleted file mode 100644 index 4128c3eb5..000000000 --- a/client/src/route/auth/VerifyConfig.tsx +++ /dev/null @@ -1,201 +0,0 @@ -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 98c7cfa25..750a422b3 100644 --- a/client/src/routes.tsx +++ b/client/src/routes.tsx @@ -3,12 +3,12 @@ import WorkBench from 'route/workbench/Workbench'; import LayoutPublic from 'page/LayoutPublic'; import PrivateRoute from 'route/auth/PrivateRoute'; import LibraryPreview from 'preview/route/library/LibraryPreview'; +import VerifyConfig from 'util/config'; 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'; -import VerifyConfig from './route/auth/VerifyConfig'; export const routes = [ { diff --git a/client/src/util/config.tsx b/client/src/util/config.tsx new file mode 100644 index 000000000..3e10203bc --- /dev/null +++ b/client/src/util/config.tsx @@ -0,0 +1,208 @@ +import { z } from 'zod'; +import * as React from 'react'; +import { Paper, Typography } from '@mui/material'; +import { ConfigItem } from 'route/auth/ConfigItems'; + +const EnvironmentEnum = z.enum(['dev', 'local', 'prod', 'test']); +const PathString = z.string(); +const ScopesString = z.literal('openid profile read_user read_repository api'); + +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: Partial = + keys.length === 0 + ? window.env + : Object.fromEntries( + keys + .filter((key) => key in window.env) + .map((key) => [ + key, + window.env[ + key as keyof typeof window.env + ] as string, + ]), + ); + + return ( + + {title} +
+ {Object.entries(displayedConfigs).map(([key, value]) => ( + + ))} +
+
+ ); +}; + +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 = { + REACT_APP_ENVIRONMENT: Promise.resolve( + parseField(EnvironmentEnum, window.env.REACT_APP_ENVIRONMENT), + ), + REACT_APP_URL: urlIsReachable(window.env.REACT_APP_URL), + REACT_APP_URL_BASENAME: Promise.resolve( + parseField(PathString, window.env.REACT_APP_URL_BASENAME), + ), + REACT_APP_URL_DTLINK: Promise.resolve( + parseField(PathString, window.env.REACT_APP_URL_DTLINK), + ), + REACT_APP_URL_LIBLINK: Promise.resolve( + parseField(PathString, window.env.REACT_APP_URL_LIBLINK), + ), + REACT_APP_WORKBENCHLINK_VNCDESKTOP: Promise.resolve( + parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_VNCDESKTOP), + ), + REACT_APP_WORKBENCHLINK_VSCODE: Promise.resolve( + parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_VSCODE), + ), + REACT_APP_WORKBENCHLINK_JUPYTERLAB: Promise.resolve( + parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_JUPYTERLAB), + ), + REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: Promise.resolve( + parseField( + PathString, + window.env.REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK, + ), + ), + REACT_APP_CLIENT_ID: Promise.resolve( + parseField(PathString, window.env.REACT_APP_CLIENT_ID), + ), + REACT_APP_AUTH_AUTHORITY: urlIsReachable(window.env.REACT_APP_AUTH_AUTHORITY), + REACT_APP_REDIRECT_URI: urlIsReachable(window.env.REACT_APP_REDIRECT_URI), + REACT_APP_LOGOUT_REDIRECT_URI: urlIsReachable( + window.env.REACT_APP_LOGOUT_REDIRECT_URI, + ), + REACT_APP_GITLAB_SCOPES: Promise.resolve( + parseField(ScopesString, window.env.REACT_APP_GITLAB_SCOPES), + ), + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: Promise.resolve( + parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW), + ), + REACT_APP_WORKBENCHLINK_DT_PREVIEW: Promise.resolve( + parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_DT_PREVIEW), + ), + }; + + 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 }), {}); +}; + +export default VerifyConfig \ No newline at end of file diff --git a/client/test/__mocks__/global_mocks.ts b/client/test/__mocks__/global_mocks.ts index 8846292a1..010926465 100644 --- a/client/test/__mocks__/global_mocks.ts +++ b/client/test/__mocks__/global_mocks.ts @@ -60,7 +60,7 @@ jest.mock('util/envUtil', () => ({ ], })); -jest.mock('route/auth/VerifyConfig', () => ({ - ...jest.requireActual('route/auth/VerifyConfig'), +jest.mock('util/config', () => ({ + ...jest.requireActual('util/config'), getValidationResults: jest.fn(), })); diff --git a/client/test/e2e/tests/verify.route.test.ts b/client/test/e2e/tests/verify.route.test.ts index cb60a209b..1c8c1fc54 100644 --- a/client/test/e2e/tests/verify.route.test.ts +++ b/client/test/e2e/tests/verify.route.test.ts @@ -2,27 +2,27 @@ import test from 'test/e2e/setup/fixtures'; import { expect } from '@playwright/test'; test('Verification is visible', async ({ page }) => { - await page.goto('./verify'); + await page.goto('./verify'); - await page.waitForSelector('[data-testid="success-icon"]', { - timeout: 4000, - state: 'visible', - }); + await page.waitForSelector('[data-testid="success-icon"]', { + timeout: 4000, + state: 'visible', + }); - await expect( - page.getByRole('heading', { name: 'Config verification' }), - ).toBeVisible(); + 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.getByText('REACT_APP_CLIENT_ID:', { exact: true })).toBeVisible(); + await expect( + page.getByText('REACT_APP_AUTH_AUTHORITY:', { exact: true }), + ).toBeVisible(); - await expect( - page - .getByLabel('ENVIRONMENT field is configured correctly.') - .locator('path'), - ).toBeVisible(); + await expect( + page + .getByLabel('REACT_APP_ENVIRONMENT field is configured correctly.') + .locator('path'), + ).toBeVisible(); - await expect(page.getByTestId('error-icon')).toBeHidden(); + 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 781fbbd3d..9bd8059d1 100644 --- a/client/test/integration/Auth/WaitAndNavigate.test.tsx +++ b/client/test/integration/Auth/WaitAndNavigate.test.tsx @@ -1,5 +1,5 @@ import { act, screen } from '@testing-library/react'; -import { getValidationResults } from 'route/auth/VerifyConfig'; +import { getValidationResults } from 'util/config'; // Globally mocked import { mockAuthState } from 'test/__mocks__/global_mocks'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; @@ -8,29 +8,29 @@ jest.useFakeTimers(); const authStateWithError = { ...mockAuthState, error: Error('Test Error') }; const setup = () => setupIntegrationTest('/library', authStateWithError); Object.defineProperty(window, 'location', { - value: { - ...window.location, - reload: jest.fn(), - }, - writable: true, + value: { + ...window.location, + reload: jest.fn(), + }, + writable: true, }); describe('WaitAndNavigate', () => { - beforeEach(async () => { - (getValidationResults as jest.Mock).mockReturnValue( - Promise.resolve({ configField: 'test' }), - ); - await setup(); - }); + beforeEach(async () => { + (getValidationResults as jest.Mock).mockReturnValue( + Promise.resolve({}), + ); + await setup(); + }); - it('redirects to the WaitAndNavigate page when getting useAuth throws an error', async () => { - expect(screen.getByText('Oops... Test Error')).toBeVisible(); - expect(screen.getByText('Waiting for 5 seconds...')).toBeVisible(); + it('redirects to the WaitAndNavigate page when getting useAuth throws an error', async () => { + expect(screen.getByText('Oops... Test Error')).toBeVisible(); + expect(screen.getByText('Waiting for 5 seconds...')).toBeVisible(); - await act(async () => { - jest.advanceTimersByTime(5000); - }); + await act(async () => { + jest.advanceTimersByTime(5000); + }); - expect(screen.getByText(/Sign In with GitLab/i)).toBeVisible(); - }); + expect(screen.getByText(/Sign In with GitLab/i)).toBeVisible(); + }); }); diff --git a/client/test/integration/Auth/authRedux.test.tsx b/client/test/integration/Auth/authRedux.test.tsx index ba7279763..b2d517f84 100644 --- a/client/test/integration/Auth/authRedux.test.tsx +++ b/client/test/integration/Auth/authRedux.test.tsx @@ -7,100 +7,100 @@ 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'; +import { getValidationResults } from 'util/config'; // Globally mocked jest.mock('util/auth/Authentication', () => ({ - useGetAndSetUsername: () => jest.fn(), + useGetAndSetUsername: () => jest.fn(), })); jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useDispatch: jest.fn(), + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), })); jest.mock('page/Menu', () => ({ - __esModule: true, - default: () =>
, + __esModule: true, + default: () =>
, })); const store = createStore(authReducer); type AuthState = { - isAuthenticated: boolean; + isAuthenticated: boolean; }; const setupTest = async (authState: AuthState) => { - (useAuth as jest.Mock).mockReturnValue({ ...authState, user: mockUser }); - (getValidationResults as jest.Mock).mockReturnValue( - Promise.resolve({ configField: 'test' }), - ); + (useAuth as jest.Mock).mockReturnValue({ ...authState, user: mockUser }); + (getValidationResults as jest.Mock).mockReturnValue( + Promise.resolve({}), + ); - if (authState.isAuthenticated) { - store.dispatch({ - type: 'auth/setUserName', - payload: mockUser.profile.profile!.split('/')[1], - }); - } else { - store.dispatch({ type: 'auth/setUserName', payload: undefined }); - } + if (authState.isAuthenticated) { + store.dispatch({ + type: 'auth/setUserName', + payload: mockUser.profile.profile!.split('/')[1], + }); + } else { + store.dispatch({ type: 'auth/setUserName', payload: undefined }); + } - await act(async () => { - renderWithRouter( - - - , - { route: '/private', store }, - ); - }); + await act(async () => { + renderWithRouter( + + + , + { route: '/private', store }, + ); + }); }; describe('Redux and Authentication integration test', () => { - let initialState: { - auth: { - userName: string | undefined; + let initialState: { + auth: { + userName: string | undefined; + }; }; - }; - beforeEach(() => { - jest.clearAllMocks(); - initialState = { - auth: { - userName: undefined, - }, - }; - }); - - it('renders undefined username when not authenticated', async () => { - await setupTest({ - isAuthenticated: false, + beforeEach(() => { + jest.clearAllMocks(); + initialState = { + auth: { + userName: undefined, + }, + }; }); - expect(screen.getByText('Sign In with GitLab')).toBeInTheDocument(); - expect(authReducer(undefined, { type: 'unknown' })).toEqual( - initialState.auth, - ); - expect(store.getState().userName).toBe(undefined); - }); + it('renders undefined username when not authenticated', async () => { + await setupTest({ + isAuthenticated: false, + }); - it('renders the correct username when authenticated', async () => { - await setupTest({ - isAuthenticated: true, + expect(screen.getByText('Sign In with GitLab')).toBeInTheDocument(); + expect(authReducer(undefined, { type: 'unknown' })).toEqual( + initialState.auth, + ); + expect(store.getState().userName).toBe(undefined); }); - expect(screen.getByText('Functions')).toBeInTheDocument(); - expect(store.getState().userName).toBe('username'); - }); + it('renders the correct username when authenticated', async () => { + await setupTest({ + isAuthenticated: true, + }); - it('renders undefined username after ending authentication', async () => { - await setupTest({ - isAuthenticated: true, + expect(screen.getByText('Functions')).toBeInTheDocument(); + expect(store.getState().userName).toBe('username'); }); - expect(screen.getByText('Functions')).toBeInTheDocument(); - expect(store.getState().userName).toBe('username'); - await setupTest({ - isAuthenticated: false, + it('renders undefined username after ending authentication', async () => { + await setupTest({ + isAuthenticated: true, + }); + expect(screen.getByText('Functions')).toBeInTheDocument(); + expect(store.getState().userName).toBe('username'); + + await setupTest({ + isAuthenticated: false, + }); + expect(screen.getByText('Sign In with GitLab')).toBeInTheDocument(); + expect(store.getState().userName).toBe(undefined); }); - expect(screen.getByText('Sign In with GitLab')).toBeInTheDocument(); - expect(store.getState().userName).toBe(undefined); - }); }); diff --git a/client/test/integration/Routes/Signin.test.tsx b/client/test/integration/Routes/Signin.test.tsx index 337301d9b..20857d4b6 100644 --- a/client/test/integration/Routes/Signin.test.tsx +++ b/client/test/integration/Routes/Signin.test.tsx @@ -1,22 +1,22 @@ import { screen, act } from '@testing-library/react'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; -import { getValidationResults } from 'route/auth/VerifyConfig'; +import { getValidationResults } from 'util/config'; // Globally mocked import { testPublicLayout } from './routes.testUtil'; const setup = () => setupIntegrationTest('/'); describe('Signin', () => { - 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(); + it('renders the Sign in page with the Public Layout correctly', async () => { + (getValidationResults as jest.Mock).mockReturnValue( + Promise.resolve({}), + ); + await act(async () => { + await setup(); + }); + await testPublicLayout(); + expect( + screen.getByRole('button', { name: /Sign In with GitLab/i }), + ).toBeVisible(); + expect(screen.getByTestId(/LockOutlinedIcon/i)).toBeVisible(); }); - await testPublicLayout(); - expect( - screen.getByRole('button', { name: /Sign In with GitLab/i }), - ).toBeVisible(); - expect(screen.getByTestId(/LockOutlinedIcon/i)).toBeVisible(); - }); }); diff --git a/client/test/unit/routes/SignIn.test.tsx b/client/test/unit/routes/SignIn.test.tsx index d5fdbe380..73184839c 100644 --- a/client/test/unit/routes/SignIn.test.tsx +++ b/client/test/unit/routes/SignIn.test.tsx @@ -1,122 +1,122 @@ import * as React from 'react'; import { - render, - screen, - fireEvent, - waitFor, - act, + 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'; +import { getValidationResults } from 'util/config'; // Globally mocked jest.unmock('route/auth/Signin'); jest.mock('react-oidc-context'); describe('SignIn', () => { - const signinRedirect = jest.fn(); + const signinRedirect = jest.fn(); - beforeEach(() => { - (useAuth as jest.Mock).mockReturnValue({ - signinRedirect, + beforeEach(() => { + (useAuth as jest.Mock).mockReturnValue({ + signinRedirect, + }); }); - }); - afterEach(() => { - jest.clearAllMocks(); - }); - - 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; + afterEach(() => { + jest.clearAllMocks(); }); - (getValidationResults as jest.Mock).mockReturnValue(validationPromise); + 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( - - - , - ), - ); + const renderResult = await act(async () => + render( + + + , + ), + ); - expect( - renderResult.getByText('Verifying configuration'), - ).toBeInTheDocument(); - expect(renderResult.getByRole('progressbar')).toBeInTheDocument(); + expect( + 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' }); + // Resolve the promise to allow the component to complete loading + await act(async () => { + resolveValidation({}); + }); }); - }); - it('renders the SignIn button', async () => { - (getValidationResults as jest.Mock).mockReturnValue( - Promise.resolve({ button: 'test' }), - ); - await act(async () => { - render( - - - , - ); + it('renders the SignIn button', async () => { + (getValidationResults as jest.Mock).mockReturnValue( + Promise.resolve({}), + ); + 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(); }); - 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)); + it('renders the config problems', async () => { + const res = { + REACT_APP_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 act(async () => { + render( + + + , + ); + }); - await waitFor(() => { - expect(screen.getByText(/Config validation failed/i)).toBeInTheDocument(); + 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, - }); - fireEvent.click(signInButton); + it('handles button click', async () => { + (getValidationResults as jest.Mock).mockReturnValue( + Promise.resolve({}), + ); + 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, + }); + fireEvent.click(signInButton); - expect(signinRedirect).toHaveBeenCalled(); - }); + expect(signinRedirect).toHaveBeenCalled(); + }); }); diff --git a/client/yarn.lock b/client/yarn.lock index 0bdbd7bb7..11133ce77 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -6317,6 +6317,11 @@ http-proxy@^1.18.1: follow-redirects "^1.0.0" requires-port "^1.0.0" +http-status-codes@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/http-status-codes/-/http-status-codes-2.3.0.tgz#987fefb28c69f92a43aecc77feec2866349a8bfc" + integrity sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA== + https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" From d388e92378cc2d99b98c88b09c15c8099c59a94b Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Wed, 18 Dec 2024 19:41:53 +0100 Subject: [PATCH 04/36] Restructure into route/config --- client/src/route/auth/Signin.tsx | 6 +- .../route/{auth => config}/ConfigItems.tsx | 30 ++-- .../config.tsx => route/config/Verify.tsx} | 2 +- client/src/routes.tsx | 130 +++++++++--------- client/test/__mocks__/global_mocks.ts | 80 +++++------ .../integration/Auth/WaitAndNavigate.test.tsx | 2 +- .../test/integration/Auth/authRedux.test.tsx | 2 +- .../test/integration/Routes/Signin.test.tsx | 2 +- client/test/unit/routes/SignIn.test.tsx | 4 +- 9 files changed, 129 insertions(+), 129 deletions(-) rename client/src/route/{auth => config}/ConfigItems.tsx (81%) rename client/src/{util/config.tsx => route/config/Verify.tsx} (99%) diff --git a/client/src/route/auth/Signin.tsx b/client/src/route/auth/Signin.tsx index 87bfe541f..9c8829493 100644 --- a/client/src/route/auth/Signin.tsx +++ b/client/src/route/auth/Signin.tsx @@ -6,7 +6,7 @@ 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 'util/config'; +import VerifyConfig, { getValidationResults, validationType } from 'route/config/Verify'; function SignIn() { const auth = useAuth(); @@ -57,8 +57,8 @@ function SignIn() { const verifyConfigComponent = (configsToVerify: string[]): React.ReactNode => VerifyConfig({ - keys: configsToVerify, - title: 'Config validation failed', + keys: ['none'], + title: 'Invalid Application Configuration.\nPlease contact the administrator of your DTaaS installation.', }); const loadingComponent = (): React.ReactNode => ( diff --git a/client/src/route/auth/ConfigItems.tsx b/client/src/route/config/ConfigItems.tsx similarity index 81% rename from client/src/route/auth/ConfigItems.tsx rename to client/src/route/config/ConfigItems.tsx index 99b1524d7..b53a0b156 100644 --- a/client/src/route/auth/ConfigItems.tsx +++ b/client/src/route/config/ConfigItems.tsx @@ -2,7 +2,7 @@ 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 'util/config'; +import { validationType } from 'route/config/Verify'; import { StatusCodes } from 'http-status-codes'; const ConfigIcon = (toolTipTitle: string, icon: JSX.Element): JSX.Element => ( @@ -48,19 +48,19 @@ export const ConfigItem: React.FC<{ value: string; validation?: validationType; }> = ({ label, value, validation = { error: 'Validation unavailable' } }) => ( -
- {getConfigIcon(validation, label)} -
- {label}: {value} -
+
+ {getConfigIcon(validation, label)} +
+ {label}: {value}
- ); +
+); ConfigItem.displayName = 'ConfigItem'; \ No newline at end of file diff --git a/client/src/util/config.tsx b/client/src/route/config/Verify.tsx similarity index 99% rename from client/src/util/config.tsx rename to client/src/route/config/Verify.tsx index 3e10203bc..b12b76e23 100644 --- a/client/src/util/config.tsx +++ b/client/src/route/config/Verify.tsx @@ -1,7 +1,7 @@ import { z } from 'zod'; import * as React from 'react'; import { Paper, Typography } from '@mui/material'; -import { ConfigItem } from 'route/auth/ConfigItems'; +import { ConfigItem } from 'route/config/ConfigItems'; const EnvironmentEnum = z.enum(['dev', 'local', 'prod', 'test']); const PathString = z.string(); diff --git a/client/src/routes.tsx b/client/src/routes.tsx index 750a422b3..b0d3d5aee 100644 --- a/client/src/routes.tsx +++ b/client/src/routes.tsx @@ -3,7 +3,7 @@ import WorkBench from 'route/workbench/Workbench'; import LayoutPublic from 'page/LayoutPublic'; import PrivateRoute from 'route/auth/PrivateRoute'; import LibraryPreview from 'preview/route/library/LibraryPreview'; -import VerifyConfig from 'util/config'; +import VerifyConfig from 'route/config/Verify'; import Library from './route/library/Library'; import DigitalTwins from './route/digitaltwins/DigitalTwins'; import DigitalTwinsPreview from './preview/route/digitaltwins/DigitalTwinsPreview'; @@ -11,70 +11,70 @@ import SignIn from './route/auth/Signin'; import Account from './route/auth/Account'; export const routes = [ - { - path: '/', - element: ( - - - - ), - }, - { - path: 'verify', - element: ( - - - - ), - }, - { - path: 'library', - element: ( - - - - ), - }, - { - path: 'digitaltwins', - element: ( - - - - ), - }, - { - path: 'account', - element: ( - - - - ), - }, - { - path: 'workbench', - element: ( - - - - ), - }, - { - path: 'preview/digitaltwins', - element: ( - - - - ), - }, - { - path: 'preview/library', - element: ( - - - - ), - }, + { + path: '/', + element: ( + + + + ), + }, + { + path: 'verify', + element: ( + + + + ), + }, + { + path: 'library', + element: ( + + + + ), + }, + { + path: 'digitaltwins', + element: ( + + + + ), + }, + { + path: 'account', + element: ( + + + + ), + }, + { + path: 'workbench', + element: ( + + + + ), + }, + { + path: 'preview/digitaltwins', + element: ( + + + + ), + }, + { + path: 'preview/library', + element: ( + + + + ), + }, ]; export default routes; diff --git a/client/test/__mocks__/global_mocks.ts b/client/test/__mocks__/global_mocks.ts index 010926465..750107a61 100644 --- a/client/test/__mocks__/global_mocks.ts +++ b/client/test/__mocks__/global_mocks.ts @@ -9,58 +9,58 @@ 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; - }; + 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', - }, + 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; + user?: mockUserType | null; + isLoading: boolean; + isAuthenticated: boolean; + activeNavigator?: string; + error?: Error; }; export const mockAuthState: mockAuthStateType = { - isAuthenticated: true, - isLoading: false, - user: mockUser, + isAuthenticated: true, + isLoading: false, + user: mockUser, }; jest.mock('util/envUtil', () => ({ - ...jest.requireActual('util/envUtil'), - useAppURL: () => mockAppURL, - 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' }, - ], + ...jest.requireActual('util/envUtil'), + useAppURL: () => mockAppURL, + 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' }, + ], })); -jest.mock('util/config', () => ({ - ...jest.requireActual('util/config'), - getValidationResults: jest.fn(), +jest.mock('route/config/Verify', () => ({ + ...jest.requireActual('route/config/Verify'), + getValidationResults: jest.fn(), })); diff --git a/client/test/integration/Auth/WaitAndNavigate.test.tsx b/client/test/integration/Auth/WaitAndNavigate.test.tsx index 9bd8059d1..46dcebd26 100644 --- a/client/test/integration/Auth/WaitAndNavigate.test.tsx +++ b/client/test/integration/Auth/WaitAndNavigate.test.tsx @@ -1,5 +1,5 @@ import { act, screen } from '@testing-library/react'; -import { getValidationResults } from 'util/config'; // Globally mocked +import { getValidationResults } from 'route/config/Verify'; // Globally mocked import { mockAuthState } from 'test/__mocks__/global_mocks'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; diff --git a/client/test/integration/Auth/authRedux.test.tsx b/client/test/integration/Auth/authRedux.test.tsx index b2d517f84..aadfe4a21 100644 --- a/client/test/integration/Auth/authRedux.test.tsx +++ b/client/test/integration/Auth/authRedux.test.tsx @@ -7,7 +7,7 @@ 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 'util/config'; // Globally mocked +import { getValidationResults } from 'route/config/Verify'; // Globally mocked jest.mock('util/auth/Authentication', () => ({ useGetAndSetUsername: () => jest.fn(), diff --git a/client/test/integration/Routes/Signin.test.tsx b/client/test/integration/Routes/Signin.test.tsx index 20857d4b6..2218986ba 100644 --- a/client/test/integration/Routes/Signin.test.tsx +++ b/client/test/integration/Routes/Signin.test.tsx @@ -1,6 +1,6 @@ import { screen, act } from '@testing-library/react'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; -import { getValidationResults } from 'util/config'; // Globally mocked +import { getValidationResults } from 'route/config/Verify'; // Globally mocked import { testPublicLayout } from './routes.testUtil'; const setup = () => setupIntegrationTest('/'); diff --git a/client/test/unit/routes/SignIn.test.tsx b/client/test/unit/routes/SignIn.test.tsx index 73184839c..999e797fa 100644 --- a/client/test/unit/routes/SignIn.test.tsx +++ b/client/test/unit/routes/SignIn.test.tsx @@ -9,7 +9,7 @@ import { import { MemoryRouter } from 'react-router-dom'; import SignIn from 'route/auth/Signin'; import { useAuth } from 'react-oidc-context'; -import { getValidationResults } from 'util/config'; // Globally mocked +import { getValidationResults } from 'route/config/Verify'; // Globally mocked jest.unmock('route/auth/Signin'); jest.mock('react-oidc-context'); @@ -94,7 +94,7 @@ describe('SignIn', () => { }); await waitFor(() => { - expect(screen.getByText(/Config validation failed/i)).toBeInTheDocument(); + expect(screen.getByText(/Invalid Application Configuration. Please contact the administrator of your DTaaS installation./i)).toBeInTheDocument(); }); }); From fd5da93ec542468b4737e4e6e3d7f462b30cf485 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Wed, 18 Dec 2024 22:32:07 +0100 Subject: [PATCH 05/36] Fix endless loop, add loading to /verify --- client/playwright.config.ts | 123 +++--- client/src/route/auth/Signin.tsx | 196 +++++----- client/src/route/config/ConfigItems.tsx | 96 ++--- client/src/route/config/Verify.tsx | 369 +++++++++--------- client/src/routes.tsx | 128 +++--- client/test/__mocks__/global_mocks.ts | 78 ++-- client/test/e2e/tests/verify.route.test.ts | 38 +- .../integration/Auth/WaitAndNavigate.test.tsx | 36 +- .../test/integration/Auth/authRedux.test.tsx | 126 +++--- .../test/integration/Routes/Signin.test.tsx | 22 +- client/test/unit/routes/SignIn.test.tsx | 184 ++++----- 11 files changed, 701 insertions(+), 695 deletions(-) diff --git a/client/playwright.config.ts b/client/playwright.config.ts index ff1d74873..bb3641356 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -16,67 +16,66 @@ dotenv.config({ path: './test/.env' }); const BASE_URI = process.env.REACT_APP_URL ?? 'http://localhost:4000/'; export default defineConfig({ - webServer: useExtServer - ? undefined - : { - command: 'yarn start', - }, - retries: process.env.CI ? 0 : 1, // Disable retries on Github actions for now as setup always fails - timeout: 120 * 1000, - globalTimeout: 10 * 60 * 1000, - testDir: './test/e2e/tests', - testMatch: /.*\.test\.ts/, - reporter: [ - [ - 'html', - { - outputFile: 'playwright-report/index.html', - }, - ], - ['list'], - [ - 'junit', - { - outputFile: 'playwright-report/results.xml', - }, - ], - [ - 'json', - { - outputFile: 'playwright-report/results.json', - }, - ], - ], // Codecov handled through Monocart-Reporter https://github.com/cenfun/monocart-reporter - use: { - baseURL: BASE_URI, - trace: 'on-first-retry', // Wil not record trace on Github actions because of no retries - // headless: false - }, - projects: [ - // Setup project - { - name: 'setup', - testMatch: /.*\.setup\.ts/, - use: { browserName: 'chromium' }, - }, - { - name: 'chromium', - use: { - ...devices['Desktop Chrome'], - storageState: 'playwright/.auth/user.json', - }, - dependencies: ['setup'], - }, - { - name: 'firefox', - use: { - ...devices['Desktop Firefox'], - // Use prepared auth state. - storageState: 'playwright/.auth/user.json', - }, - dependencies: ['setup'], - }, + webServer: useExtServer + ? undefined + : { + command: 'yarn start', + }, + retries: process.env.CI ? 0 : 1, // Disable retries on Github actions for now as setup always fails + timeout: 90 * 1000, + globalTimeout: 10 * 60 * 1000, + testDir: './test/e2e/tests', + testMatch: /.*\.test\.ts/, + reporter: [ + [ + 'html', + { + outputFile: 'playwright-report/index.html', + }, + ], + ['list'], + [ + 'junit', + { + outputFile: 'playwright-report/results.xml', + }, + ], + [ + 'json', + { + outputFile: 'playwright-report/results.json', + }, ], - globalSetup: 'test/e2e/setup/global.setup.ts', - globalTeardown: 'test/e2e/setup/global-teardown.ts', + ], // Codecov handled through Monocart-Reporter https://github.com/cenfun/monocart-reporter + use: { + baseURL: BASE_URI, + trace: 'on-first-retry', // Wil not record trace on Github actions because of no retries + }, + projects: [ + // Setup project + { + name: 'setup', + testMatch: /.*\.setup\.ts/, + use: { browserName: 'chromium' }, + }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/user.json', + }, + dependencies: ['setup'], + }, + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + // Use prepared auth state. + storageState: 'playwright/.auth/user.json', + }, + dependencies: ['setup'], + }, + ], + globalSetup: 'test/e2e/setup/global.setup.ts', + globalTeardown: 'test/e2e/setup/global-teardown.ts', }); diff --git a/client/src/route/auth/Signin.tsx b/client/src/route/auth/Signin.tsx index 9c8829493..f29c3820d 100644 --- a/client/src/route/auth/Signin.tsx +++ b/client/src/route/auth/Signin.tsx @@ -5,124 +5,114 @@ 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 'route/config/Verify'; +import VerifyConfig, { + getValidationResults, + loadingComponent, + validationType, +} from 'route/config/Verify'; function SignIn() { - const auth = useAuth(); - const [validationResults, setValidationResults] = useState<{ - [key: string]: validationType; - }>({}); - const [isLoading, setIsLoading] = useState(true); + const auth = useAuth(); + const [validationResults, setValidationResults] = useState<{ + [key: string]: validationType; + }>({}); + const [isLoading, setIsLoading] = useState(true); - const configsToVerify = [ - 'REACT_APP_URL', - 'REACT_APP_AUTH_AUTHORITY', - 'REACT_APP_REDIRECT_URI', - 'REACT_APP_LOGOUT_REDIRECT_URI', - ]; + const configsToVerify = [ + 'REACT_APP_URL', + 'REACT_APP_AUTH_AUTHORITY', + 'REACT_APP_REDIRECT_URI', + 'REACT_APP_LOGOUT_REDIRECT_URI', + ]; - useEffect(() => { - const fetchValidationResults = async () => { - const results = await getValidationResults(configsToVerify); - setValidationResults(results); - setIsLoading(false); - }; - fetchValidationResults(); - }); - - const startAuthProcess = () => { - auth.signinRedirect(); + useEffect(() => { + const fetchValidationResults = async () => { + const results = await getValidationResults(configsToVerify); + setValidationResults(results); + setIsLoading(false); }; + fetchValidationResults(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [window.env]); + + const startAuthProcess = () => { + auth.signinRedirect(); + }; - const loading = loadingComponent(); - const signin = signInComponent(startAuthProcess); - const verifyConfig = verifyConfigComponent(configsToVerify); + const loading = loadingComponent(); + const signin = signInComponent(startAuthProcess); + const verifyConfig = verifyConfigComponent(['none']); - let displayedComponent = loading; - const hasConfigErrors = configsToVerify.some( - (key) => validationResults[key]?.error !== undefined, - ); + let displayedComponent = loading; + const hasConfigErrors = configsToVerify.some( + (key) => validationResults[key]?.error !== undefined, + ); - if (!isLoading) { - // Show signin if config is ready and good, otherwise show problems - displayedComponent = signin; - if (hasConfigErrors) { - displayedComponent = verifyConfig; - } + if (!isLoading) { + // Show signin if config is ready and good, otherwise show problems + displayedComponent = signin; + if (hasConfigErrors) { + displayedComponent = verifyConfig; } + } - return displayedComponent; + return displayedComponent; } -const verifyConfigComponent = (configsToVerify: string[]): React.ReactNode => - VerifyConfig({ - keys: ['none'], - title: 'Invalid Application Configuration.\nPlease contact the administrator of your DTaaS installation.', - }); - -const loadingComponent = (): React.ReactNode => ( - - Verifying configuration - - -); +const verifyConfigComponent = (configsToShow: string[]): React.ReactNode => + VerifyConfig({ + keys: configsToShow, + title: `Invalid Application Configuration. Please contact the administrator of your DTaaS installation.`, + }); const signInComponent = (startAuthProcess: () => void): React.ReactNode => ( - + + + + - + Sign In with GitLab + + ); export default SignIn; diff --git a/client/src/route/config/ConfigItems.tsx b/client/src/route/config/ConfigItems.tsx index b53a0b156..08ce08be6 100644 --- a/client/src/route/config/ConfigItems.tsx +++ b/client/src/route/config/ConfigItems.tsx @@ -6,61 +6,61 @@ import { validationType } from 'route/config/Verify'; import { StatusCodes } from 'http-status-codes'; const ConfigIcon = (toolTipTitle: string, icon: JSX.Element): JSX.Element => ( - - {icon} - + + {icon} + ); export const getConfigIcon = ( - validation: validationType, - label: string, + 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! === StatusCodes.OK; - icon = - validationStatusIsOK || !configHasStatus ? ( - - ) : ( - - ); - toolTipTitle = - validationStatusIsOK || !configHasStatus - ? `${label} field is configured correctly.` - : `${label} field may not be configured correctly.`; - toolTipTitle += ` ${statusMessage}`; - } - return ConfigIcon(toolTipTitle, icon); + 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! === StatusCodes.OK; + 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: string; + value: string; + validation?: validationType; }> = ({ label, value, validation = { error: 'Validation unavailable' } }) => ( -
- {getConfigIcon(validation, label)} -
- {label}: {value} -
+
+ {getConfigIcon(validation, label)} +
+ {label}: {value}
+
); -ConfigItem.displayName = 'ConfigItem'; \ No newline at end of file +ConfigItem.displayName = 'ConfigItem'; diff --git a/client/src/route/config/Verify.tsx b/client/src/route/config/Verify.tsx index b12b76e23..20ef933ad 100644 --- a/client/src/route/config/Verify.tsx +++ b/client/src/route/config/Verify.tsx @@ -1,6 +1,6 @@ import { z } from 'zod'; import * as React from 'react'; -import { Paper, Typography } from '@mui/material'; +import { Box, CircularProgress, Paper, Typography } from '@mui/material'; import { ConfigItem } from 'route/config/ConfigItems'; const EnvironmentEnum = z.enum(['dev', 'local', 'prod', 'test']); @@ -8,201 +8,222 @@ const PathString = z.string(); const ScopesString = z.literal('openid profile read_user read_repository api'); const VerifyConfig: React.FC<{ keys?: string[]; title?: string }> = ({ - keys = [], - title = 'Config verification', + 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: Partial = - keys.length === 0 - ? window.env - : Object.fromEntries( - keys - .filter((key) => key in window.env) - .map((key) => [ - key, - window.env[ - key as keyof typeof window.env - ] as string, - ]), - ); - - return ( - - {title} -
- {Object.entries(displayedConfigs).map(([key, value]) => ( - - ))} -
-
- ); + const [validationResults, setValidationResults] = React.useState<{ + [key: string]: validationType; + }>({}); + const [isLoading, setIsLoading] = React.useState(true); + + React.useEffect(() => { + const fetchValidations = async () => { + const results = await getValidationResults(keys); + setValidationResults(results); + setIsLoading(false); + }; + fetchValidations(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [window.env]); + + const displayedConfigs: Partial = + keys.length === 0 + ? window.env + : Object.fromEntries( + keys + .filter((key) => key in window.env) + .map((key) => [ + key, + window.env[key as keyof typeof window.env] as string, + ]), + ); + + return isLoading ? ( + loadingComponent() + ) : ( + + {title} +
+ {Object.entries(displayedConfigs).map(([key, value]) => ( + + ))} +
+
+ ); }; export type validationType = { - value?: string; - status?: number; - error?: string; + value?: string; + status?: number; + error?: string; }; +export const loadingComponent = (): React.ReactNode => ( + + Verifying configuration + + +); + 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; + const urlValidation: validationType = { + value: url, + status: undefined, + error: undefined, + }; + try { + await fetch(url, { + method: 'HEAD', + mode: 'no-cors', + signal: AbortSignal.timeout(2000), + }); + 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; + const urlValidation: validationType = { + value: url, + status: undefined, + error: undefined, + }; + const response = await fetch(url, { + method: 'HEAD', + signal: AbortSignal.timeout(2000), + }); + 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; + 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, + 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 }; + const result = parser.safeParse(value); + return result.success + ? { value, error: undefined } + : { value: undefined, error: result.error?.message }; }; export const getValidationResults = async ( - keysToValidate: string[], + keysToValidate: string[], ): Promise<{ - [key: string]: validationType; + [key: string]: validationType; }> => { - const allVerifications = { - REACT_APP_ENVIRONMENT: Promise.resolve( - parseField(EnvironmentEnum, window.env.REACT_APP_ENVIRONMENT), - ), - REACT_APP_URL: urlIsReachable(window.env.REACT_APP_URL), - REACT_APP_URL_BASENAME: Promise.resolve( - parseField(PathString, window.env.REACT_APP_URL_BASENAME), - ), - REACT_APP_URL_DTLINK: Promise.resolve( - parseField(PathString, window.env.REACT_APP_URL_DTLINK), - ), - REACT_APP_URL_LIBLINK: Promise.resolve( - parseField(PathString, window.env.REACT_APP_URL_LIBLINK), - ), - REACT_APP_WORKBENCHLINK_VNCDESKTOP: Promise.resolve( - parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_VNCDESKTOP), - ), - REACT_APP_WORKBENCHLINK_VSCODE: Promise.resolve( - parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_VSCODE), - ), - REACT_APP_WORKBENCHLINK_JUPYTERLAB: Promise.resolve( - parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_JUPYTERLAB), - ), - REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: Promise.resolve( - parseField( - PathString, - window.env.REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK, - ), - ), - REACT_APP_CLIENT_ID: Promise.resolve( - parseField(PathString, window.env.REACT_APP_CLIENT_ID), - ), - REACT_APP_AUTH_AUTHORITY: urlIsReachable(window.env.REACT_APP_AUTH_AUTHORITY), - REACT_APP_REDIRECT_URI: urlIsReachable(window.env.REACT_APP_REDIRECT_URI), - REACT_APP_LOGOUT_REDIRECT_URI: urlIsReachable( - window.env.REACT_APP_LOGOUT_REDIRECT_URI, - ), - REACT_APP_GITLAB_SCOPES: Promise.resolve( - parseField(ScopesString, window.env.REACT_APP_GITLAB_SCOPES), - ), - REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: Promise.resolve( - parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW), - ), - REACT_APP_WORKBENCHLINK_DT_PREVIEW: Promise.resolve( - parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_DT_PREVIEW), - ), - }; + const allVerifications = { + REACT_APP_ENVIRONMENT: Promise.resolve( + parseField(EnvironmentEnum, window.env.REACT_APP_ENVIRONMENT), + ), + REACT_APP_URL: urlIsReachable(window.env.REACT_APP_URL), + REACT_APP_URL_BASENAME: Promise.resolve( + parseField(PathString, window.env.REACT_APP_URL_BASENAME), + ), + REACT_APP_URL_DTLINK: Promise.resolve( + parseField(PathString, window.env.REACT_APP_URL_DTLINK), + ), + REACT_APP_URL_LIBLINK: Promise.resolve( + parseField(PathString, window.env.REACT_APP_URL_LIBLINK), + ), + REACT_APP_WORKBENCHLINK_VNCDESKTOP: Promise.resolve( + parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_VNCDESKTOP), + ), + REACT_APP_WORKBENCHLINK_VSCODE: Promise.resolve( + parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_VSCODE), + ), + REACT_APP_WORKBENCHLINK_JUPYTERLAB: Promise.resolve( + parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_JUPYTERLAB), + ), + REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: Promise.resolve( + parseField( + PathString, + window.env.REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK, + ), + ), + REACT_APP_CLIENT_ID: Promise.resolve( + parseField(PathString, window.env.REACT_APP_CLIENT_ID), + ), + REACT_APP_AUTH_AUTHORITY: urlIsReachable( + window.env.REACT_APP_AUTH_AUTHORITY, + ), + REACT_APP_REDIRECT_URI: urlIsReachable(window.env.REACT_APP_REDIRECT_URI), + REACT_APP_LOGOUT_REDIRECT_URI: urlIsReachable( + window.env.REACT_APP_LOGOUT_REDIRECT_URI, + ), + REACT_APP_GITLAB_SCOPES: Promise.resolve( + parseField(ScopesString, window.env.REACT_APP_GITLAB_SCOPES), + ), + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: Promise.resolve( + parseField( + PathString, + window.env.REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW, + ), + ), + REACT_APP_WORKBENCHLINK_DT_PREVIEW: Promise.resolve( + parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_DT_PREVIEW), + ), + }; + + 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, + })), + ); - 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 }), {}); + return results.reduce((acc, result) => ({ ...acc, ...result }), {}); }; -export default VerifyConfig \ No newline at end of file +export default VerifyConfig; diff --git a/client/src/routes.tsx b/client/src/routes.tsx index b0d3d5aee..7c33319c9 100644 --- a/client/src/routes.tsx +++ b/client/src/routes.tsx @@ -11,70 +11,70 @@ import SignIn from './route/auth/Signin'; import Account from './route/auth/Account'; export const routes = [ - { - path: '/', - element: ( - - - - ), - }, - { - path: 'verify', - element: ( - - - - ), - }, - { - path: 'library', - element: ( - - - - ), - }, - { - path: 'digitaltwins', - element: ( - - - - ), - }, - { - path: 'account', - element: ( - - - - ), - }, - { - path: 'workbench', - element: ( - - - - ), - }, - { - path: 'preview/digitaltwins', - element: ( - - - - ), - }, - { - path: 'preview/library', - element: ( - - - - ), - }, + { + path: '/', + element: ( + + + + ), + }, + { + path: 'verify', + element: ( + + + + ), + }, + { + path: 'library', + element: ( + + + + ), + }, + { + path: 'digitaltwins', + element: ( + + + + ), + }, + { + path: 'account', + element: ( + + + + ), + }, + { + path: 'workbench', + element: ( + + + + ), + }, + { + path: 'preview/digitaltwins', + element: ( + + + + ), + }, + { + path: 'preview/library', + element: ( + + + + ), + }, ]; export default routes; diff --git a/client/test/__mocks__/global_mocks.ts b/client/test/__mocks__/global_mocks.ts index 750107a61..867bc68e8 100644 --- a/client/test/__mocks__/global_mocks.ts +++ b/client/test/__mocks__/global_mocks.ts @@ -9,58 +9,58 @@ 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; - }; + 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', - }, + 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; + user?: mockUserType | null; + isLoading: boolean; + isAuthenticated: boolean; + activeNavigator?: string; + error?: Error; }; export const mockAuthState: mockAuthStateType = { - isAuthenticated: true, - isLoading: false, - user: mockUser, + isAuthenticated: true, + isLoading: false, + user: mockUser, }; jest.mock('util/envUtil', () => ({ - ...jest.requireActual('util/envUtil'), - useAppURL: () => mockAppURL, - 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' }, - ], + ...jest.requireActual('util/envUtil'), + useAppURL: () => mockAppURL, + 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' }, + ], })); jest.mock('route/config/Verify', () => ({ - ...jest.requireActual('route/config/Verify'), - getValidationResults: jest.fn(), + ...jest.requireActual('route/config/Verify'), + getValidationResults: jest.fn(), })); diff --git a/client/test/e2e/tests/verify.route.test.ts b/client/test/e2e/tests/verify.route.test.ts index 1c8c1fc54..bd03c6172 100644 --- a/client/test/e2e/tests/verify.route.test.ts +++ b/client/test/e2e/tests/verify.route.test.ts @@ -2,27 +2,29 @@ import test from 'test/e2e/setup/fixtures'; import { expect } from '@playwright/test'; test('Verification is visible', async ({ page }) => { - await page.goto('./verify'); + await page.goto('./verify'); - await page.waitForSelector('[data-testid="success-icon"]', { - timeout: 4000, - state: 'visible', - }); + await page.waitForSelector('[data-testid="success-icon"]', { + timeout: 4000, + state: 'visible', + }); - await expect( - page.getByRole('heading', { name: 'Config verification' }), - ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Config verification' }), + ).toBeVisible(); - await expect(page.getByText('REACT_APP_CLIENT_ID:', { exact: true })).toBeVisible(); - await expect( - page.getByText('REACT_APP_AUTH_AUTHORITY:', { exact: true }), - ).toBeVisible(); + await expect( + page.getByText('REACT_APP_CLIENT_ID:', { exact: true }), + ).toBeVisible(); + await expect( + page.getByText('REACT_APP_AUTH_AUTHORITY:', { exact: true }), + ).toBeVisible(); - await expect( - page - .getByLabel('REACT_APP_ENVIRONMENT field is configured correctly.') - .locator('path'), - ).toBeVisible(); + await expect( + page + .getByLabel('REACT_APP_ENVIRONMENT field is configured correctly.') + .locator('path'), + ).toBeVisible(); - await expect(page.getByTestId('error-icon')).toBeHidden(); + 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 46dcebd26..a324fd168 100644 --- a/client/test/integration/Auth/WaitAndNavigate.test.tsx +++ b/client/test/integration/Auth/WaitAndNavigate.test.tsx @@ -8,29 +8,27 @@ jest.useFakeTimers(); const authStateWithError = { ...mockAuthState, error: Error('Test Error') }; const setup = () => setupIntegrationTest('/library', authStateWithError); Object.defineProperty(window, 'location', { - value: { - ...window.location, - reload: jest.fn(), - }, - writable: true, + value: { + ...window.location, + reload: jest.fn(), + }, + writable: true, }); describe('WaitAndNavigate', () => { - beforeEach(async () => { - (getValidationResults as jest.Mock).mockReturnValue( - Promise.resolve({}), - ); - await setup(); - }); - - it('redirects to the WaitAndNavigate page when getting useAuth throws an error', async () => { - expect(screen.getByText('Oops... Test Error')).toBeVisible(); - expect(screen.getByText('Waiting for 5 seconds...')).toBeVisible(); + beforeEach(async () => { + (getValidationResults as jest.Mock).mockReturnValue(Promise.resolve({})); + await setup(); + }); - await act(async () => { - jest.advanceTimersByTime(5000); - }); + it('redirects to the WaitAndNavigate page when getting useAuth throws an error', async () => { + expect(screen.getByText('Oops... Test Error')).toBeVisible(); + expect(screen.getByText('Waiting for 5 seconds...')).toBeVisible(); - expect(screen.getByText(/Sign In with GitLab/i)).toBeVisible(); + await act(async () => { + jest.advanceTimersByTime(5000); }); + + expect(screen.getByText(/Sign In with GitLab/i)).toBeVisible(); + }); }); diff --git a/client/test/integration/Auth/authRedux.test.tsx b/client/test/integration/Auth/authRedux.test.tsx index aadfe4a21..a37deb899 100644 --- a/client/test/integration/Auth/authRedux.test.tsx +++ b/client/test/integration/Auth/authRedux.test.tsx @@ -10,97 +10,95 @@ import { renderWithRouter } from 'test/unit/unit.testUtil'; import { getValidationResults } from 'route/config/Verify'; // Globally mocked jest.mock('util/auth/Authentication', () => ({ - useGetAndSetUsername: () => jest.fn(), + useGetAndSetUsername: () => jest.fn(), })); jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useDispatch: jest.fn(), + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), })); jest.mock('page/Menu', () => ({ - __esModule: true, - default: () =>
, + __esModule: true, + default: () =>
, })); const store = createStore(authReducer); type AuthState = { - isAuthenticated: boolean; + isAuthenticated: boolean; }; const setupTest = async (authState: AuthState) => { - (useAuth as jest.Mock).mockReturnValue({ ...authState, user: mockUser }); - (getValidationResults as jest.Mock).mockReturnValue( - Promise.resolve({}), - ); - - if (authState.isAuthenticated) { - store.dispatch({ - type: 'auth/setUserName', - payload: mockUser.profile.profile!.split('/')[1], - }); - } else { - store.dispatch({ type: 'auth/setUserName', payload: undefined }); - } + (useAuth as jest.Mock).mockReturnValue({ ...authState, user: mockUser }); + (getValidationResults as jest.Mock).mockReturnValue(Promise.resolve({})); - await act(async () => { - renderWithRouter( - - - , - { route: '/private', store }, - ); + if (authState.isAuthenticated) { + store.dispatch({ + type: 'auth/setUserName', + payload: mockUser.profile.profile!.split('/')[1], }); + } else { + store.dispatch({ type: 'auth/setUserName', payload: undefined }); + } + + await act(async () => { + renderWithRouter( + + + , + { route: '/private', store }, + ); + }); }; describe('Redux and Authentication integration test', () => { - let initialState: { - auth: { - userName: string | undefined; - }; + let initialState: { + auth: { + userName: string | undefined; }; - beforeEach(() => { - jest.clearAllMocks(); - initialState = { - auth: { - userName: undefined, - }, - }; + }; + beforeEach(() => { + jest.clearAllMocks(); + initialState = { + auth: { + userName: undefined, + }, + }; + }); + + it('renders undefined username when not authenticated', async () => { + await setupTest({ + isAuthenticated: false, }); - it('renders undefined username when not authenticated', async () => { - await setupTest({ - isAuthenticated: false, - }); + expect(screen.getByText('Sign In with GitLab')).toBeInTheDocument(); + expect(authReducer(undefined, { type: 'unknown' })).toEqual( + initialState.auth, + ); + expect(store.getState().userName).toBe(undefined); + }); - expect(screen.getByText('Sign In with GitLab')).toBeInTheDocument(); - expect(authReducer(undefined, { type: 'unknown' })).toEqual( - initialState.auth, - ); - expect(store.getState().userName).toBe(undefined); + it('renders the correct username when authenticated', async () => { + await setupTest({ + isAuthenticated: true, }); - it('renders the correct username when authenticated', async () => { - await setupTest({ - isAuthenticated: true, - }); + expect(screen.getByText('Functions')).toBeInTheDocument(); + expect(store.getState().userName).toBe('username'); + }); - expect(screen.getByText('Functions')).toBeInTheDocument(); - expect(store.getState().userName).toBe('username'); + it('renders undefined username after ending authentication', async () => { + await setupTest({ + isAuthenticated: true, }); + expect(screen.getByText('Functions')).toBeInTheDocument(); + expect(store.getState().userName).toBe('username'); - it('renders undefined username after ending authentication', async () => { - await setupTest({ - isAuthenticated: true, - }); - expect(screen.getByText('Functions')).toBeInTheDocument(); - expect(store.getState().userName).toBe('username'); - - await setupTest({ - isAuthenticated: false, - }); - expect(screen.getByText('Sign In with GitLab')).toBeInTheDocument(); - expect(store.getState().userName).toBe(undefined); + await setupTest({ + isAuthenticated: false, }); + expect(screen.getByText('Sign In with GitLab')).toBeInTheDocument(); + expect(store.getState().userName).toBe(undefined); + }); }); diff --git a/client/test/integration/Routes/Signin.test.tsx b/client/test/integration/Routes/Signin.test.tsx index 2218986ba..4f79e0ba0 100644 --- a/client/test/integration/Routes/Signin.test.tsx +++ b/client/test/integration/Routes/Signin.test.tsx @@ -6,17 +6,15 @@ import { testPublicLayout } from './routes.testUtil'; const setup = () => setupIntegrationTest('/'); describe('Signin', () => { - it('renders the Sign in page with the Public Layout correctly', async () => { - (getValidationResults as jest.Mock).mockReturnValue( - Promise.resolve({}), - ); - await act(async () => { - await setup(); - }); - await testPublicLayout(); - expect( - screen.getByRole('button', { name: /Sign In with GitLab/i }), - ).toBeVisible(); - expect(screen.getByTestId(/LockOutlinedIcon/i)).toBeVisible(); + it('renders the Sign in page with the Public Layout correctly', async () => { + (getValidationResults as jest.Mock).mockReturnValue(Promise.resolve({})); + await act(async () => { + await setup(); }); + await testPublicLayout(); + expect( + screen.getByRole('button', { name: /Sign In with GitLab/i }), + ).toBeVisible(); + expect(screen.getByTestId(/LockOutlinedIcon/i)).toBeVisible(); + }); }); diff --git a/client/test/unit/routes/SignIn.test.tsx b/client/test/unit/routes/SignIn.test.tsx index 999e797fa..9acb67ec7 100644 --- a/client/test/unit/routes/SignIn.test.tsx +++ b/client/test/unit/routes/SignIn.test.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; import { - render, - screen, - fireEvent, - waitFor, - act, + render, + screen, + fireEvent, + waitFor, + act, } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import SignIn from 'route/auth/Signin'; @@ -15,108 +15,108 @@ jest.unmock('route/auth/Signin'); jest.mock('react-oidc-context'); describe('SignIn', () => { - const signinRedirect = jest.fn(); + const signinRedirect = jest.fn(); - beforeEach(() => { - (useAuth as jest.Mock).mockReturnValue({ - signinRedirect, - }); + beforeEach(() => { + (useAuth as jest.Mock).mockReturnValue({ + signinRedirect, }); + }); - afterEach(() => { - jest.clearAllMocks(); - }); + afterEach(() => { + jest.clearAllMocks(); + }); - 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; - }); + 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); + (getValidationResults as jest.Mock).mockReturnValue(validationPromise); - const renderResult = await act(async () => - render( - - - , - ), - ); + const renderResult = await act(async () => + render( + + + , + ), + ); - expect( - renderResult.getByText('Verifying configuration'), - ).toBeInTheDocument(); - expect(renderResult.getByRole('progressbar')).toBeInTheDocument(); + expect( + renderResult.getByText('Verifying configuration'), + ).toBeInTheDocument(); + expect(renderResult.getByRole('progressbar')).toBeInTheDocument(); - // Resolve the promise to allow the component to complete loading - await act(async () => { - resolveValidation({}); - }); + // Resolve the promise to allow the component to complete loading + await act(async () => { + resolveValidation({}); }); + }); - it('renders the SignIn button', async () => { - (getValidationResults as jest.Mock).mockReturnValue( - Promise.resolve({}), - ); - 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 SignIn button', async () => { + (getValidationResults as jest.Mock).mockReturnValue(Promise.resolve({})); + 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 = { - REACT_APP_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( - - - , - ); - }); + it('renders the config problems', async () => { + const res = { + REACT_APP_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 waitFor(() => { - expect(screen.getByText(/Invalid Application Configuration. Please contact the administrator of your DTaaS installation./i)).toBeInTheDocument(); - }); + await act(async () => { + render( + + + , + ); }); - it('handles button click', async () => { - (getValidationResults as jest.Mock).mockReturnValue( - Promise.resolve({}), - ); - 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, - }); - fireEvent.click(signInButton); + await waitFor(() => { + expect( + screen.getByText( + /Invalid Application Configuration. Please contact the administrator of your DTaaS installation./i, + ), + ).toBeInTheDocument(); + }); + }); - expect(signinRedirect).toHaveBeenCalled(); + it('handles button click', async () => { + (getValidationResults as jest.Mock).mockReturnValue(Promise.resolve({})); + 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, }); + fireEvent.click(signInButton); + + expect(signinRedirect).toHaveBeenCalled(); + }); }); From aab3df655341e4675b2fb6bc286a0d95da42f3d1 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Thu, 19 Dec 2024 22:22:39 +0100 Subject: [PATCH 06/36] Make config warning responsive --- client/src/route/auth/Signin.tsx | 9 ++++-- client/src/route/config/ConfigItems.tsx | 16 ++++++++++- client/src/route/config/Developer.tsx | 5 ++++ client/src/route/config/User.tsx | 5 ++++ client/src/route/config/Verify.tsx | 37 ++++++++++++------------- 5 files changed, 50 insertions(+), 22 deletions(-) create mode 100644 client/src/route/config/Developer.tsx create mode 100644 client/src/route/config/User.tsx diff --git a/client/src/route/auth/Signin.tsx b/client/src/route/auth/Signin.tsx index f29c3820d..04fde768c 100644 --- a/client/src/route/auth/Signin.tsx +++ b/client/src/route/auth/Signin.tsx @@ -7,9 +7,9 @@ import Button from '@mui/material/Button'; import { useState, useEffect } from 'react'; import VerifyConfig, { getValidationResults, - loadingComponent, validationType, } from 'route/config/Verify'; +import { loadingComponent } from 'route/config/ConfigItems'; function SignIn() { const auth = useAuth(); @@ -62,7 +62,12 @@ function SignIn() { const verifyConfigComponent = (configsToShow: string[]): React.ReactNode => VerifyConfig({ keys: configsToShow, - title: `Invalid Application Configuration. Please contact the administrator of your DTaaS installation.`, + title: ( + <> + Invalid Application Configuration.
+ Please contact the administrator of your DTaaS installation. + + ), }); const signInComponent = (startAuthProcess: () => void): React.ReactNode => ( diff --git a/client/src/route/config/ConfigItems.tsx b/client/src/route/config/ConfigItems.tsx index 08ce08be6..4a0d136eb 100644 --- a/client/src/route/config/ConfigItems.tsx +++ b/client/src/route/config/ConfigItems.tsx @@ -1,6 +1,6 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; -import { Tooltip } from '@mui/material'; +import { Box, CircularProgress, Tooltip } from '@mui/material'; import * as React from 'react'; import { validationType } from 'route/config/Verify'; import { StatusCodes } from 'http-status-codes'; @@ -64,3 +64,17 @@ export const ConfigItem: React.FC<{
); ConfigItem.displayName = 'ConfigItem'; + +export const loadingComponent = (): React.ReactNode => ( + + Verifying configuration + + +); diff --git a/client/src/route/config/Developer.tsx b/client/src/route/config/Developer.tsx new file mode 100644 index 000000000..d8c42a566 --- /dev/null +++ b/client/src/route/config/Developer.tsx @@ -0,0 +1,5 @@ +import * as React from 'react'; + +const Developer = () => <>; + +export default Developer; diff --git a/client/src/route/config/User.tsx b/client/src/route/config/User.tsx new file mode 100644 index 000000000..128defab3 --- /dev/null +++ b/client/src/route/config/User.tsx @@ -0,0 +1,5 @@ +import * as React from 'react'; + +const User = () => <>; + +export default User; diff --git a/client/src/route/config/Verify.tsx b/client/src/route/config/Verify.tsx index 20ef933ad..fb239dc48 100644 --- a/client/src/route/config/Verify.tsx +++ b/client/src/route/config/Verify.tsx @@ -1,13 +1,15 @@ import { z } from 'zod'; import * as React from 'react'; -import { Box, CircularProgress, Paper, Typography } from '@mui/material'; +import { Paper, Typography } from '@mui/material'; import { ConfigItem } from 'route/config/ConfigItems'; +import { loadingComponent } from 'route/config/ConfigItems'; + const EnvironmentEnum = z.enum(['dev', 'local', 'prod', 'test']); const PathString = z.string(); const ScopesString = z.literal('openid profile read_user read_repository api'); -const VerifyConfig: React.FC<{ keys?: string[]; title?: string }> = ({ +const VerifyConfig: React.FC<{ keys?: string[]; title?: JSX.Element }> = ({ keys = [], title = 'Config verification', }) => { @@ -37,20 +39,31 @@ const VerifyConfig: React.FC<{ keys?: string[]; title?: string }> = ({ window.env[key as keyof typeof window.env] as string, ]), ); - return isLoading ? ( loadingComponent() ) : ( - {title} + + {title} +
{Object.entries(displayedConfigs).map(([key, value]) => ( ( - - Verifying configuration - - -); - async function opaqueRequest(url: string): Promise { const urlValidation: validationType = { value: url, From 30ac82df87e8ce308eed67835e18d8b3252e1188 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Fri, 20 Dec 2024 01:01:31 +0100 Subject: [PATCH 07/36] Add new / and /config/verify pages --- client/src/route/auth/Signin.tsx | 149 ++++--------- client/src/route/config/Config.tsx | 120 +++++++++++ client/src/route/config/Developer.tsx | 5 - client/src/route/config/User.tsx | 5 - .../{ConfigItems.tsx => Verification.tsx} | 4 +- client/src/routes.tsx | 19 +- .../config/Verify.tsx => util/config.ts} | 204 ++++++------------ .../integration/Auth/WaitAndNavigate.test.tsx | 2 +- .../test/integration/Auth/authRedux.test.tsx | 2 +- .../test/integration/Routes/Signin.test.tsx | 2 +- client/test/unit/routes/SignIn.test.tsx | 2 +- 11 files changed, 250 insertions(+), 264 deletions(-) create mode 100644 client/src/route/config/Config.tsx delete mode 100644 client/src/route/config/Developer.tsx delete mode 100644 client/src/route/config/User.tsx rename client/src/route/config/{ConfigItems.tsx => Verification.tsx} (97%) rename client/src/{route/config/Verify.tsx => util/config.ts} (68%) diff --git a/client/src/route/auth/Signin.tsx b/client/src/route/auth/Signin.tsx index 04fde768c..b20d60a6a 100644 --- a/client/src/route/auth/Signin.tsx +++ b/client/src/route/auth/Signin.tsx @@ -4,120 +4,63 @@ 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 VerifyConfig, { - getValidationResults, - validationType, -} from 'route/config/Verify'; -import { loadingComponent } from 'route/config/ConfigItems'; function SignIn() { const auth = useAuth(); - const [validationResults, setValidationResults] = useState<{ - [key: string]: validationType; - }>({}); - const [isLoading, setIsLoading] = useState(true); - - const configsToVerify = [ - 'REACT_APP_URL', - 'REACT_APP_AUTH_AUTHORITY', - 'REACT_APP_REDIRECT_URI', - 'REACT_APP_LOGOUT_REDIRECT_URI', - ]; - - useEffect(() => { - const fetchValidationResults = async () => { - const results = await getValidationResults(configsToVerify); - setValidationResults(results); - setIsLoading(false); - }; - fetchValidationResults(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [window.env]); const startAuthProcess = () => { auth.signinRedirect(); }; - const loading = loadingComponent(); - const signin = signInComponent(startAuthProcess); - const verifyConfig = verifyConfigComponent(['none']); - - let displayedComponent = loading; - const hasConfigErrors = configsToVerify.some( - (key) => 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 = (configsToShow: string[]): React.ReactNode => - VerifyConfig({ - keys: configsToShow, - title: ( - <> - Invalid Application Configuration.
- Please contact the administrator of your DTaaS installation. - - ), - }); - -const signInComponent = (startAuthProcess: () => void): React.ReactNode => ( - - - - - - -); + + + + + + ); +} export default SignIn; diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx new file mode 100644 index 000000000..0282adc5c --- /dev/null +++ b/client/src/route/config/Config.tsx @@ -0,0 +1,120 @@ +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { getValidationResults, validationType } from 'util/config'; +import { ConfigItem, loadingComponent } from './Verification'; +import { Paper, Typography } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; + +const DeveloperConfig = (validationResults: { + [key: string]: validationType; +}): JSX.Element => { + return ( + + + {'Config verification'} + +
+ {Object.entries(window.env).map(([key, value]) => ( + + ))} +
+
+ ); +}; + +const UserConfig = (): JSX.Element => { + const title: JSX.Element = ( + <> + Invalid Application Configuration.
+ Please contact the administ rator of your DTaaS installation. + + ); + return ( + + + {title} + + + ); +}; + +const Config = (props: { role: string }) => { + const [validationResults, setValidationResults] = useState<{ + [key: string]: validationType; + }>({}); + const [isLoading, setIsLoading] = useState(true); + const navigate = useNavigate(); + + useEffect(() => { + const fetchValidationResults = async () => { + const results = await getValidationResults([]); + setValidationResults(results); + setIsLoading(false); + }; + fetchValidationResults(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [window.env]); + + const loading = loadingComponent(); + const verifyConfig = + props.role === 'user' ? UserConfig() : DeveloperConfig(validationResults); + + let displayedComponent = loading; + const hasConfigErrors = Object.values(window.env).some( + (key: string | undefined) => + key !== undefined && validationResults[key]?.error !== undefined, + ); + + if (!isLoading) { + // Show signin if config is ready and good, otherwise show problems + if (hasConfigErrors || props.role === 'developer') { + displayedComponent = verifyConfig; + } else if (props.role === 'user') { + navigate('/signin'); + } + } + + return displayedComponent; +}; + +export default Config; diff --git a/client/src/route/config/Developer.tsx b/client/src/route/config/Developer.tsx deleted file mode 100644 index d8c42a566..000000000 --- a/client/src/route/config/Developer.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import * as React from 'react'; - -const Developer = () => <>; - -export default Developer; diff --git a/client/src/route/config/User.tsx b/client/src/route/config/User.tsx deleted file mode 100644 index 128defab3..000000000 --- a/client/src/route/config/User.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import * as React from 'react'; - -const User = () => <>; - -export default User; diff --git a/client/src/route/config/ConfigItems.tsx b/client/src/route/config/Verification.tsx similarity index 97% rename from client/src/route/config/ConfigItems.tsx rename to client/src/route/config/Verification.tsx index 4a0d136eb..f8cf9e031 100644 --- a/client/src/route/config/ConfigItems.tsx +++ b/client/src/route/config/Verification.tsx @@ -1,8 +1,8 @@ +import * as React from 'react'; +import { validationType } from 'util/config'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import { Box, CircularProgress, Tooltip } from '@mui/material'; -import * as React from 'react'; -import { validationType } from 'route/config/Verify'; import { StatusCodes } from 'http-status-codes'; const ConfigIcon = (toolTipTitle: string, icon: JSX.Element): JSX.Element => ( diff --git a/client/src/routes.tsx b/client/src/routes.tsx index 7c33319c9..891126b4d 100644 --- a/client/src/routes.tsx +++ b/client/src/routes.tsx @@ -3,27 +3,36 @@ import WorkBench from 'route/workbench/Workbench'; import LayoutPublic from 'page/LayoutPublic'; import PrivateRoute from 'route/auth/PrivateRoute'; import LibraryPreview from 'preview/route/library/LibraryPreview'; -import VerifyConfig from 'route/config/Verify'; +// import VerifyConfig from 'route/config/Verify'; 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'; +import Config from './route/config/Config'; export const routes = [ { path: '/', element: ( - - + + ), }, { - path: 'verify', + path: 'config/verify', element: ( - + + + ), + }, + { + path: 'signin', + element: ( + + ), }, diff --git a/client/src/route/config/Verify.tsx b/client/src/util/config.ts similarity index 68% rename from client/src/route/config/Verify.tsx rename to client/src/util/config.ts index fb239dc48..e8f982ca2 100644 --- a/client/src/route/config/Verify.tsx +++ b/client/src/util/config.ts @@ -1,82 +1,4 @@ import { z } from 'zod'; -import * as React from 'react'; -import { Paper, Typography } from '@mui/material'; -import { ConfigItem } from 'route/config/ConfigItems'; - -import { loadingComponent } from 'route/config/ConfigItems'; - -const EnvironmentEnum = z.enum(['dev', 'local', 'prod', 'test']); -const PathString = z.string(); -const ScopesString = z.literal('openid profile read_user read_repository api'); - -const VerifyConfig: React.FC<{ keys?: string[]; title?: JSX.Element }> = ({ - keys = [], - title = 'Config verification', -}) => { - const [validationResults, setValidationResults] = React.useState<{ - [key: string]: validationType; - }>({}); - const [isLoading, setIsLoading] = React.useState(true); - - React.useEffect(() => { - const fetchValidations = async () => { - const results = await getValidationResults(keys); - setValidationResults(results); - setIsLoading(false); - }; - fetchValidations(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [window.env]); - - const displayedConfigs: Partial = - keys.length === 0 - ? window.env - : Object.fromEntries( - keys - .filter((key) => key in window.env) - .map((key) => [ - key, - window.env[key as keyof typeof window.env] as string, - ]), - ); - return isLoading ? ( - loadingComponent() - ) : ( - - - {title} - -
- {Object.entries(displayedConfigs).map(([key, value]) => ( - - ))} -
-
- ); -}; export type validationType = { value?: string; @@ -84,67 +6,9 @@ export type validationType = { 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(2000), - }); - 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(2000), - }); - 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 }; -}; +const EnvironmentEnum = z.enum(['dev', 'local', 'prod', 'test']); +const PathString = z.string(); +const ScopesString = z.literal('openid profile read_user read_repository api'); export const getValidationResults = async ( keysToValidate: string[], @@ -225,4 +89,64 @@ export const getValidationResults = async ( return results.reduce((acc, result) => ({ ...acc, ...result }), {}); }; -export default VerifyConfig; +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(2000), + }); + 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(2000), + }); + const responseIsAcceptable = response.ok || response.status === 302; + if (!responseIsAcceptable) { + urlValidation.error = `Unexpected response code ${response.status} from ${url}.`; + } + urlValidation.status = response.status; + return urlValidation; +} + +export 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 }; +}; diff --git a/client/test/integration/Auth/WaitAndNavigate.test.tsx b/client/test/integration/Auth/WaitAndNavigate.test.tsx index a324fd168..19605d1e6 100644 --- a/client/test/integration/Auth/WaitAndNavigate.test.tsx +++ b/client/test/integration/Auth/WaitAndNavigate.test.tsx @@ -1,5 +1,5 @@ import { act, screen } from '@testing-library/react'; -import { getValidationResults } from 'route/config/Verify'; // Globally mocked +import { getValidationResults } from 'route/config/Verification'; // Globally mocked import { mockAuthState } from 'test/__mocks__/global_mocks'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; diff --git a/client/test/integration/Auth/authRedux.test.tsx b/client/test/integration/Auth/authRedux.test.tsx index a37deb899..0d778fbb3 100644 --- a/client/test/integration/Auth/authRedux.test.tsx +++ b/client/test/integration/Auth/authRedux.test.tsx @@ -7,7 +7,7 @@ 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/config/Verify'; // Globally mocked +import { getValidationResults } from 'route/config/Verification'; // Globally mocked jest.mock('util/auth/Authentication', () => ({ useGetAndSetUsername: () => jest.fn(), diff --git a/client/test/integration/Routes/Signin.test.tsx b/client/test/integration/Routes/Signin.test.tsx index 4f79e0ba0..55ca5be3d 100644 --- a/client/test/integration/Routes/Signin.test.tsx +++ b/client/test/integration/Routes/Signin.test.tsx @@ -1,6 +1,6 @@ import { screen, act } from '@testing-library/react'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; -import { getValidationResults } from 'route/config/Verify'; // Globally mocked +import { getValidationResults } from 'route/config/Verification'; // Globally mocked import { testPublicLayout } from './routes.testUtil'; const setup = () => setupIntegrationTest('/'); diff --git a/client/test/unit/routes/SignIn.test.tsx b/client/test/unit/routes/SignIn.test.tsx index 9acb67ec7..7d93b6d15 100644 --- a/client/test/unit/routes/SignIn.test.tsx +++ b/client/test/unit/routes/SignIn.test.tsx @@ -9,7 +9,7 @@ import { import { MemoryRouter } from 'react-router-dom'; import SignIn from 'route/auth/Signin'; import { useAuth } from 'react-oidc-context'; -import { getValidationResults } from 'route/config/Verify'; // Globally mocked +import { getValidationResults } from 'route/config/Verification'; // Globally mocked jest.unmock('route/auth/Signin'); jest.mock('react-oidc-context'); From dfbab92c5195137cbf3df8502b5468768882f5f5 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Fri, 20 Dec 2024 20:06:41 +0100 Subject: [PATCH 08/36] Extend responsiveness to other boxes --- client/src/route/config/Config.tsx | 17 +++++++++++------ client/src/route/config/Verification.tsx | 11 ++++++++--- client/src/routes.tsx | 1 - 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index 0282adc5c..e6956893c 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -13,12 +13,16 @@ const DeveloperConfig = (validationResults: { sx={{ p: 2, width: 'min(60vw, 100%)', - aspectRatio: '2 / 1', + height: 'auto', + marginTop: '2%', + maxHeight: '75vh', + minWidth: '360px', display: 'flex', flexDirection: 'column', marginLeft: 'auto', marginRight: 'auto', position: 'relative', + overflow: 'auto', }} > { key !== undefined && validationResults[key]?.error !== undefined, ); - if (!isLoading) { - // Show signin if config is ready and good, otherwise show problems - if (hasConfigErrors || props.role === 'developer') { - displayedComponent = verifyConfig; - } else if (props.role === 'user') { + useEffect(() => { + if (!isLoading && props.role === 'user' && !hasConfigErrors) { navigate('/signin'); } + }, [isLoading, props.role, hasConfigErrors, navigate]); + + if (!isLoading && (hasConfigErrors || props.role === 'developer')) { + displayedComponent = verifyConfig; } return displayedComponent; diff --git a/client/src/route/config/Verification.tsx b/client/src/route/config/Verification.tsx index f8cf9e031..f53b5d258 100644 --- a/client/src/route/config/Verification.tsx +++ b/client/src/route/config/Verification.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { validationType } from 'util/config'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; -import { Box, CircularProgress, Tooltip } from '@mui/material'; +import { Box, CircularProgress, Tooltip, Typography } from '@mui/material'; import { StatusCodes } from 'http-status-codes'; const ConfigIcon = (toolTipTitle: string, icon: JSX.Element): JSX.Element => ( @@ -58,9 +58,14 @@ export const ConfigItem: React.FC<{ className="Config-item" > {getConfigIcon(validation, label)} -
+ {label}: {value} -
+
); ConfigItem.displayName = 'ConfigItem'; diff --git a/client/src/routes.tsx b/client/src/routes.tsx index 891126b4d..14b792ea7 100644 --- a/client/src/routes.tsx +++ b/client/src/routes.tsx @@ -3,7 +3,6 @@ import WorkBench from 'route/workbench/Workbench'; import LayoutPublic from 'page/LayoutPublic'; import PrivateRoute from 'route/auth/PrivateRoute'; import LibraryPreview from 'preview/route/library/LibraryPreview'; -// import VerifyConfig from 'route/config/Verify'; import Library from './route/library/Library'; import DigitalTwins from './route/digitaltwins/DigitalTwins'; import DigitalTwinsPreview from './preview/route/digitaltwins/DigitalTwinsPreview'; From 0433ded6295e296f60a390c4d3556b64731a3827 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Mon, 23 Dec 2024 18:45:51 +0100 Subject: [PATCH 09/36] Ensure tests pass with updated logic --- client/package.json | 4 +- client/playwright.config.ts | 3 +- client/src/route/config/Config.tsx | 18 ++-- client/src/route/config/Verification.tsx | 2 +- client/src/util/auth/Authentication.ts | 4 - client/src/util/{config.ts => configUtil.ts} | 50 +++++++--- client/test/__mocks__/global_mocks.ts | 24 ++++- client/test/e2e/tests/Auth.test.ts | 4 +- ...fy.route.test.ts => Config.Verify.test.ts} | 4 +- .../integration/Auth/WaitAndNavigate.test.tsx | 28 ++++-- .../test/integration/Auth/authRedux.test.tsx | 50 ++++++---- .../test/integration/Routes/Signin.test.tsx | 23 +++-- client/test/integration/jest.setup.ts | 19 ---- client/test/preview/__mocks__/global_mocks.ts | 19 ++++ client/test/preview/integration/jest.setup.ts | 19 ---- .../unit/components/PrivateRoute.test.tsx | 20 +++- client/test/unit/jest.setup.ts | 19 ---- client/test/unit/routes/SignIn.test.tsx | 97 +++---------------- .../unit/util/Auth/Authentication.test.ts | 8 -- client/yarn.lock | 8 +- 20 files changed, 197 insertions(+), 226 deletions(-) rename client/src/util/{config.ts => configUtil.ts} (79%) rename client/test/e2e/tests/{verify.route.test.ts => Config.Verify.test.ts} (92%) diff --git a/client/package.json b/client/package.json index 10225f034..81aec790a 100644 --- a/client/package.json +++ b/client/package.json @@ -31,7 +31,7 @@ "syntax": "npx eslint . --fix", "test:all": "yarn test:unit && yarn test:int && yarn test:e2e && yarn test:preview:unit && yarn test:preview:int", "test:e2e:ext": "cross-env ext=true yarn test:e2e", - "test:e2e": "yarn config:test && playwright test -c ./playwright.config.ts", + "test:e2e": "yarn config:test && yarn build && 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", @@ -97,7 +97,7 @@ "serve": "^14.2.1", "styled-components": "^6.1.1", "typescript": "5.1.6", - "zod": "^3.23.8" + "zod": "^3.24.1" }, "devDependencies": { "@babel/core": "7.25.8", diff --git a/client/playwright.config.ts b/client/playwright.config.ts index bb3641356..1824a614e 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: 90 * 1000, + timeout: process.env.CI ? 0 : 120 * 1000, // Disable timeouts on Github actions for now as setup always fails globalTimeout: 10 * 60 * 1000, testDir: './test/e2e/tests', testMatch: /.*\.test\.ts/, @@ -50,6 +50,7 @@ export default defineConfig({ use: { baseURL: BASE_URI, trace: 'on-first-retry', // Wil not record trace on Github actions because of no retries + headless: true, }, projects: [ // Setup project diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index e6956893c..db0110e63 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { useEffect, useState } from 'react'; -import { getValidationResults, validationType } from 'util/config'; +import { getValidationResults, validationType } from 'util/configUtil'; import { ConfigItem, loadingComponent } from './Verification'; import { Paper, Typography } from '@mui/material'; import { useNavigate } from 'react-router-dom'; @@ -59,7 +59,8 @@ const UserConfig = (): JSX.Element => { { const verifyConfig = props.role === 'user' ? UserConfig() : DeveloperConfig(validationResults); - let displayedComponent = loading; - const hasConfigErrors = Object.values(window.env).some( + const hasConfigErrors = Object.keys(window.env).some( (key: string | undefined) => key !== undefined && validationResults[key]?.error !== undefined, ); @@ -115,8 +115,14 @@ const Config = (props: { role: string }) => { } }, [isLoading, props.role, hasConfigErrors, navigate]); - if (!isLoading && (hasConfigErrors || props.role === 'developer')) { - displayedComponent = verifyConfig; + let displayedComponent = loading; + + if (!isLoading) { + if (props.role === 'developer') { + displayedComponent = verifyConfig; + } else if (hasConfigErrors) { + displayedComponent = loading; + } } return displayedComponent; diff --git a/client/src/route/config/Verification.tsx b/client/src/route/config/Verification.tsx index f53b5d258..da373792e 100644 --- a/client/src/route/config/Verification.tsx +++ b/client/src/route/config/Verification.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { validationType } from 'util/config'; +import { validationType } from 'util/configUtil'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import { Box, CircularProgress, Tooltip, Typography } from '@mui/material'; diff --git a/client/src/util/auth/Authentication.ts b/client/src/util/auth/Authentication.ts index 5e2262032..b68085cdd 100644 --- a/client/src/util/auth/Authentication.ts +++ b/client/src/util/auth/Authentication.ts @@ -45,10 +45,6 @@ async function performSignOutFlow(auth: AuthContextProps, appURL: string) { await fetch(`${cleanURL(appURL)}/_oauth/logout`, { signal: AbortSignal.timeout(30000), }); - - setTimeout(() => { - window.location.reload(); - }, 3000); } export function useSignOut() { diff --git a/client/src/util/config.ts b/client/src/util/configUtil.ts similarity index 79% rename from client/src/util/config.ts rename to client/src/util/configUtil.ts index e8f982ca2..6f850dffd 100644 --- a/client/src/util/config.ts +++ b/client/src/util/configUtil.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { wait } from 'util/auth/Authentication'; export type validationType = { value?: string; @@ -10,6 +11,22 @@ const EnvironmentEnum = z.enum(['dev', 'local', 'prod', 'test']); const PathString = z.string(); const ScopesString = z.literal('openid profile read_user read_repository api'); +export async function retryFetch( + url: string, + options: RequestInit = {}, + retries = 2, +): Promise { + if (retries === 0) { + throw new Error('No retries left'); + } + try { + return await fetch(url, options); + } catch (_error) { + wait(1000); + return retryFetch(url, options, retries - 1); + } +} + export const getValidationResults = async ( keysToValidate: string[], ): Promise<{ @@ -96,7 +113,7 @@ async function opaqueRequest(url: string): Promise { error: undefined, }; try { - await fetch(url, { + await retryFetch(url, { method: 'HEAD', mode: 'no-cors', signal: AbortSignal.timeout(2000), @@ -104,6 +121,7 @@ async function opaqueRequest(url: string): Promise { urlValidation.status = 0; } catch (error) { urlValidation.error = `An error occurred when fetching ${url}: ${error}`; + throw error; } return urlValidation; } @@ -114,26 +132,30 @@ async function corsRequest(url: string): Promise { status: undefined, error: undefined, }; - const response = await fetch(url, { - method: 'HEAD', - signal: AbortSignal.timeout(2000), - }); - const responseIsAcceptable = response.ok || response.status === 302; - if (!responseIsAcceptable) { - urlValidation.error = `Unexpected response code ${response.status} from ${url}.`; + try { + const response = await retryFetch(url, { + method: 'HEAD', + signal: AbortSignal.timeout(2000), + }); + const responseIsAcceptable = response.ok || response.redirected; + if (!responseIsAcceptable) { + urlValidation.error = `Unexpected response code ${response.status} from ${url}.`; + throw new Error(urlValidation.error); + } + urlValidation.status = response.status; + return urlValidation; + } catch (error) { + urlValidation.error = `An error occurred when fetching ${url}: ${error}`; + throw error; } - urlValidation.status = response.status; - return urlValidation; } export async function urlIsReachable(url: string): Promise { - let urlValidation: validationType; try { - urlValidation = await corsRequest(url); + return await corsRequest(url); } catch { - urlValidation = await opaqueRequest(url); + return opaqueRequest(url); } - return urlValidation; } const parseField = ( diff --git a/client/test/__mocks__/global_mocks.ts b/client/test/__mocks__/global_mocks.ts index 867bc68e8..db8318404 100644 --- a/client/test/__mocks__/global_mocks.ts +++ b/client/test/__mocks__/global_mocks.ts @@ -42,6 +42,25 @@ export const mockAuthState: mockAuthStateType = { user: mockUser, }; +window.env = { + ...global.window.env, + REACT_APP_AUTH_AUTHORITY: 'https://foo.git.com', + REACT_APP_ENVIRONMENT: 'test', + REACT_APP_URL: 'https://foo.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://bar.com', + REACT_APP_LOGOUT_REDIRECT_URI: 'https://foobar.com', + REACT_APP_GITLAB_SCOPES: 'openid profile read_user read_repository api', +}; + jest.mock('util/envUtil', () => ({ ...jest.requireActual('util/envUtil'), useAppURL: () => mockAppURL, @@ -59,8 +78,3 @@ jest.mock('util/envUtil', () => ({ { key: '3', link: 'link3' }, ], })); - -jest.mock('route/config/Verify', () => ({ - ...jest.requireActual('route/config/Verify'), - getValidationResults: jest.fn(), -})); diff --git a/client/test/e2e/tests/Auth.test.ts b/client/test/e2e/tests/Auth.test.ts index 835d9094c..b62b13770 100644 --- a/client/test/e2e/tests/Auth.test.ts +++ b/client/test/e2e/tests/Auth.test.ts @@ -46,7 +46,9 @@ test.describe('Tests on Authentication Flow', () => { await previousPromise; await page.goto(link.url.charAt(1).toUpperCase()); await expect(page).toHaveURL(baseURL?.replace(/\/$/, '') ?? './'); - await expect(page.locator('button:has-text("Sign In")')).toBeVisible(); + await expect(page.locator('button:has-text("Sign In")')).toBeVisible({ + timeout: 10000, + }); }, Promise.resolve()); }); }); diff --git a/client/test/e2e/tests/verify.route.test.ts b/client/test/e2e/tests/Config.Verify.test.ts similarity index 92% rename from client/test/e2e/tests/verify.route.test.ts rename to client/test/e2e/tests/Config.Verify.test.ts index bd03c6172..4775bb2e5 100644 --- a/client/test/e2e/tests/verify.route.test.ts +++ b/client/test/e2e/tests/Config.Verify.test.ts @@ -2,10 +2,10 @@ import test from 'test/e2e/setup/fixtures'; import { expect } from '@playwright/test'; test('Verification is visible', async ({ page }) => { - await page.goto('./verify'); + await page.goto('./config/verify'); await page.waitForSelector('[data-testid="success-icon"]', { - timeout: 4000, + timeout: 12000, state: 'visible', }); diff --git a/client/test/integration/Auth/WaitAndNavigate.test.tsx b/client/test/integration/Auth/WaitAndNavigate.test.tsx index 19605d1e6..b9a48d4f7 100644 --- a/client/test/integration/Auth/WaitAndNavigate.test.tsx +++ b/client/test/integration/Auth/WaitAndNavigate.test.tsx @@ -1,10 +1,21 @@ -import { act, screen } from '@testing-library/react'; -import { getValidationResults } from 'route/config/Verification'; // Globally mocked +import { act, screen, waitFor } from '@testing-library/react'; import { mockAuthState } from 'test/__mocks__/global_mocks'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; jest.useFakeTimers(); +// Bypass the config verification +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: 'success' }), +}); + +Object.defineProperty(AbortSignal, 'timeout', { + value: jest.fn(), + writable: false, +}); + const authStateWithError = { ...mockAuthState, error: Error('Test Error') }; const setup = () => setupIntegrationTest('/library', authStateWithError); Object.defineProperty(window, 'location', { @@ -16,12 +27,11 @@ Object.defineProperty(window, 'location', { }); describe('WaitAndNavigate', () => { - beforeEach(async () => { - (getValidationResults as jest.Mock).mockReturnValue(Promise.resolve({})); - await setup(); - }); - it('redirects to the WaitAndNavigate page when getting useAuth throws an error', async () => { + await act(async () => { + await setup(); + }); + expect(screen.getByText('Oops... Test Error')).toBeVisible(); expect(screen.getByText('Waiting for 5 seconds...')).toBeVisible(); @@ -29,6 +39,8 @@ describe('WaitAndNavigate', () => { jest.advanceTimersByTime(5000); }); - expect(screen.getByText(/Sign In with GitLab/i)).toBeVisible(); + await waitFor(() => + expect(screen.getByText(/Sign In with GitLab/i)).toBeVisible(), + ); }); }); diff --git a/client/test/integration/Auth/authRedux.test.tsx b/client/test/integration/Auth/authRedux.test.tsx index 0d778fbb3..9f3ebb254 100644 --- a/client/test/integration/Auth/authRedux.test.tsx +++ b/client/test/integration/Auth/authRedux.test.tsx @@ -1,13 +1,12 @@ import * as React from 'react'; import { createStore } from 'redux'; -import { screen, act } from '@testing-library/react'; +import { screen, waitFor } 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/config/Verification'; // Globally mocked jest.mock('util/auth/Authentication', () => ({ useGetAndSetUsername: () => jest.fn(), @@ -23,15 +22,26 @@ jest.mock('page/Menu', () => ({ default: () =>
, })); +// Bypass the config verification +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: 'success' }), +}); + +Object.defineProperty(AbortSignal, 'timeout', { + value: jest.fn(), + writable: false, +}); + const store = createStore(authReducer); type AuthState = { isAuthenticated: boolean; }; -const setupTest = async (authState: AuthState) => { +const setupTest = (authState: AuthState) => { (useAuth as jest.Mock).mockReturnValue({ ...authState, user: mockUser }); - (getValidationResults as jest.Mock).mockReturnValue(Promise.resolve({})); if (authState.isAuthenticated) { store.dispatch({ @@ -42,14 +52,12 @@ const setupTest = async (authState: AuthState) => { store.dispatch({ type: 'auth/setUserName', payload: undefined }); } - await act(async () => { - renderWithRouter( - - - , - { route: '/private', store }, - ); - }); + renderWithRouter( + + + , + { route: '/private', store }, + ); }; describe('Redux and Authentication integration test', () => { @@ -68,19 +76,21 @@ describe('Redux and Authentication integration test', () => { }); it('renders undefined username when not authenticated', async () => { - await setupTest({ + setupTest({ isAuthenticated: false, }); - expect(screen.getByText('Sign In with GitLab')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText(/Sign In with GitLab/i)).toBeInTheDocument(); + }); expect(authReducer(undefined, { type: 'unknown' })).toEqual( initialState.auth, ); expect(store.getState().userName).toBe(undefined); }); - it('renders the correct username when authenticated', async () => { - await setupTest({ + it('renders the correct username when authenticated', () => { + setupTest({ isAuthenticated: true, }); @@ -89,16 +99,18 @@ describe('Redux and Authentication integration test', () => { }); it('renders undefined username after ending authentication', async () => { - await setupTest({ + setupTest({ isAuthenticated: true, }); expect(screen.getByText('Functions')).toBeInTheDocument(); expect(store.getState().userName).toBe('username'); - await setupTest({ + setupTest({ isAuthenticated: false, }); - expect(screen.getByText('Sign In with GitLab')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText(/Sign In with GitLab/i)).toBeInTheDocument(); + }); expect(store.getState().userName).toBe(undefined); }); }); diff --git a/client/test/integration/Routes/Signin.test.tsx b/client/test/integration/Routes/Signin.test.tsx index 55ca5be3d..0a0fd32d4 100644 --- a/client/test/integration/Routes/Signin.test.tsx +++ b/client/test/integration/Routes/Signin.test.tsx @@ -1,16 +1,27 @@ -import { screen, act } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; -import { getValidationResults } from 'route/config/Verification'; // Globally mocked import { testPublicLayout } from './routes.testUtil'; +// Bypass the config verification +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: 'success' }), +}); + +Object.defineProperty(AbortSignal, 'timeout', { + value: jest.fn(), + writable: false, +}); + 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({})); - 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 588a833ba..f48d08d20 100644 --- a/client/test/integration/jest.setup.ts +++ b/client/test/integration/jest.setup.ts @@ -5,22 +5,3 @@ import 'test/__mocks__/global_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/preview/__mocks__/global_mocks.ts b/client/test/preview/__mocks__/global_mocks.ts index 8d9e0d057..92cf408e0 100644 --- a/client/test/preview/__mocks__/global_mocks.ts +++ b/client/test/preview/__mocks__/global_mocks.ts @@ -180,3 +180,22 @@ jest.mock('util/envUtil', () => ({ { key: '3', link: 'link3' }, ], })); + +window.env = { + ...global.window.env, + REACT_APP_AUTH_AUTHORITY: 'https://foo.net', + REACT_APP_ENVIRONMENT: 'test', + REACT_APP_URL: 'https://foo.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://bar.com', + REACT_APP_LOGOUT_REDIRECT_URI: 'https://foobar.com', + REACT_APP_GITLAB_SCOPES: 'openid profile read_user read_repository api', +}; diff --git a/client/test/preview/integration/jest.setup.ts b/client/test/preview/integration/jest.setup.ts index bf729a7b8..7bb13bd15 100644 --- a/client/test/preview/integration/jest.setup.ts +++ b/client/test/preview/integration/jest.setup.ts @@ -5,22 +5,3 @@ import 'test/preview/__mocks__/global_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/components/PrivateRoute.test.tsx b/client/test/unit/components/PrivateRoute.test.tsx index 87dddc9b7..98f58afa7 100644 --- a/client/test/unit/components/PrivateRoute.test.tsx +++ b/client/test/unit/components/PrivateRoute.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import { useAuth } from 'react-oidc-context'; import PrivateRoute from 'route/auth/PrivateRoute'; import { renderWithRouter } from 'test/unit/unit.testUtil'; @@ -8,6 +8,18 @@ jest.mock('react-oidc-context', () => ({ useAuth: jest.fn(), })); +// Bypass the config verification +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: 'success' }), +}); + +Object.defineProperty(AbortSignal, 'timeout', { + value: jest.fn(), + writable: false, +}); + const TestComponent = () =>
Test Component
; type AuthState = { @@ -34,14 +46,16 @@ const setupTest = (authState: AuthState) => { ); }; -test('renders loading and redirects correctly when authenticated/not authentic', () => { +test('renders loading and redirects correctly when authenticated/not authentic', async () => { setupTest({ isLoading: false, error: null, isAuthenticated: false, }); - expect(screen.getByText('Signin')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText('Signin')).toBeInTheDocument(), { + timeout: 60000, + }); setupTest({ isLoading: true, diff --git a/client/test/unit/jest.setup.ts b/client/test/unit/jest.setup.ts index 6e522fe11..53953aa4b 100644 --- a/client/test/unit/jest.setup.ts +++ b/client/test/unit/jest.setup.ts @@ -7,22 +7,3 @@ 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 7d93b6d15..3ab03ab3b 100644 --- a/client/test/unit/routes/SignIn.test.tsx +++ b/client/test/unit/routes/SignIn.test.tsx @@ -1,15 +1,8 @@ import * as React from 'react'; -import { - render, - screen, - fireEvent, - waitFor, - act, -} from '@testing-library/react'; +import { render, screen, fireEvent } 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/config/Verification'; // Globally mocked jest.unmock('route/auth/Signin'); jest.mock('react-oidc-context'); @@ -27,91 +20,25 @@ describe('SignIn', () => { jest.clearAllMocks(); }); - 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( - - - , - ), + it('renders the SignIn button', () => { + render( + + + , ); - expect( - renderResult.getByText('Verifying configuration'), - ).toBeInTheDocument(); - expect(renderResult.getByRole('progressbar')).toBeInTheDocument(); - - // Resolve the promise to allow the component to complete loading - await act(async () => { - resolveValidation({}); - }); - }); - - it('renders the SignIn button', async () => { - (getValidationResults as jest.Mock).mockReturnValue(Promise.resolve({})); - 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 = { - REACT_APP_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( - /Invalid Application Configuration. Please contact the administrator of your DTaaS installation./i, - ), - ).toBeInTheDocument(); - }); - }); - - it('handles button click', async () => { - (getValidationResults as jest.Mock).mockReturnValue(Promise.resolve({})); - await act(async () => { - render( - - - , - ); - }); - await waitFor(() => - screen.getByRole('button', { name: /Sign In With GitLab/i }), + it('handles button click', () => { + render( + + + , ); + const signInButton = screen.getByRole('button', { name: /Sign In With GitLab/i, }); diff --git a/client/test/unit/util/Auth/Authentication.test.ts b/client/test/unit/util/Auth/Authentication.test.ts index cc72f8c07..a160e0658 100644 --- a/client/test/unit/util/Auth/Authentication.test.ts +++ b/client/test/unit/util/Auth/Authentication.test.ts @@ -143,14 +143,6 @@ describe('useSignOut', () => { ); }); - it('reloads the page', async () => { - const auth = useAuth(); - const signOut = useSignOut(); - await signOut(auth); - jest.advanceTimersByTime(3000); - expect(window.location.reload).toHaveBeenCalled(); - }); - it('clears sessionStorage', async () => { const auth = useAuth(); const signOut = useSignOut(); diff --git a/client/yarn.lock b/client/yarn.lock index 11133ce77..32d35f389 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -12045,7 +12045,7 @@ yocto-queue@^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== +zod@^3.24.1: + version "3.24.1" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.1.tgz#27445c912738c8ad1e9de1bea0359fa44d9d35ee" + integrity sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A== From 41b95837a4e705613a9d0d6d39168d5b50d105c7 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Tue, 24 Dec 2024 00:53:56 +0100 Subject: [PATCH 10/36] Refactor getConfigIcon and fix retry bug --- client/package.json | 7 +-- client/src/route/config/Config.tsx | 14 +++--- client/src/route/config/Verification.tsx | 57 ++++++++++++++---------- client/src/util/configUtil.ts | 22 ++++++--- 4 files changed, 59 insertions(+), 41 deletions(-) diff --git a/client/package.json b/client/package.json index 81aec790a..0e5f96b97 100644 --- a/client/package.json +++ b/client/package.json @@ -5,11 +5,12 @@ "main": "index.tsx", "author": "prasadtalasila (http://prasad.talasila.in/)", "contributors": [ - "Omar Suleiman", "Asger Busk Breinholm", - "Mathias Brændgaard", - "Emre Temel", "Cesar Vela", + "Emre Temel", + "Enok Maj", + "Mathias Brændgaard", + "Omar Suleiman", "Vanessa Scherma" ], "license": "SEE LICENSE IN ", diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index db0110e63..a2d645122 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -51,8 +51,12 @@ const DeveloperConfig = (validationResults: { const UserConfig = (): JSX.Element => { const title: JSX.Element = ( <> - Invalid Application Configuration.
- Please contact the administ rator of your DTaaS installation. + Invalid Application Configuration. Please contact the administrator of + your DTaaS installation. +
+ + Inspect configuration + ); return ( @@ -118,11 +122,7 @@ const Config = (props: { role: string }) => { let displayedComponent = loading; if (!isLoading) { - if (props.role === 'developer') { - displayedComponent = verifyConfig; - } else if (hasConfigErrors) { - displayedComponent = loading; - } + displayedComponent = verifyConfig; } return displayedComponent; diff --git a/client/src/route/config/Verification.tsx b/client/src/route/config/Verification.tsx index da373792e..36995c4a1 100644 --- a/client/src/route/config/Verification.tsx +++ b/client/src/route/config/Verification.tsx @@ -14,39 +14,48 @@ const ConfigIcon = (toolTipTitle: string, icon: JSX.Element): JSX.Element => ( ); +interface ValidationIconConfig { + icon: JSX.Element; + hoverTip: string; +} + +const getValidationIconConfig = ( + validation: validationType, + label: string, +): ValidationIconConfig => { + const { value, status, error } = validation; + if (error) { + return { + icon: , + hoverTip: `${label} threw the following error: ${error}`, + }; + } + + const statusIsAcceptable = status === StatusCodes.OK || status === undefined; + + return statusIsAcceptable + ? { + icon: , + hoverTip: `${label} field is configured correctly.`, + } + : { + icon: , + hoverTip: `${label} field may not be configured correctly. ${value} responded with status code ${status}.`, + }; +}; + 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! === StatusCodes.OK; - icon = - validationStatusIsOK || !configHasStatus ? ( - - ) : ( - - ); - toolTipTitle = - validationStatusIsOK || !configHasStatus - ? `${label} field is configured correctly.` - : `${label} field may not be configured correctly.`; - toolTipTitle += ` ${statusMessage}`; - } - return ConfigIcon(toolTipTitle, icon); + const { icon, hoverTip } = getValidationIconConfig(validation, label); + return ConfigIcon(hoverTip, icon); }; export const ConfigItem: React.FC<{ label: string; value: string; - validation?: validationType; + validation: validationType; }> = ({ label, value, validation = { error: 'Validation unavailable' } }) => (
{ - if (retries === 0) { - throw new Error('No retries left'); - } try { return await fetch(url, options); - } catch (_error) { + } catch (error) { + if (retries === 0) { + return Promise.reject(error); + } wait(1000); return retryFetch(url, options, retries - 1); } @@ -143,18 +143,26 @@ async function corsRequest(url: string): Promise { throw new Error(urlValidation.error); } urlValidation.status = response.status; - return urlValidation; } catch (error) { urlValidation.error = `An error occurred when fetching ${url}: ${error}`; throw error; } + return urlValidation; } export async function urlIsReachable(url: string): Promise { try { return await corsRequest(url); - } catch { - return opaqueRequest(url); + } catch (_corsError) { + try { + return await opaqueRequest(url); + } catch (opaqueError) { + return { + value: url, + status: undefined, + error: `Failed to fetch ${url} after multiple attempts: ${opaqueError instanceof Error ? opaqueError.message : opaqueError}`, + }; + } } } From e15161f5f00388564812ff34046dfd911bba39e7 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Fri, 27 Dec 2024 21:42:53 +0100 Subject: [PATCH 11/36] Add ConfigUtil unit tests, fix memory leak in unit.testUtil --- client/package.json | 2 +- client/src/util/auth/Authentication.ts | 2 +- client/src/util/configUtil.ts | 18 +-- client/test/__mocks__/global_mocks.ts | 4 +- .../integration/Routes/routes.testUtil.tsx | 6 +- client/test/preview/__mocks__/global_mocks.ts | 4 +- client/test/unit/unit.testUtil.tsx | 6 +- client/test/unit/util/ConfigUtil.test.ts | 124 ++++++++++++++++++ 8 files changed, 150 insertions(+), 16 deletions(-) create mode 100644 client/test/unit/util/ConfigUtil.test.ts diff --git a/client/package.json b/client/package.json index 0e5f96b97..a59fe4691 100644 --- a/client/package.json +++ b/client/package.json @@ -34,7 +34,7 @@ "test:e2e:ext": "cross-env ext=true yarn test:e2e", "test:e2e": "yarn config:test && yarn build && 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:int": "jest -c ./jest.config.json --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" diff --git a/client/src/util/auth/Authentication.ts b/client/src/util/auth/Authentication.ts index b68085cdd..8cc7c6c2d 100644 --- a/client/src/util/auth/Authentication.ts +++ b/client/src/util/auth/Authentication.ts @@ -62,7 +62,7 @@ export function useSignOut() { return signOut; } -export function wait(milliseconds: number): Promise { +export async function wait(milliseconds: number): Promise { return new Promise((resolve) => { const onTimeout = () => { resolve(); diff --git a/client/src/util/configUtil.ts b/client/src/util/configUtil.ts index 0bc3f92cb..901663289 100644 --- a/client/src/util/configUtil.ts +++ b/client/src/util/configUtil.ts @@ -14,15 +14,15 @@ const ScopesString = z.literal('openid profile read_user read_repository api'); export async function retryFetch( url: string, options: RequestInit = {}, - retries = 2, + retries = 1, ): Promise { try { return await fetch(url, options); } catch (error) { - if (retries === 0) { + if (retries <= 0) { return Promise.reject(error); } - wait(1000); + await wait(1000); return retryFetch(url, options, retries - 1); } } @@ -108,7 +108,7 @@ export const getValidationResults = async ( async function opaqueRequest(url: string): Promise { const urlValidation: validationType = { - value: url, + value: undefined, status: undefined, error: undefined, }; @@ -118,6 +118,7 @@ async function opaqueRequest(url: string): Promise { mode: 'no-cors', signal: AbortSignal.timeout(2000), }); + urlValidation.value = url; urlValidation.status = 0; } catch (error) { urlValidation.error = `An error occurred when fetching ${url}: ${error}`; @@ -128,7 +129,7 @@ async function opaqueRequest(url: string): Promise { async function corsRequest(url: string): Promise { const urlValidation: validationType = { - value: url, + value: undefined, status: undefined, error: undefined, }; @@ -142,6 +143,7 @@ async function corsRequest(url: string): Promise { urlValidation.error = `Unexpected response code ${response.status} from ${url}.`; throw new Error(urlValidation.error); } + urlValidation.value = url; urlValidation.status = response.status; } catch (error) { urlValidation.error = `An error occurred when fetching ${url}: ${error}`; @@ -158,7 +160,7 @@ export async function urlIsReachable(url: string): Promise { return await opaqueRequest(url); } catch (opaqueError) { return { - value: url, + value: undefined, status: undefined, error: `Failed to fetch ${url} after multiple attempts: ${opaqueError instanceof Error ? opaqueError.message : opaqueError}`, }; @@ -177,6 +179,6 @@ const parseField = ( ): validationType => { const result = parser.safeParse(value); return result.success - ? { value, error: undefined } - : { value: undefined, error: result.error?.message }; + ? { error: undefined, value, status: undefined } + : { error: result.error?.message, status: undefined, value: undefined }; }; diff --git a/client/test/__mocks__/global_mocks.ts b/client/test/__mocks__/global_mocks.ts index db8318404..799c37ac1 100644 --- a/client/test/__mocks__/global_mocks.ts +++ b/client/test/__mocks__/global_mocks.ts @@ -44,7 +44,6 @@ export const mockAuthState: mockAuthStateType = { window.env = { ...global.window.env, - REACT_APP_AUTH_AUTHORITY: 'https://foo.git.com', REACT_APP_ENVIRONMENT: 'test', REACT_APP_URL: 'https://foo.com', REACT_APP_URL_BASENAME: 'mock_url_basename', @@ -54,8 +53,11 @@ window.env = { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: 'abc123', + REACT_APP_AUTH_AUTHORITY: 'https://foo.git.com', REACT_APP_REDIRECT_URI: 'https://bar.com', REACT_APP_LOGOUT_REDIRECT_URI: 'https://foobar.com', REACT_APP_GITLAB_SCOPES: 'openid profile read_user read_repository api', diff --git a/client/test/integration/Routes/routes.testUtil.tsx b/client/test/integration/Routes/routes.testUtil.tsx index 5e9a8043c..32c2c6187 100644 --- a/client/test/integration/Routes/routes.testUtil.tsx +++ b/client/test/integration/Routes/routes.testUtil.tsx @@ -19,10 +19,10 @@ export async function testPublicLayout() { export async function testDrawer() { expect(screen.getByTestId(/ChevronLeftIcon/)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /Library/ })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /^Library$/ })).toBeInTheDocument(); expect(screen.getByTestId(/ExtensionIcon/)).toBeInTheDocument(); expect( - screen.getByRole('link', { name: /Digital Twins/ }), + screen.getByRole('link', { name: /^Digital Twins$/ }), ).toBeInTheDocument(); expect(screen.getByTestId(/PeopleIcon/)).toBeInTheDocument(); expect(screen.getByRole('link', { name: /Workbench/ })).toBeInTheDocument(); @@ -99,7 +99,7 @@ async function itOpensAndClosesTheSettingsMenu() { async function itOpensAndClosesTheDrawer() { // Drawer is collapsed const drawerInnerDiv = closestDiv( - screen.getByRole('link', { name: /Library/ }), + screen.getByRole('link', { name: /^Library$/ }), ); expect(drawerInnerDiv).toHaveStyle('width:calc(56px + 1px);'); // Open-drawer-button is visible diff --git a/client/test/preview/__mocks__/global_mocks.ts b/client/test/preview/__mocks__/global_mocks.ts index 92cf408e0..51089732a 100644 --- a/client/test/preview/__mocks__/global_mocks.ts +++ b/client/test/preview/__mocks__/global_mocks.ts @@ -183,7 +183,6 @@ jest.mock('util/envUtil', () => ({ window.env = { ...global.window.env, - REACT_APP_AUTH_AUTHORITY: 'https://foo.net', REACT_APP_ENVIRONMENT: 'test', REACT_APP_URL: 'https://foo.com', REACT_APP_URL_BASENAME: 'mock_url_basename', @@ -193,8 +192,11 @@ window.env = { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', + REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: 'abc123', + REACT_APP_AUTH_AUTHORITY: 'https://foo.git.com', REACT_APP_REDIRECT_URI: 'https://bar.com', REACT_APP_LOGOUT_REDIRECT_URI: 'https://foobar.com', REACT_APP_GITLAB_SCOPES: 'openid profile read_user read_repository api', diff --git a/client/test/unit/unit.testUtil.tsx b/client/test/unit/unit.testUtil.tsx index aab550f8e..9cff02b89 100644 --- a/client/test/unit/unit.testUtil.tsx +++ b/client/test/unit/unit.testUtil.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { act, + cleanup, createEvent, fireEvent, getDefaultNormalizer, @@ -51,7 +52,6 @@ const RouterComponent: React.FC = ({ ui, route }) => ( key={`route-${routeElement.path.slice(1, -1)}`} /> ))} - ; ); @@ -71,6 +71,10 @@ export function InitRouteTests(component: React.ReactElement) { }); }); + afterEach(() => { + cleanup(); + }); + it('renders', () => { expect(true); }); diff --git a/client/test/unit/util/ConfigUtil.test.ts b/client/test/unit/util/ConfigUtil.test.ts new file mode 100644 index 000000000..651e306b6 --- /dev/null +++ b/client/test/unit/util/ConfigUtil.test.ts @@ -0,0 +1,124 @@ +import { + retryFetch, + getValidationResults, + urlIsReachable, +} from 'util/configUtil'; + +jest.deepUnmock('util/configUtil'); + +Object.defineProperty(AbortSignal, 'timeout', { + value: jest.fn(), + writable: false, +}); + +describe('configUtil', () => { + let networkError: Error; + const initialEnv = { ...window.env }; + beforeEach(() => { + window.env = initialEnv; + networkError = new Error('Network error'); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const mockResponse = { + ok: true, + status: 200, + json: async () => ({ data: 'success' }), + }; + + test('retryFetch returns a valid response', async () => { + global.fetch = jest.fn().mockResolvedValue(mockResponse); + const response = await retryFetch('https://foo.bar', { + method: 'HEAD', + signal: AbortSignal.timeout(1000), + }); + + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + + const jsonResult = await response.json(); + expect(jsonResult).toEqual({ data: 'success' }); + }); + + test('retryFetch retries conditionally until getting a valid response', async () => { + global.fetch = jest + .fn() + .mockRejectedValueOnce(networkError) + .mockRejectedValueOnce(networkError) + .mockResolvedValueOnce(mockResponse); + + const response = await retryFetch( + 'http://foo.foo', + { + method: 'HEAD', + mode: 'no-cors', + signal: AbortSignal.timeout(1500), + }, + 3, + ); + expect(response).toBe(mockResponse); + expect(global.fetch).toHaveBeenCalledTimes(3); + }); + + test('retryFetch retries until failing', async () => { + global.fetch = jest.fn().mockRejectedValue(networkError); + + await expect(retryFetch('https://bar.com')).rejects.toThrow(networkError); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + test('getValidationResults object includes all keys of window.env', async () => { + const results = await getValidationResults([]); + const resultKeys = Object.keys(results); + const envKeys = Object.keys(window.env); + + const missingKeys = envKeys.filter((key) => !resultKeys.includes(key)); + const unexpectedKeys = resultKeys.filter((key) => !envKeys.includes(key)); + + expect(missingKeys).toEqual([]); + expect(unexpectedKeys).toEqual([]); + }); + + test('urlIsReachable object has value if it succeeds', async () => { + global.fetch = jest.fn().mockResolvedValue(mockResponse); + const result = await urlIsReachable('https://foo.bar'); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(200); + expect(result.value).toEqual('https://foo.bar'); + }); + + test('urlIsReachable object has error if it fails', async () => { + global.fetch = jest.fn().mockRejectedValue(networkError); + const result = await urlIsReachable('https://foo.bar'); + expect(result.error).toBeDefined(); + expect(result.status).toBeUndefined(); + expect(result.value).toBeUndefined(); + }); + + test('getValidationResult AUTH_AUTHORITY has error if it fails reachability', async () => { + window.env.REACT_APP_AUTH_AUTHORITY = 'https://foo.bar'; + global.fetch = jest.fn().mockRejectedValue(networkError); + const results = await getValidationResults([]); + expect(results.REACT_APP_AUTH_AUTHORITY.error).toBeDefined(); + expect(results.REACT_APP_AUTH_AUTHORITY.status).toBeUndefined(); + expect(results.REACT_APP_AUTH_AUTHORITY.value).toBeUndefined(); + }); + + test('getValidationResult ENVIRONMENT has error if it fails parse', async () => { + window.env.REACT_APP_ENVIRONMENT = 'foo'; + const results = await getValidationResults([]); + expect(results.REACT_APP_ENVIRONMENT.error).toBeDefined(); + expect(results.REACT_APP_ENVIRONMENT.status).toBeUndefined(); + expect(results.REACT_APP_ENVIRONMENT.value).toBeUndefined(); + }); + + test('getValidationResult CLIENT_ID has value if it succeeds parse', async () => { + const results = await getValidationResults([]); + expect(results.REACT_APP_CLIENT_ID.error).toBeUndefined(); + expect(results.REACT_APP_CLIENT_ID.status).toBeUndefined(); + expect(results.REACT_APP_CLIENT_ID.value).toEqual('abc123'); + }); +}); From b03dfa546ee3248527bd3ff53f2c810c0263addf Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Sat, 28 Dec 2024 01:07:45 +0100 Subject: [PATCH 12/36] Add ConfigItems unit tests --- client/src/route/config/Config.tsx | 2 +- .../{Verification.tsx => ConfigItems.tsx} | 9 ++- client/test/unit/page/ConfigItem.test.tsx | 72 +++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) rename client/src/route/config/{Verification.tsx => ConfigItems.tsx} (90%) create mode 100644 client/test/unit/page/ConfigItem.test.tsx diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index a2d645122..d69b07fc1 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { useEffect, useState } from 'react'; import { getValidationResults, validationType } from 'util/configUtil'; -import { ConfigItem, loadingComponent } from './Verification'; +import { ConfigItem, loadingComponent } from './ConfigItems'; import { Paper, Typography } from '@mui/material'; import { useNavigate } from 'react-router-dom'; diff --git a/client/src/route/config/Verification.tsx b/client/src/route/config/ConfigItems.tsx similarity index 90% rename from client/src/route/config/Verification.tsx rename to client/src/route/config/ConfigItems.tsx index 36995c4a1..5ac7a7e82 100644 --- a/client/src/route/config/Verification.tsx +++ b/client/src/route/config/ConfigItems.tsx @@ -8,7 +8,11 @@ import { StatusCodes } from 'http-status-codes'; const ConfigIcon = (toolTipTitle: string, icon: JSX.Element): JSX.Element => ( {icon} @@ -31,6 +35,7 @@ const getValidationIconConfig = ( }; } + // If status is undefined then the validation is derived from a parsing. const statusIsAcceptable = status === StatusCodes.OK || status === undefined; return statusIsAcceptable @@ -89,6 +94,6 @@ export const loadingComponent = (): React.ReactNode => ( }} > Verifying configuration - + ); diff --git a/client/test/unit/page/ConfigItem.test.tsx b/client/test/unit/page/ConfigItem.test.tsx new file mode 100644 index 000000000..0d50f14e3 --- /dev/null +++ b/client/test/unit/page/ConfigItem.test.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { Tooltip } from '@mui/material'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import { + getConfigIcon, + ConfigItem, + loadingComponent, +} from 'route/config/ConfigItems'; +import { cleanup, render, screen } from '@testing-library/react'; + +describe('ConfigItem', () => { + afterEach(() => { + cleanup(); + }); + + test('getConfigIcon returns a CheckCircleIcon when the status is OK or undefined', () => { + const validation = { value: 'value', status: 200 }; + const label = 'label'; + const result = getConfigIcon(validation, label); + expect(result).toEqual( + + + , + ); + }); + + test('getConfigIcon returns an ErrorOutlineIcon when the status is not OK or undefined', () => { + const validation = { value: 'value', status: 400 }; + const label = 'label'; + const result = getConfigIcon(validation, label); + expect(result).toEqual( + + + , + ); + }); + + test('getConfigIcon returns an ErrorOutlineIcon when the validation has an error', () => { + const validation = { error: 'error' }; + const label = 'label'; + const result = getConfigIcon(validation, label); + expect(result).toEqual( + + + , + ); + }); + + test('ConfigItem renders correctly', () => { + render( + , + ); + expect(screen.getByText(/value/i)).toHaveProperty( + 'innerHTML', + 'label: value', + ); + expect(screen.getByTestId('success-icon')).toBeInTheDocument(); + }); + + test('loadingComponent renders correctly', () => { + render(loadingComponent()); + expect(screen.getByText(/Verifying configuration/i)).toBeInTheDocument(); + expect(screen.getByTestId('loading-icon')).toBeInTheDocument(); + }); +}); From 5ed7c8c3a81b6c8fdee1984114dac99d9eacb6e2 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Mon, 30 Dec 2024 18:23:41 +0100 Subject: [PATCH 13/36] Unit test Config.tsx --- client/src/route/config/Config.tsx | 10 +- client/test/__mocks__/global_mocks.ts | 2 +- client/test/e2e/tests/Config.Verify.test.ts | 2 +- client/test/preview/__mocks__/global_mocks.ts | 2 +- .../unit/components/PrivateRoute.test.tsx | 69 ++++---- client/test/unit/page/Config.test.tsx | 93 ++++++++++ client/test/unit/util/ConfigUtil.test.ts | 162 +++++++++--------- 7 files changed, 223 insertions(+), 117 deletions(-) create mode 100644 client/test/unit/page/Config.test.tsx diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index d69b07fc1..d67e8421f 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -105,7 +105,7 @@ const Config = (props: { role: string }) => { }, [window.env]); const loading = loadingComponent(); - const verifyConfig = + const configVerification = props.role === 'user' ? UserConfig() : DeveloperConfig(validationResults); const hasConfigErrors = Object.keys(window.env).some( @@ -113,16 +113,18 @@ const Config = (props: { role: string }) => { key !== undefined && validationResults[key]?.error !== undefined, ); + const shouldRedirect = + !isLoading && props.role === 'user' && !hasConfigErrors; useEffect(() => { - if (!isLoading && props.role === 'user' && !hasConfigErrors) { + if (shouldRedirect) { navigate('/signin'); } }, [isLoading, props.role, hasConfigErrors, navigate]); let displayedComponent = loading; - if (!isLoading) { - displayedComponent = verifyConfig; + if (!isLoading && !shouldRedirect) { + displayedComponent = configVerification; } return displayedComponent; diff --git a/client/test/__mocks__/global_mocks.ts b/client/test/__mocks__/global_mocks.ts index 799c37ac1..6c1071c2a 100644 --- a/client/test/__mocks__/global_mocks.ts +++ b/client/test/__mocks__/global_mocks.ts @@ -43,7 +43,7 @@ export const mockAuthState: mockAuthStateType = { }; window.env = { - ...global.window.env, + ...window.env, REACT_APP_ENVIRONMENT: 'test', REACT_APP_URL: 'https://foo.com', REACT_APP_URL_BASENAME: 'mock_url_basename', diff --git a/client/test/e2e/tests/Config.Verify.test.ts b/client/test/e2e/tests/Config.Verify.test.ts index 4775bb2e5..f46acac71 100644 --- a/client/test/e2e/tests/Config.Verify.test.ts +++ b/client/test/e2e/tests/Config.Verify.test.ts @@ -1,7 +1,7 @@ import test from 'test/e2e/setup/fixtures'; import { expect } from '@playwright/test'; -test('Verification is visible', async ({ page }) => { +test('Developer config is visible', async ({ page }) => { await page.goto('./config/verify'); await page.waitForSelector('[data-testid="success-icon"]', { diff --git a/client/test/preview/__mocks__/global_mocks.ts b/client/test/preview/__mocks__/global_mocks.ts index 51089732a..ada39a2f9 100644 --- a/client/test/preview/__mocks__/global_mocks.ts +++ b/client/test/preview/__mocks__/global_mocks.ts @@ -182,7 +182,7 @@ jest.mock('util/envUtil', () => ({ })); window.env = { - ...global.window.env, + ...window.env, REACT_APP_ENVIRONMENT: 'test', REACT_APP_URL: 'https://foo.com', REACT_APP_URL_BASENAME: 'mock_url_basename', diff --git a/client/test/unit/components/PrivateRoute.test.tsx b/client/test/unit/components/PrivateRoute.test.tsx index 98f58afa7..2a25e7cf8 100644 --- a/client/test/unit/components/PrivateRoute.test.tsx +++ b/client/test/unit/components/PrivateRoute.test.tsx @@ -46,40 +46,45 @@ const setupTest = (authState: AuthState) => { ); }; -test('renders loading and redirects correctly when authenticated/not authentic', async () => { - setupTest({ - isLoading: false, - error: null, - isAuthenticated: false, +describe('PrivateRoute', () => { + test('renders loading and redirects correctly when authenticated/not authentic', async () => { + setupTest({ + isLoading: false, + error: null, + isAuthenticated: false, + }); + + await waitFor( + () => expect(screen.getByText('Signin')).toBeInTheDocument(), + { + timeout: 60000, + }, + ); + + setupTest({ + isLoading: true, + error: null, + isAuthenticated: false, + }); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + + setupTest({ + isLoading: false, + error: null, + isAuthenticated: true, + }); + + expect(screen.getByText('Test Component')).toBeInTheDocument(); }); - await waitFor(() => expect(screen.getByText('Signin')).toBeInTheDocument(), { - timeout: 60000, - }); + test('renders error', () => { + setupTest({ + isLoading: false, + error: new Error('Test error'), + isAuthenticated: false, + }); - setupTest({ - isLoading: true, - error: null, - isAuthenticated: false, + expect(screen.getByText('Oops... Test error')).toBeInTheDocument(); }); - - expect(screen.getByText('Loading...')).toBeInTheDocument(); - - setupTest({ - isLoading: false, - error: null, - isAuthenticated: true, - }); - - expect(screen.getByText('Test Component')).toBeInTheDocument(); -}); - -test('renders error', () => { - setupTest({ - isLoading: false, - error: new Error('Test error'), - isAuthenticated: false, - }); - - expect(screen.getByText('Oops... Test error')).toBeInTheDocument(); }); diff --git a/client/test/unit/page/Config.test.tsx b/client/test/unit/page/Config.test.tsx new file mode 100644 index 000000000..531bd0eda --- /dev/null +++ b/client/test/unit/page/Config.test.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; +import Config from 'route/config/Config'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +Object.defineProperty(AbortSignal, 'timeout', { + value: jest.fn(), + writable: false, +}); + +const initialEnv = { ...window.env }; + +describe('Config', () => { + const mockResponse = { + ok: true, + status: 200, + json: async () => ({ data: 'success' }), + }; + beforeEach(() => { + window.env = { ...initialEnv }; + global.fetch = jest.fn().mockResolvedValue(mockResponse); + }); + + afterEach(() => { + cleanup(); + jest.resetAllMocks(); + }); + + test('renders DeveloperConfig correctly', async () => { + render( + + + , + ); + + expect(screen.getByText(/Verifying configuration/i)).toBeInTheDocument(); + expect(screen.getByTestId('loading-icon')).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByText(/Config verification/i)).toBeInTheDocument(), + ); + expect(screen.getByText(/REACT_APP_URL_BASENAME/i)).toBeInTheDocument(); + expect( + screen.getByText(/REACT_APP_WORKBENCHLINK_JUPYTERLAB/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/REACT_APP_LOGOUT_REDIRECT_URI/i), + ).toBeInTheDocument(); + }); + + test('renders UserConfig correctly', async () => { + // Invalidate one config field to show user config + window.env.REACT_APP_GITLAB_SCOPES = 'invalid'; + render( + + + , + ); + + expect(screen.getByText(/Verifying configuration/i)).toBeInTheDocument(); + expect(screen.getByTestId('loading-icon')).toBeInTheDocument(); + await waitFor(() => + expect( + screen.getByText(/Invalid Application Configuration/i), + ).toBeInTheDocument(), + ); + const linkToDeveloperConfig = screen.getByRole('link', { + name: /Inspect configuration/i, + }); + expect(linkToDeveloperConfig).toBeInTheDocument(); + expect(linkToDeveloperConfig).toHaveAttribute('href', './config/verify'); + }); + + test('redirects to /signin', async () => { + render( + + + , + ); + + expect(screen.getByText(/Verifying configuration/i)).toBeInTheDocument(); + expect(screen.getByTestId('loading-icon')).toBeInTheDocument(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/signin'); + }); + }); +}); diff --git a/client/test/unit/util/ConfigUtil.test.ts b/client/test/unit/util/ConfigUtil.test.ts index 651e306b6..0fdcdf3cb 100644 --- a/client/test/unit/util/ConfigUtil.test.ts +++ b/client/test/unit/util/ConfigUtil.test.ts @@ -15,11 +15,12 @@ describe('configUtil', () => { let networkError: Error; const initialEnv = { ...window.env }; beforeEach(() => { - window.env = initialEnv; + global.fetch = jest.fn().mockResolvedValue(mockResponse); networkError = new Error('Network error'); }); afterEach(() => { + window.env = { ...initialEnv }; jest.resetAllMocks(); }); @@ -29,96 +30,101 @@ describe('configUtil', () => { json: async () => ({ data: 'success' }), }; - test('retryFetch returns a valid response', async () => { - global.fetch = jest.fn().mockResolvedValue(mockResponse); - const response = await retryFetch('https://foo.bar', { - method: 'HEAD', - signal: AbortSignal.timeout(1000), - }); - - expect(response.ok).toBe(true); - expect(response.status).toBe(200); + describe('retryFetch', () => { + test('retryFetch returns a valid response', async () => { + global.fetch = jest.fn().mockResolvedValue(mockResponse); + const response = await retryFetch('https://foo.bar', { + method: 'HEAD', + signal: AbortSignal.timeout(1000), + }); - const jsonResult = await response.json(); - expect(jsonResult).toEqual({ data: 'success' }); - }); + expect(response.ok).toBe(true); + expect(response.status).toBe(200); - test('retryFetch retries conditionally until getting a valid response', async () => { - global.fetch = jest - .fn() - .mockRejectedValueOnce(networkError) - .mockRejectedValueOnce(networkError) - .mockResolvedValueOnce(mockResponse); + const jsonResult = await response.json(); + expect(jsonResult).toEqual({ data: 'success' }); + }); - const response = await retryFetch( - 'http://foo.foo', - { - method: 'HEAD', - mode: 'no-cors', - signal: AbortSignal.timeout(1500), - }, - 3, - ); - expect(response).toBe(mockResponse); - expect(global.fetch).toHaveBeenCalledTimes(3); - }); + test('retryFetch retries conditionally until getting a valid response', async () => { + global.fetch = jest + .fn() + .mockRejectedValueOnce(networkError) + .mockRejectedValueOnce(networkError) + .mockResolvedValueOnce(mockResponse); + + const response = await retryFetch( + 'http://foo.foo', + { + method: 'HEAD', + mode: 'no-cors', + signal: AbortSignal.timeout(1500), + }, + 3, + ); + expect(response).toBe(mockResponse); + expect(global.fetch).toHaveBeenCalledTimes(3); + }); - test('retryFetch retries until failing', async () => { - global.fetch = jest.fn().mockRejectedValue(networkError); + test('retryFetch retries until failing', async () => { + global.fetch = jest.fn().mockRejectedValue(networkError); - await expect(retryFetch('https://bar.com')).rejects.toThrow(networkError); - expect(global.fetch).toHaveBeenCalledTimes(2); + await expect(retryFetch('https://bar.com')).rejects.toThrow(networkError); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); }); - test('getValidationResults object includes all keys of window.env', async () => { - const results = await getValidationResults([]); - const resultKeys = Object.keys(results); - const envKeys = Object.keys(window.env); + describe('getValidationResults', () => { + test('getValidationResults object includes all keys of window.env', async () => { + const results = await getValidationResults([]); + const resultKeys = Object.keys(results); + const envKeys = Object.keys(window.env); - const missingKeys = envKeys.filter((key) => !resultKeys.includes(key)); - const unexpectedKeys = resultKeys.filter((key) => !envKeys.includes(key)); + const missingKeys = envKeys.filter((key) => !resultKeys.includes(key)); + const unexpectedKeys = resultKeys.filter((key) => !envKeys.includes(key)); - expect(missingKeys).toEqual([]); - expect(unexpectedKeys).toEqual([]); - }); - - test('urlIsReachable object has value if it succeeds', async () => { - global.fetch = jest.fn().mockResolvedValue(mockResponse); - const result = await urlIsReachable('https://foo.bar'); - expect(result.error).toBeUndefined(); - expect(result.status).toBe(200); - expect(result.value).toEqual('https://foo.bar'); - }); + expect(missingKeys).toEqual([]); + expect(unexpectedKeys).toEqual([]); + }); + test('getValidationResult AUTH_AUTHORITY has error if it fails reachability', async () => { + window.env.REACT_APP_AUTH_AUTHORITY = 'https://foo.bar'; + global.fetch = jest.fn().mockRejectedValue(networkError); + const results = await getValidationResults([]); + expect(results.REACT_APP_AUTH_AUTHORITY.error).toBeDefined(); + expect(results.REACT_APP_AUTH_AUTHORITY.status).toBeUndefined(); + expect(results.REACT_APP_AUTH_AUTHORITY.value).toBeUndefined(); + }); - test('urlIsReachable object has error if it fails', async () => { - global.fetch = jest.fn().mockRejectedValue(networkError); - const result = await urlIsReachable('https://foo.bar'); - expect(result.error).toBeDefined(); - expect(result.status).toBeUndefined(); - expect(result.value).toBeUndefined(); - }); + test('getValidationResult ENVIRONMENT has error if it fails parse', async () => { + window.env.REACT_APP_ENVIRONMENT = 'foo'; + const results = await getValidationResults([]); + expect(results.REACT_APP_ENVIRONMENT.error).toBeDefined(); + expect(results.REACT_APP_ENVIRONMENT.status).toBeUndefined(); + expect(results.REACT_APP_ENVIRONMENT.value).toBeUndefined(); + }); - test('getValidationResult AUTH_AUTHORITY has error if it fails reachability', async () => { - window.env.REACT_APP_AUTH_AUTHORITY = 'https://foo.bar'; - global.fetch = jest.fn().mockRejectedValue(networkError); - const results = await getValidationResults([]); - expect(results.REACT_APP_AUTH_AUTHORITY.error).toBeDefined(); - expect(results.REACT_APP_AUTH_AUTHORITY.status).toBeUndefined(); - expect(results.REACT_APP_AUTH_AUTHORITY.value).toBeUndefined(); + test('getValidationResult CLIENT_ID has value if it succeeds parse', async () => { + const results = await getValidationResults([]); + expect(results.REACT_APP_CLIENT_ID.error).toBeUndefined(); + expect(results.REACT_APP_CLIENT_ID.status).toBeUndefined(); + expect(results.REACT_APP_CLIENT_ID.value).toEqual('abc123'); + }); }); - test('getValidationResult ENVIRONMENT has error if it fails parse', async () => { - window.env.REACT_APP_ENVIRONMENT = 'foo'; - const results = await getValidationResults([]); - expect(results.REACT_APP_ENVIRONMENT.error).toBeDefined(); - expect(results.REACT_APP_ENVIRONMENT.status).toBeUndefined(); - expect(results.REACT_APP_ENVIRONMENT.value).toBeUndefined(); - }); + describe('urlIsReachable', () => { + test('urlIsReachable object has value if it succeeds', async () => { + global.fetch = jest.fn().mockResolvedValue(mockResponse); + const result = await urlIsReachable('https://foo.bar'); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(200); + expect(result.value).toEqual('https://foo.bar'); + }); - test('getValidationResult CLIENT_ID has value if it succeeds parse', async () => { - const results = await getValidationResults([]); - expect(results.REACT_APP_CLIENT_ID.error).toBeUndefined(); - expect(results.REACT_APP_CLIENT_ID.status).toBeUndefined(); - expect(results.REACT_APP_CLIENT_ID.value).toEqual('abc123'); + test('urlIsReachable object has error if it fails', async () => { + global.fetch = jest.fn().mockRejectedValue(networkError); + const result = await urlIsReachable('https://foo.bar'); + expect(result.error).toBeDefined(); + expect(result.status).toBeUndefined(); + expect(result.value).toBeUndefined(); + }); }); }); From 58bddce168c69ff91f25adf6ea2873db8672ff36 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Mon, 30 Dec 2024 18:31:39 +0100 Subject: [PATCH 14/36] Add .json to yarn format --- client/jest.config.json | 80 +++++------- client/package.json | 280 ++++++++++++++++++++-------------------- client/tsconfig.json | 17 +-- 3 files changed, 176 insertions(+), 201 deletions(-) diff --git a/client/jest.config.json b/client/jest.config.json index c6aeb5c9a..1e803bd4c 100644 --- a/client/jest.config.json +++ b/client/jest.config.json @@ -1,49 +1,33 @@ { - "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 + "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" + } +} diff --git a/client/package.json b/client/package.json index a59fe4691..77aa9ae31 100644 --- a/client/package.json +++ b/client/package.json @@ -1,142 +1,142 @@ { - "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": [ - "Asger Busk Breinholm", - "Cesar Vela", - "Emre Temel", - "Enok Maj", - "Mathias Brændgaard", - "Omar Suleiman", - "Vanessa Scherma" + "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": [ + "Asger Busk Breinholm", + "Cesar Vela", + "Emre Temel", + "Enok Maj", + "Mathias Brændgaard", + "Omar Suleiman", + "Vanessa Scherma" + ], + "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,json}\"", + "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 && yarn test:preview:unit && yarn test:preview:int", + "test:e2e:ext": "cross-env ext=true yarn test:e2e", + "test:e2e": "yarn config:test && yarn build && 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 --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", + "http-status-codes": "^2.3.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.24.1" + }, + "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" ], - "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 && yarn test:preview:unit && yarn test:preview:int", - "test:e2e:ext": "cross-env ext=true yarn test:e2e", - "test:e2e": "yarn config:test && yarn build && 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 --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", - "http-status-codes": "^2.3.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.24.1" - }, - "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 + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/client/tsconfig.json b/client/tsconfig.json index 7dc6d30ec..259e1b105 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -5,10 +5,7 @@ "sourceMap": true, //generate .map files "target": "es6", //target es6 "jsx": "react", //use react - "types": [ - "react", - "node" - ], //use react and node types + "types": ["react", "node"], //use react and node types "module": "esnext", //use esnext modules "moduleResolution": "node", //use node module resolution strategy node "experimentalDecorators": true, //allow experimental decorators for es7 @@ -21,9 +18,7 @@ "outDir": "dist", //output directory "baseUrl": "src", //base url for imports "paths": { - "test/*": [ - "../test/*" - ] + "test/*": ["../test/*"] }, "typeRoots": [ "node_modules/@types" //use node_modules/@types for type definitions @@ -31,9 +26,5 @@ "strictNullChecks": true, //enable strict null checks "resolveJsonModule": true //allow to import JSON files directly }, - "exclude": [ - "**/node_modules/*", - "babel.config.cjs", - "dist" - ] -} \ No newline at end of file + "exclude": ["**/node_modules/*", "babel.config.cjs", "dist"] +} From 60b8841fbc95b98785803582fa9d10589db1a50b Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Mon, 30 Dec 2024 19:01:51 +0100 Subject: [PATCH 15/36] Rm route/config from Eslint ignore list, fix issues --- client/eslint.config.mjs | 4 +- client/src/route/config/Config.tsx | 76 +++++++++++++++--------------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs index 6e61e7da1..c66a8e0f0 100644 --- a/client/eslint.config.mjs +++ b/client/eslint.config.mjs @@ -22,7 +22,7 @@ export default [{ ignores: [ "**/api/", "**/build/", - "**/config/", + "client/config/", "**/node_modules/", "**/script/", "**/coverage/", @@ -91,6 +91,8 @@ export default [{ "caughtErrorsIgnorePattern": "^_", } ], + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "error", "no-console": "error", "import/first": "error", "react/prop-types": "off", diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index d67e8421f..34500ba98 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -1,52 +1,50 @@ import * as React from 'react'; import { useEffect, useState } from 'react'; import { getValidationResults, validationType } from 'util/configUtil'; -import { ConfigItem, loadingComponent } from './ConfigItems'; import { Paper, Typography } from '@mui/material'; import { useNavigate } from 'react-router-dom'; +import { ConfigItem, loadingComponent } from './ConfigItems'; const DeveloperConfig = (validationResults: { [key: string]: validationType; -}): JSX.Element => { - return ( - ( + + - - {'Config verification'} - -
- {Object.entries(window.env).map(([key, value]) => ( - - ))} -
-
- ); -}; + {'Config verification'} + +
+ {Object.entries(window.env).map(([key, value]) => ( + + ))} +
+
+); const UserConfig = (): JSX.Element => { const title: JSX.Element = ( @@ -119,7 +117,7 @@ const Config = (props: { role: string }) => { if (shouldRedirect) { navigate('/signin'); } - }, [isLoading, props.role, hasConfigErrors, navigate]); + }, [shouldRedirect, navigate]); let displayedComponent = loading; From 67ded78a9ea7c2cd9d009a1e29789e32c4a617d7 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Wed, 1 Jan 2025 18:47:17 +0100 Subject: [PATCH 16/36] Strengthen flaky e2e setup --- client/playwright.config.ts | 5 +++-- client/src/util/configUtil.ts | 2 +- client/test/e2e/tests/auth.setup.ts | 3 ++- client/test/unit/util/ConfigUtil.test.ts | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/client/playwright.config.ts b/client/playwright.config.ts index 1824a614e..0a64cef9c 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -20,9 +20,10 @@ export default defineConfig({ ? undefined : { command: 'yarn start', + url: BASE_URI, }, retries: process.env.CI ? 0 : 1, // Disable retries on Github actions for now as setup always fails - timeout: process.env.CI ? 0 : 120 * 1000, // Disable timeouts on Github actions for now as setup always fails + timeout: process.env.CI ? 1000 : 30 * 1000, globalTimeout: 10 * 60 * 1000, testDir: './test/e2e/tests', testMatch: /.*\.test\.ts/, @@ -49,7 +50,7 @@ export default defineConfig({ ], // Codecov handled through Monocart-Reporter https://github.com/cenfun/monocart-reporter use: { baseURL: BASE_URI, - trace: 'on-first-retry', // Wil not record trace on Github actions because of no retries + trace: 'on', // Wil not record trace on Github actions because of no retries headless: true, }, projects: [ diff --git a/client/src/util/configUtil.ts b/client/src/util/configUtil.ts index 901663289..8a5b1e041 100644 --- a/client/src/util/configUtil.ts +++ b/client/src/util/configUtil.ts @@ -14,7 +14,7 @@ const ScopesString = z.literal('openid profile read_user read_repository api'); export async function retryFetch( url: string, options: RequestInit = {}, - retries = 1, + retries = 2, ): Promise { try { return await fetch(url, options); diff --git a/client/test/e2e/tests/auth.setup.ts b/client/test/e2e/tests/auth.setup.ts index dcb555bea..aae502b5e 100644 --- a/client/test/e2e/tests/auth.setup.ts +++ b/client/test/e2e/tests/auth.setup.ts @@ -12,9 +12,10 @@ const testPassword = process.env.REACT_APP_TEST_PASSWORD ?? ''; setup('authenticate', async ({ page }) => { // Perform authentication steps for authentication process. await page.goto('./'); + await expect(page.getByText('Verifying configuration')).toBeVisible(); await page .getByRole('button', { name: 'GitLab logo Sign In with GitLab' }) - .click(); + .click({ timeout: 15000 }); await page.waitForSelector('label[for="user_login"]', { timeout: 10000 }); // wait up to 10 seconds await page.locator('label').filter({ hasText: 'Remember me' }).click(); await page.fill('#user_login', testUsername.toString()); // Insert valid GitLab testing username. diff --git a/client/test/unit/util/ConfigUtil.test.ts b/client/test/unit/util/ConfigUtil.test.ts index 0fdcdf3cb..b44f46322 100644 --- a/client/test/unit/util/ConfigUtil.test.ts +++ b/client/test/unit/util/ConfigUtil.test.ts @@ -69,7 +69,7 @@ describe('configUtil', () => { global.fetch = jest.fn().mockRejectedValue(networkError); await expect(retryFetch('https://bar.com')).rejects.toThrow(networkError); - expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenCalledTimes(3); }); }); From 36a36529f2af8f2cf66300dc14c21dcfe352633a Mon Sep 17 00:00:00 2001 From: atomicgamedeveloper <109801255+atomicgamedeveloper@users.noreply.github.com> Date: Wed, 1 Jan 2025 18:53:50 +0100 Subject: [PATCH 17/36] Update playwright.config.ts --- client/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/playwright.config.ts b/client/playwright.config.ts index 0a64cef9c..6e2eb5a9e 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -50,7 +50,7 @@ export default defineConfig({ ], // Codecov handled through Monocart-Reporter https://github.com/cenfun/monocart-reporter use: { baseURL: BASE_URI, - trace: 'on', // Wil not record trace on Github actions because of no retries + trace: 'on-first-retry', // Wil not record trace on Github actions because of no retries headless: true, }, projects: [ From 24add3546d0820ee90aa6085030b2552806c7b14 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jan 2025 18:17:37 +0000 Subject: [PATCH 18/36] Bump cross-spawn from 7.0.3 to 7.0.6 in /client Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6. - [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md) - [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6) --- updated-dependencies: - dependency-name: cross-spawn dependency-type: indirect ... Signed-off-by: dependabot[bot] --- client/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/yarn.lock b/client/yarn.lock index 32d35f389..22cd77e21 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -4376,9 +4376,9 @@ cross-fetch@^3.0.4: node-fetch "^2.6.12" cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" From cb0fa48ea1d5ca04aee13f16dfcd366d54cdb066 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jan 2025 18:19:10 +0000 Subject: [PATCH 19/36] Bump nanoid from 3.3.7 to 3.3.8 in /client Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8. - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8) --- updated-dependencies: - dependency-name: nanoid dependency-type: indirect ... Signed-off-by: dependabot[bot] --- client/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/yarn.lock b/client/yarn.lock index 32d35f389..984f98ebf 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -8317,9 +8317,9 @@ mz@^2.7.0: thenify-all "^1.0.0" nanoid@^3.3.7: - version "3.3.7" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" - integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + version "3.3.8" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" + integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== natural-compare-lite@^1.4.0: version "1.4.0" From e533f21cd37ff357b76426e06c1d0f21b38e839e Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Wed, 1 Jan 2025 21:34:12 +0100 Subject: [PATCH 20/36] Refactor Signin function --- client/src/route/auth/Signin.tsx | 94 +++++++++++++++++++------------- 1 file changed, 56 insertions(+), 38 deletions(-) diff --git a/client/src/route/auth/Signin.tsx b/client/src/route/auth/Signin.tsx index b20d60a6a..71429ec24 100644 --- a/client/src/route/auth/Signin.tsx +++ b/client/src/route/auth/Signin.tsx @@ -12,6 +12,15 @@ function SignIn() { auth.signinRedirect(); }; + return ( + + {avatar} + {signInButton(startAuthProcess)} + + ); +} + +function BoxForSignIn(props: { children: React.ReactNode }) { return ( - - - - + {props.children} ); } +const avatar: JSX.Element = ( + + + +); + +const signInButton = (startAuthProcess: () => void) => ( + +); + +const startIcon = ( + GitLab logo +); + export default SignIn; From 239ae3ae31045516df2286c1a4953c5cd20dbefc Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Wed, 22 Jan 2025 23:50:59 +0100 Subject: [PATCH 21/36] Add Docker setup, fix more CodeCov issues, change routing --- .../route/digitaltwins/editor/PreviewTab.tsx | 17 +- client/src/route/config/Config.tsx | 125 ++++---- client/src/route/config/ConfigItems.tsx | 8 +- client/src/routes.tsx | 12 +- client/src/util/configUtil.ts | 302 +++++++++--------- client/test/__mocks__/global_mocks.ts | 6 + client/test/e2e/tests/Config.Verify.test.ts | 9 +- client/test/e2e/tests/auth.setup.ts | 1 - .../integration/Auth/WaitAndNavigate.test.tsx | 8 +- .../test/integration/Auth/authRedux.test.tsx | 8 +- .../test/integration/Routes/Signin.test.tsx | 7 +- .../unit/components/PrivateRoute.test.tsx | 7 +- client/test/unit/page/Config.test.tsx | 6 +- client/test/unit/util/ConfigUtil.test.ts | 8 +- docker/client.built.dockerfile | 19 ++ docker/compose.dev.yml | 10 + 16 files changed, 288 insertions(+), 265 deletions(-) create mode 100644 docker/client.built.dockerfile diff --git a/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx b/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx index 31acd62f8..a5e2c0efe 100644 --- a/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx +++ b/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx @@ -3,7 +3,7 @@ import { Remarkable } from 'remarkable'; import 'katex/dist/katex.min.css'; // @ts-expect-error: Ignoring TypeScript error due to missing type definitions for 'remarkable-katex'. import * as RemarkableKatex from 'remarkable-katex'; -import SyntaxHighlighter from 'react-syntax-highlighter'; +/* import SyntaxHighlighter from 'react-syntax-highlighter'; */ interface PreviewProps { fileContent: string; @@ -62,13 +62,14 @@ function PreviewTab({ fileContent, fileType }: PreviewProps) { ); } - if (fileType === 'json') { - return {fileContent}; - } - if (fileType === 'yaml' || fileType === 'yml') { - return {fileContent}; - } - return {fileContent}; + /* if (fileType === 'json') { + return {fileContent}; + } + if (fileType === 'yaml' || fileType === 'yml') { + return {fileContent}; + } + return {fileContent}; */ + return null; } export default PreviewTab; diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index 34500ba98..ef3c5e185 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -1,36 +1,39 @@ import * as React from 'react'; import { useEffect, useState } from 'react'; -import { getValidationResults, validationType } from 'util/configUtil'; +import { getValidationResults, ValidationType } from 'util/configUtil'; import { Paper, Typography } from '@mui/material'; import { useNavigate } from 'react-router-dom'; import { ConfigItem, loadingComponent } from './ConfigItems'; +const paperStyle = { + p: 2, + marginTop: '2%', + position: 'relative', + marginLeft: 'auto', + marginRight: 'auto', + display: 'flex', + flexDirection: 'column', +}; + +const typographyStyle = { + fontSize: 'clamp(0.2rem, 4vw, 1.6rem)', + padding: 'clamp(0, 4vw, 5%)', +}; + const DeveloperConfig = (validationResults: { - [key: string]: validationType; + [key: string]: ValidationType; }): JSX.Element => ( - + {'Config verification'}
@@ -46,86 +49,86 @@ const DeveloperConfig = (validationResults: { ); -const UserConfig = (): JSX.Element => { - const title: JSX.Element = ( - <> - Invalid Application Configuration. Please contact the administrator of - your DTaaS installation. -
- - Inspect configuration - - - ); - return ( +const userConfigTitle: JSX.Element = ( + <> + Invalid Application Configuration. Please contact the administrator of your + DTaaS installation. +
+ + Inspect configuration + + +); + +const UserConfig = (): JSX.Element => ( - - {title} + + {userConfigTitle} ); -}; -const Config = (props: { role: string }) => { +const useValidationResults = () => { const [validationResults, setValidationResults] = useState<{ - [key: string]: validationType; + [key: string]: ValidationType; }>({}); const [isLoading, setIsLoading] = useState(true); - const navigate = useNavigate(); useEffect(() => { const fetchValidationResults = async () => { - const results = await getValidationResults([]); - setValidationResults(results); - setIsLoading(false); + try { + const results = await getValidationResults(); + setValidationResults(results); + } finally { + setIsLoading(false); + } }; + fetchValidationResults(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [window.env]); - const loading = loadingComponent(); - const configVerification = - props.role === 'user' ? UserConfig() : DeveloperConfig(validationResults); + return { validationResults, isLoading }; +}; - const hasConfigErrors = Object.keys(window.env).some( - (key: string | undefined) => - key !== undefined && validationResults[key]?.error !== undefined, +const useConfigErrors = (validationResults: { + [key: string]: ValidationType; +}) => Object.keys(window.env).some( + (key) => key !== undefined && validationResults[key]?.error !== undefined, ); +const Config = (props: { role: string }) => { + const navigate = useNavigate(); + const { validationResults, isLoading } = useValidationResults(); + const hasConfigErrors = useConfigErrors(validationResults); + + const configVerification = + props.role === 'user' ? UserConfig() : DeveloperConfig(validationResults); + const shouldRedirect = !isLoading && props.role === 'user' && !hasConfigErrors; useEffect(() => { if (shouldRedirect) { - navigate('/signin'); + navigate('/'); } }, [shouldRedirect, navigate]); - let displayedComponent = loading; + if (isLoading) { + return loadingComponent(); + } - if (!isLoading && !shouldRedirect) { - displayedComponent = configVerification; + if (shouldRedirect) { + return null; } - return displayedComponent; + return configVerification; }; export default Config; diff --git a/client/src/route/config/ConfigItems.tsx b/client/src/route/config/ConfigItems.tsx index 5ac7a7e82..1214ac61a 100644 --- a/client/src/route/config/ConfigItems.tsx +++ b/client/src/route/config/ConfigItems.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { validationType } from 'util/configUtil'; +import { ValidationType } from 'util/configUtil'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import { Box, CircularProgress, Tooltip, Typography } from '@mui/material'; @@ -24,7 +24,7 @@ interface ValidationIconConfig { } const getValidationIconConfig = ( - validation: validationType, + validation: ValidationType, label: string, ): ValidationIconConfig => { const { value, status, error } = validation; @@ -50,7 +50,7 @@ const getValidationIconConfig = ( }; export const getConfigIcon = ( - validation: validationType, + validation: ValidationType, label: string, ): JSX.Element => { const { icon, hoverTip } = getValidationIconConfig(validation, label); @@ -60,7 +60,7 @@ export const getConfigIcon = ( export const ConfigItem: React.FC<{ label: string; value: string; - validation: validationType; + validation: ValidationType; }> = ({ label, value, validation = { error: 'Validation unavailable' } }) => (
- + + ), }, { - path: 'config/verify', + path: 'config/developer', element: ( @@ -28,10 +28,10 @@ export const routes = [ ), }, { - path: 'signin', + path: 'config/user', element: ( - - + + ), }, diff --git a/client/src/util/configUtil.ts b/client/src/util/configUtil.ts index 8a5b1e041..3553079d8 100644 --- a/client/src/util/configUtil.ts +++ b/client/src/util/configUtil.ts @@ -1,184 +1,176 @@ import { z } from 'zod'; import { wait } from 'util/auth/Authentication'; -export type validationType = { - value?: string; - status?: number; - error?: string; +export type ValidationType = { + value?: string; + status?: number; + error?: string; }; const EnvironmentEnum = z.enum(['dev', 'local', 'prod', 'test']); const PathString = z.string(); const ScopesString = z.literal('openid profile read_user read_repository api'); -export async function retryFetch( - url: string, - options: RequestInit = {}, - retries = 2, -): Promise { - try { - return await fetch(url, options); - } catch (error) { - if (retries <= 0) { - return Promise.reject(error); - } - await wait(1000); - return retryFetch(url, options, retries - 1); - } -} +const pathKeys = [ + 'REACT_APP_URL_BASENAME', + 'REACT_APP_URL_DTLINK', + 'REACT_APP_URL_LIBLINK', + 'REACT_APP_WORKBENCHLINK_VNCDESKTOP', + 'REACT_APP_WORKBENCHLINK_VSCODE', + 'REACT_APP_WORKBENCHLINK_JUPYTERLAB', + 'REACT_APP_WORKBENCHLINK_JUPYTERLAB', + 'REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK', + 'REACT_APP_CLIENT_ID', + 'REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW', + 'REACT_APP_WORKBENCHLINK_DT_PREVIEW', +]; -export const getValidationResults = async ( - keysToValidate: string[], -): Promise<{ - [key: string]: validationType; -}> => { - const allVerifications = { - REACT_APP_ENVIRONMENT: Promise.resolve( - parseField(EnvironmentEnum, window.env.REACT_APP_ENVIRONMENT), - ), - REACT_APP_URL: urlIsReachable(window.env.REACT_APP_URL), - REACT_APP_URL_BASENAME: Promise.resolve( - parseField(PathString, window.env.REACT_APP_URL_BASENAME), - ), - REACT_APP_URL_DTLINK: Promise.resolve( - parseField(PathString, window.env.REACT_APP_URL_DTLINK), - ), - REACT_APP_URL_LIBLINK: Promise.resolve( - parseField(PathString, window.env.REACT_APP_URL_LIBLINK), - ), - REACT_APP_WORKBENCHLINK_VNCDESKTOP: Promise.resolve( - parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_VNCDESKTOP), - ), - REACT_APP_WORKBENCHLINK_VSCODE: Promise.resolve( - parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_VSCODE), - ), - REACT_APP_WORKBENCHLINK_JUPYTERLAB: Promise.resolve( - parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_JUPYTERLAB), - ), - REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: Promise.resolve( - parseField( - PathString, - window.env.REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK, - ), - ), - REACT_APP_CLIENT_ID: Promise.resolve( - parseField(PathString, window.env.REACT_APP_CLIENT_ID), - ), - REACT_APP_AUTH_AUTHORITY: urlIsReachable( - window.env.REACT_APP_AUTH_AUTHORITY, - ), - REACT_APP_REDIRECT_URI: urlIsReachable(window.env.REACT_APP_REDIRECT_URI), - REACT_APP_LOGOUT_REDIRECT_URI: urlIsReachable( - window.env.REACT_APP_LOGOUT_REDIRECT_URI, - ), - REACT_APP_GITLAB_SCOPES: Promise.resolve( - parseField(ScopesString, window.env.REACT_APP_GITLAB_SCOPES), - ), - REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: Promise.resolve( - parseField( - PathString, - window.env.REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW, - ), - ), - REACT_APP_WORKBENCHLINK_DT_PREVIEW: Promise.resolve( - parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_DT_PREVIEW), - ), - }; +const urlKeys = [ + 'REACT_APP_URL', + 'REACT_APP_REDIRECT_URI', + 'REACT_APP_LOGOUT_REDIRECT_URI', + 'REACT_APP_AUTH_AUTHORITY', +]; - const verifications = - keysToValidate.length === 0 - ? allVerifications - : Object.fromEntries( - keysToValidate - .filter((key) => key in allVerifications) - .map((key) => [ - key, - allVerifications[key as keyof typeof allVerifications], +function getValidationPromises(): Record> { + const isDocker = process.env.REACT_APP_IS_DOCKER === 'true'; + return { + REACT_APP_ENVIRONMENT: Promise.resolve( + parseField(EnvironmentEnum, window.env.REACT_APP_ENVIRONMENT), + ), + REACT_APP_GITLAB_SCOPES: Promise.resolve( + parseField(ScopesString, window.env.REACT_APP_GITLAB_SCOPES), + ), + ...Object.fromEntries( + pathKeys.map((key) => [ + key, + parseField(PathString, window.env[key] ?? ''), ]), - ); + ), + ...Object.fromEntries( + urlKeys.map((key) => { + const url = window.env[key] ?? ''; + if (isDocker) { + window.env[key] = url.replace( + /https?:\/\/localhost(:\d*)?/i, + 'http://host.docker.internal:80', + ); + } + return [key, urlIsReachable(window.env[key] ?? '')]; + }), + ), + }; +} - const results = await Promise.all( - Object.entries(verifications).map(async ([key, task]) => ({ - [key]: await task, - })), - ); +export const getValidationResults = async (): Promise<{ + [key: string]: ValidationType; +}> => { + const validationPromises: Record< + string, + Promise + > = getValidationPromises(); - return results.reduce((acc, result) => ({ ...acc, ...result }), {}); + return ( + await Promise.all( + Object.entries(validationPromises).map(async ([key, task]) => ({ + [key]: await task, + })), + ) + ).reduce((acc, result) => ({ ...acc, ...result }), {}); }; -async function opaqueRequest(url: string): Promise { - const urlValidation: validationType = { - value: undefined, - status: undefined, - error: undefined, - }; - try { - await retryFetch(url, { - method: 'HEAD', - mode: 'no-cors', - signal: AbortSignal.timeout(2000), - }); - urlValidation.value = url; - urlValidation.status = 0; - } catch (error) { - urlValidation.error = `An error occurred when fetching ${url}: ${error}`; - throw error; - } - return urlValidation; +export async function retryFetch( + url: string, + options: RequestInit = {}, + retries = 2, +): Promise { + try { + return await fetch(url, options); + } catch (error) { + if (retries <= 0) { + return Promise.reject(error); + } + await wait(1000); + return retryFetch(url, options, retries - 1); + } } -async function corsRequest(url: string): Promise { - const urlValidation: validationType = { - value: undefined, - status: undefined, - error: undefined, - }; - try { - const response = await retryFetch(url, { - method: 'HEAD', - signal: AbortSignal.timeout(2000), - }); - const responseIsAcceptable = response.ok || response.redirected; - if (!responseIsAcceptable) { - urlValidation.error = `Unexpected response code ${response.status} from ${url}.`; - throw new Error(urlValidation.error); +async function corsRequest(url: string): Promise { + const urlValidation: ValidationType = { + value: undefined, + status: undefined, + error: undefined, + }; + try { + const response = await retryFetch(url, { + method: 'GET', + signal: AbortSignal.timeout(2000), + headers: { + Accept: '*/*', + Origin: window.location.origin, + }, + redirect: 'manual', + }); + const responseIsAcceptable = response.ok || response.redirected; + if (responseIsAcceptable) { + urlValidation.value = url; + urlValidation.status = response.status; + } + } catch (_error) { + return null; } - urlValidation.value = url; - urlValidation.status = response.status; - } catch (error) { - urlValidation.error = `An error occurred when fetching ${url}: ${error}`; - throw error; - } - return urlValidation; + return urlValidation; } -export async function urlIsReachable(url: string): Promise { - try { - return await corsRequest(url); - } catch (_corsError) { +async function opaqueRequest(url: string): Promise { + const urlValidation: ValidationType = { + value: undefined, + status: undefined, + error: undefined, + }; try { - return await opaqueRequest(url); - } catch (opaqueError) { - return { + await retryFetch(url, { + method: 'GET', + mode: 'no-cors', + signal: AbortSignal.timeout(2000), + }); + urlValidation.value = url; + urlValidation.status = 0; + } catch (_error) { + return null; + } + return urlValidation; +} + +export async function urlIsReachable(url: string): Promise { + let reachability: ValidationType = { value: undefined, status: undefined, - error: `Failed to fetch ${url} after multiple attempts: ${opaqueError instanceof Error ? opaqueError.message : opaqueError}`, - }; + error: `Failed to fetch ${url} after multiple attempts.`, + }; + const corsResponse = await corsRequest(url); + if (corsResponse) { + reachability = corsResponse; + } else { + const opaqueResponse = await opaqueRequest(url); + if (opaqueResponse) { + reachability = opaqueResponse; + } } - } + return reachability; } const parseField = ( - parser: { - safeParse: (value: string) => { - success: boolean; - error?: { message?: string }; - }; - }, - value: string, -): validationType => { - const result = parser.safeParse(value); - return result.success - ? { error: undefined, value, status: undefined } - : { error: result.error?.message, status: undefined, value: undefined }; + parser: { + safeParse: (value: string) => { + success: boolean; + error?: { message?: string }; + }; + }, + value: string, +): ValidationType => { + const result = parser.safeParse(value); + return result.success + ? { error: undefined, value, status: undefined } + : { error: result.error?.message, status: undefined, value: undefined }; }; diff --git a/client/test/__mocks__/global_mocks.ts b/client/test/__mocks__/global_mocks.ts index 6c1071c2a..5e5020a14 100644 --- a/client/test/__mocks__/global_mocks.ts +++ b/client/test/__mocks__/global_mocks.ts @@ -28,6 +28,12 @@ export const mockUser: mockUserType = { }, }; +export const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: 'success' }), +}); + export type mockAuthStateType = { user?: mockUserType | null; isLoading: boolean; diff --git a/client/test/e2e/tests/Config.Verify.test.ts b/client/test/e2e/tests/Config.Verify.test.ts index f46acac71..6fdbef4ef 100644 --- a/client/test/e2e/tests/Config.Verify.test.ts +++ b/client/test/e2e/tests/Config.Verify.test.ts @@ -2,7 +2,8 @@ import test from 'test/e2e/setup/fixtures'; import { expect } from '@playwright/test'; test('Developer config is visible', async ({ page }) => { - await page.goto('./config/verify'); + await page.goto('./config/developer'); + await expect(page.getByText('Verifying configuration')).toBeVisible(); await page.waitForSelector('[data-testid="success-icon"]', { timeout: 12000, @@ -28,3 +29,9 @@ test('Developer config is visible', async ({ page }) => { await expect(page.getByTestId('error-icon')).toBeHidden(); }); + +test('User config is visible', async ({ page }) => { + await page.goto('./config/user'); + await expect(page.getByText('Verifying configuration')).toBeVisible(); + await page.waitForURL('/', { timeout: 12000 }); +}); diff --git a/client/test/e2e/tests/auth.setup.ts b/client/test/e2e/tests/auth.setup.ts index aae502b5e..cf6aba5c1 100644 --- a/client/test/e2e/tests/auth.setup.ts +++ b/client/test/e2e/tests/auth.setup.ts @@ -12,7 +12,6 @@ const testPassword = process.env.REACT_APP_TEST_PASSWORD ?? ''; setup('authenticate', async ({ page }) => { // Perform authentication steps for authentication process. await page.goto('./'); - await expect(page.getByText('Verifying configuration')).toBeVisible(); await page .getByRole('button', { name: 'GitLab logo Sign In with GitLab' }) .click({ timeout: 15000 }); diff --git a/client/test/integration/Auth/WaitAndNavigate.test.tsx b/client/test/integration/Auth/WaitAndNavigate.test.tsx index b9a48d4f7..16318f102 100644 --- a/client/test/integration/Auth/WaitAndNavigate.test.tsx +++ b/client/test/integration/Auth/WaitAndNavigate.test.tsx @@ -1,15 +1,11 @@ import { act, screen, waitFor } from '@testing-library/react'; -import { mockAuthState } from 'test/__mocks__/global_mocks'; +import { mockAuthState, mockFetch } from 'test/__mocks__/global_mocks'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; jest.useFakeTimers(); // Bypass the config verification -global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ data: 'success' }), -}); +global.fetch = mockFetch; Object.defineProperty(AbortSignal, 'timeout', { value: jest.fn(), diff --git a/client/test/integration/Auth/authRedux.test.tsx b/client/test/integration/Auth/authRedux.test.tsx index 9f3ebb254..5b2c9155c 100644 --- a/client/test/integration/Auth/authRedux.test.tsx +++ b/client/test/integration/Auth/authRedux.test.tsx @@ -5,7 +5,7 @@ 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 { mockFetch, mockUser } from 'test/__mocks__/global_mocks'; import { renderWithRouter } from 'test/unit/unit.testUtil'; jest.mock('util/auth/Authentication', () => ({ @@ -23,11 +23,7 @@ jest.mock('page/Menu', () => ({ })); // Bypass the config verification -global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ data: 'success' }), -}); +global.fetch = mockFetch; Object.defineProperty(AbortSignal, 'timeout', { value: jest.fn(), diff --git a/client/test/integration/Routes/Signin.test.tsx b/client/test/integration/Routes/Signin.test.tsx index 0a0fd32d4..c056ae5e4 100644 --- a/client/test/integration/Routes/Signin.test.tsx +++ b/client/test/integration/Routes/Signin.test.tsx @@ -1,13 +1,10 @@ import { screen } from '@testing-library/react'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; +import { mockFetch } from 'test/__mocks__/global_mocks'; import { testPublicLayout } from './routes.testUtil'; // Bypass the config verification -global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ data: 'success' }), -}); +global.fetch = mockFetch; Object.defineProperty(AbortSignal, 'timeout', { value: jest.fn(), diff --git a/client/test/unit/components/PrivateRoute.test.tsx b/client/test/unit/components/PrivateRoute.test.tsx index 2a25e7cf8..bfe4c27d3 100644 --- a/client/test/unit/components/PrivateRoute.test.tsx +++ b/client/test/unit/components/PrivateRoute.test.tsx @@ -3,17 +3,14 @@ import { screen, waitFor } from '@testing-library/react'; import { useAuth } from 'react-oidc-context'; import PrivateRoute from 'route/auth/PrivateRoute'; import { renderWithRouter } from 'test/unit/unit.testUtil'; +import { mockFetch } from 'test/__mocks__/global_mocks'; jest.mock('react-oidc-context', () => ({ useAuth: jest.fn(), })); // Bypass the config verification -global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ data: 'success' }), -}); +global.fetch = mockFetch; Object.defineProperty(AbortSignal, 'timeout', { value: jest.fn(), diff --git a/client/test/unit/page/Config.test.tsx b/client/test/unit/page/Config.test.tsx index 531bd0eda..fd83e54af 100644 --- a/client/test/unit/page/Config.test.tsx +++ b/client/test/unit/page/Config.test.tsx @@ -73,10 +73,10 @@ describe('Config', () => { name: /Inspect configuration/i, }); expect(linkToDeveloperConfig).toBeInTheDocument(); - expect(linkToDeveloperConfig).toHaveAttribute('href', './config/verify'); + expect(linkToDeveloperConfig).toHaveAttribute('href', './developer'); }); - test('redirects to /signin', async () => { + test('redirects to /', async () => { render( @@ -87,7 +87,7 @@ describe('Config', () => { expect(screen.getByTestId('loading-icon')).toBeInTheDocument(); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/signin'); + expect(mockNavigate).toHaveBeenCalledWith('/'); }); }); }); diff --git a/client/test/unit/util/ConfigUtil.test.ts b/client/test/unit/util/ConfigUtil.test.ts index b44f46322..ee5115c91 100644 --- a/client/test/unit/util/ConfigUtil.test.ts +++ b/client/test/unit/util/ConfigUtil.test.ts @@ -75,7 +75,7 @@ describe('configUtil', () => { describe('getValidationResults', () => { test('getValidationResults object includes all keys of window.env', async () => { - const results = await getValidationResults([]); + const results = await getValidationResults(); const resultKeys = Object.keys(results); const envKeys = Object.keys(window.env); @@ -88,7 +88,7 @@ describe('configUtil', () => { test('getValidationResult AUTH_AUTHORITY has error if it fails reachability', async () => { window.env.REACT_APP_AUTH_AUTHORITY = 'https://foo.bar'; global.fetch = jest.fn().mockRejectedValue(networkError); - const results = await getValidationResults([]); + const results = await getValidationResults(); expect(results.REACT_APP_AUTH_AUTHORITY.error).toBeDefined(); expect(results.REACT_APP_AUTH_AUTHORITY.status).toBeUndefined(); expect(results.REACT_APP_AUTH_AUTHORITY.value).toBeUndefined(); @@ -96,14 +96,14 @@ describe('configUtil', () => { test('getValidationResult ENVIRONMENT has error if it fails parse', async () => { window.env.REACT_APP_ENVIRONMENT = 'foo'; - const results = await getValidationResults([]); + const results = await getValidationResults(); expect(results.REACT_APP_ENVIRONMENT.error).toBeDefined(); expect(results.REACT_APP_ENVIRONMENT.status).toBeUndefined(); expect(results.REACT_APP_ENVIRONMENT.value).toBeUndefined(); }); test('getValidationResult CLIENT_ID has value if it succeeds parse', async () => { - const results = await getValidationResults([]); + const results = await getValidationResults(); expect(results.REACT_APP_CLIENT_ID.error).toBeUndefined(); expect(results.REACT_APP_CLIENT_ID.status).toBeUndefined(); expect(results.REACT_APP_CLIENT_ID.value).toEqual('abc123'); diff --git a/docker/client.built.dockerfile b/docker/client.built.dockerfile new file mode 100644 index 000000000..9af75d94a --- /dev/null +++ b/docker/client.built.dockerfile @@ -0,0 +1,19 @@ +#! docker should be run from the root directory of the project +FROM node:20.10.0-slim as build + +ARG REACT_APP_IS_DOCKER +ENV REACT_APP_IS_DOCKER=$REACT_APP_IS_DOCKER + +# Set the working directory inside the container +WORKDIR /dtaas/client + +# Copy package.json and package-lock.json to the working directory +COPY ./client/package.json ./ + +# Copy the rest of the application code to the working directory +COPY ./client/ . + +WORKDIR /dtaas/client +RUN npm i -g serve +# Define the command to run your app +CMD ["yarn", "start"] \ No newline at end of file diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index 830ecf3cb..987985ec4 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -9,6 +9,12 @@ services: - "--entrypoints.web.forwardedHeaders.insecure=true" - "--entrypoints.web.proxyProtocol.insecure=true" - "--log.level=DEBUG" + labels: + - "traefik.http.middlewares.cors.headers.accessControlAllowOriginList=http://localhost,http://host.docker.internal" + - "traefik.http.middlewares.cors.headers.accessControlAllowMethods=GET,OPTIONS" + - "traefik.http.middlewares.cors.headers.accessControlAllowCredentials=true" + - "traefik.http.routers.myservice.rule=Host(`myservice.localhost`)" + - "traefik.http.services.myservice.loadbalancer.server.port=80" ports: - "80:80" volumes: @@ -21,6 +27,10 @@ services: build: context: ${DTAAS_DIR}/ dockerfile: ${DTAAS_DIR}/docker/client.dockerfile + args: + - REACT_APP_IS_DOCKER=true + environment: + - REACT_APP_IS_DOCKER=true restart: unless-stopped volumes: - "${DTAAS_DIR}/client/config/local.js:/dtaas/client/build/env.js" From 8edc264ba6c5f5662813cbd3a21c64db4eef6aaa Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Wed, 22 Jan 2025 23:58:24 +0100 Subject: [PATCH 22/36] Undo PreviewTab.tsx changes --- .../route/digitaltwins/editor/PreviewTab.tsx | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx b/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx index a5e2c0efe..1a96f5db2 100644 --- a/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx +++ b/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx @@ -3,41 +3,41 @@ import { Remarkable } from 'remarkable'; import 'katex/dist/katex.min.css'; // @ts-expect-error: Ignoring TypeScript error due to missing type definitions for 'remarkable-katex'. import * as RemarkableKatex from 'remarkable-katex'; -/* import SyntaxHighlighter from 'react-syntax-highlighter'; */ +import SyntaxHighlighter from 'react-syntax-highlighter'; interface PreviewProps { - fileContent: string; - fileType: string; + fileContent: string; + fileType: string; } function PreviewTab({ fileContent, fileType }: PreviewProps) { - if (fileType === 'md') { - const md = new Remarkable({ - html: true, - typographer: true, - }).use(RemarkableKatex); + if (fileType === 'md') { + const md = new Remarkable({ + html: true, + typographer: true, + }).use(RemarkableKatex); - const renderedMarkdown = md.render(fileContent); + const renderedMarkdown = md.render(fileContent); - return ( -
-
- -
- ); - } +
+ ); + } - /* if (fileType === 'json') { + if (fileType === 'json') { return {fileContent}; - } - if (fileType === 'yaml' || fileType === 'yml') { + } + if (fileType === 'yaml' || fileType === 'yml') { return {fileContent}; - } - return {fileContent}; */ - return null; + } + return {fileContent}; + return null; } export default PreviewTab; From 026a14d47d980087ecd6c9e5f678691715488d57 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Wed, 29 Jan 2025 07:52:49 +0100 Subject: [PATCH 23/36] Remove superfluous Docker checks, add missing eslint rule and remove temporary code --- client/eslint.config.mjs | 1 + .../route/digitaltwins/editor/PreviewTab.tsx | 75 +++-- client/src/route/config/Config.tsx | 29 +- client/src/util/configUtil.ts | 256 +++++++++--------- docker/compose.dev.yml | 15 +- 5 files changed, 181 insertions(+), 195 deletions(-) diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs index c66a8e0f0..b575050fd 100644 --- a/client/eslint.config.mjs +++ b/client/eslint.config.mjs @@ -100,6 +100,7 @@ export default [{ "import/no-unresolved": "off", "import/extensions": "off", "no-use-before-define": "off", + "no-unreachable": "error", }, }, { files: ["**/*.ts", "**/*.tsx"], diff --git a/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx b/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx index 1a96f5db2..31acd62f8 100644 --- a/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx +++ b/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx @@ -6,38 +6,38 @@ import * as RemarkableKatex from 'remarkable-katex'; import SyntaxHighlighter from 'react-syntax-highlighter'; interface PreviewProps { - fileContent: string; - fileType: string; + fileContent: string; + fileType: string; } function PreviewTab({ fileContent, fileType }: PreviewProps) { - if (fileType === 'md') { - const md = new Remarkable({ - html: true, - typographer: true, - }).use(RemarkableKatex); + if (fileType === 'md') { + const md = new Remarkable({ + html: true, + typographer: true, + }).use(RemarkableKatex); - const renderedMarkdown = md.render(fileContent); + const renderedMarkdown = md.render(fileContent); - return ( -
-
- -
- ); - } +
+ ); + } - if (fileType === 'json') { - return {fileContent}; - } - if (fileType === 'yaml' || fileType === 'yml') { - return {fileContent}; - } - return {fileContent}; - return null; + if (fileType === 'json') { + return {fileContent}; + } + if (fileType === 'yaml' || fileType === 'yml') { + return {fileContent}; + } + return {fileContent}; } export default PreviewTab; diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index ef3c5e185..257b310dc 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -61,19 +61,19 @@ const userConfigTitle: JSX.Element = ( ); const UserConfig = (): JSX.Element => ( - - - {userConfigTitle} - - - ); + + + {userConfigTitle} + + +); const useValidationResults = () => { const [validationResults, setValidationResults] = useState<{ @@ -100,7 +100,8 @@ const useValidationResults = () => { const useConfigErrors = (validationResults: { [key: string]: ValidationType; -}) => Object.keys(window.env).some( +}) => + Object.keys(window.env).some( (key) => key !== undefined && validationResults[key]?.error !== undefined, ); diff --git a/client/src/util/configUtil.ts b/client/src/util/configUtil.ts index 3553079d8..e80536707 100644 --- a/client/src/util/configUtil.ts +++ b/client/src/util/configUtil.ts @@ -2,9 +2,9 @@ import { z } from 'zod'; import { wait } from 'util/auth/Authentication'; export type ValidationType = { - value?: string; - status?: number; - error?: string; + value?: string; + status?: number; + error?: string; }; const EnvironmentEnum = z.enum(['dev', 'local', 'prod', 'test']); @@ -12,165 +12,155 @@ const PathString = z.string(); const ScopesString = z.literal('openid profile read_user read_repository api'); const pathKeys = [ - 'REACT_APP_URL_BASENAME', - 'REACT_APP_URL_DTLINK', - 'REACT_APP_URL_LIBLINK', - 'REACT_APP_WORKBENCHLINK_VNCDESKTOP', - 'REACT_APP_WORKBENCHLINK_VSCODE', - 'REACT_APP_WORKBENCHLINK_JUPYTERLAB', - 'REACT_APP_WORKBENCHLINK_JUPYTERLAB', - 'REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK', - 'REACT_APP_CLIENT_ID', - 'REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW', - 'REACT_APP_WORKBENCHLINK_DT_PREVIEW', + 'REACT_APP_URL_BASENAME', + 'REACT_APP_URL_DTLINK', + 'REACT_APP_URL_LIBLINK', + 'REACT_APP_WORKBENCHLINK_VNCDESKTOP', + 'REACT_APP_WORKBENCHLINK_VSCODE', + 'REACT_APP_WORKBENCHLINK_JUPYTERLAB', + 'REACT_APP_WORKBENCHLINK_JUPYTERLAB', + 'REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK', + 'REACT_APP_CLIENT_ID', + 'REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW', + 'REACT_APP_WORKBENCHLINK_DT_PREVIEW', ]; const urlKeys = [ - 'REACT_APP_URL', - 'REACT_APP_REDIRECT_URI', - 'REACT_APP_LOGOUT_REDIRECT_URI', - 'REACT_APP_AUTH_AUTHORITY', + 'REACT_APP_URL', + 'REACT_APP_REDIRECT_URI', + 'REACT_APP_LOGOUT_REDIRECT_URI', + 'REACT_APP_AUTH_AUTHORITY', ]; function getValidationPromises(): Record> { - const isDocker = process.env.REACT_APP_IS_DOCKER === 'true'; - return { - REACT_APP_ENVIRONMENT: Promise.resolve( - parseField(EnvironmentEnum, window.env.REACT_APP_ENVIRONMENT), - ), - REACT_APP_GITLAB_SCOPES: Promise.resolve( - parseField(ScopesString, window.env.REACT_APP_GITLAB_SCOPES), - ), - ...Object.fromEntries( - pathKeys.map((key) => [ - key, - parseField(PathString, window.env[key] ?? ''), - ]), - ), - ...Object.fromEntries( - urlKeys.map((key) => { - const url = window.env[key] ?? ''; - if (isDocker) { - window.env[key] = url.replace( - /https?:\/\/localhost(:\d*)?/i, - 'http://host.docker.internal:80', - ); - } - return [key, urlIsReachable(window.env[key] ?? '')]; - }), - ), - }; + return { + REACT_APP_ENVIRONMENT: Promise.resolve( + parseField(EnvironmentEnum, window.env.REACT_APP_ENVIRONMENT), + ), + REACT_APP_GITLAB_SCOPES: Promise.resolve( + parseField(ScopesString, window.env.REACT_APP_GITLAB_SCOPES), + ), + ...Object.fromEntries( + pathKeys.map((key) => [ + key, + parseField(PathString, window.env[key] ?? ''), + ]), + ), + ...Object.fromEntries( + urlKeys.map((key) => [key, urlIsReachable(window.env[key] ?? '')]), + ), + }; } export const getValidationResults = async (): Promise<{ - [key: string]: ValidationType; + [key: string]: ValidationType; }> => { - const validationPromises: Record< - string, - Promise - > = getValidationPromises(); + const validationPromises: Record< + string, + Promise + > = getValidationPromises(); - return ( - await Promise.all( - Object.entries(validationPromises).map(async ([key, task]) => ({ - [key]: await task, - })), - ) - ).reduce((acc, result) => ({ ...acc, ...result }), {}); + return ( + await Promise.all( + Object.entries(validationPromises).map(async ([key, task]) => ({ + [key]: await task, + })), + ) + ).reduce((acc, result) => ({ ...acc, ...result }), {}); }; export async function retryFetch( - url: string, - options: RequestInit = {}, - retries = 2, + url: string, + options: RequestInit = {}, + retries = 2, ): Promise { - try { - return await fetch(url, options); - } catch (error) { - if (retries <= 0) { - return Promise.reject(error); - } - await wait(1000); - return retryFetch(url, options, retries - 1); + try { + return await fetch(url, options); + } catch (error) { + if (retries <= 0) { + return Promise.reject(error); } + await wait(1000); + return retryFetch(url, options, retries - 1); + } } async function corsRequest(url: string): Promise { - const urlValidation: ValidationType = { - value: undefined, - status: undefined, - error: undefined, - }; - try { - const response = await retryFetch(url, { - method: 'GET', - signal: AbortSignal.timeout(2000), - headers: { - Accept: '*/*', - Origin: window.location.origin, - }, - redirect: 'manual', - }); - const responseIsAcceptable = response.ok || response.redirected; - if (responseIsAcceptable) { - urlValidation.value = url; - urlValidation.status = response.status; - } - } catch (_error) { - return null; + const urlValidation: ValidationType = { + value: undefined, + status: undefined, + error: undefined, + }; + try { + const response = await retryFetch(url, { + method: 'GET', + signal: AbortSignal.timeout(2000), + headers: { + Accept: '*/*', + Origin: window.location.origin, + }, + redirect: 'manual', + }); + const responseIsAcceptable = response.ok || response.redirected; + if (responseIsAcceptable) { + urlValidation.value = url; + urlValidation.status = response.status; } - return urlValidation; + } catch (_error) { + return null; + } + return urlValidation; } async function opaqueRequest(url: string): Promise { - const urlValidation: ValidationType = { - value: undefined, - status: undefined, - error: undefined, - }; - try { - await retryFetch(url, { - method: 'GET', - mode: 'no-cors', - signal: AbortSignal.timeout(2000), - }); - urlValidation.value = url; - urlValidation.status = 0; - } catch (_error) { - return null; - } - return urlValidation; + const urlValidation: ValidationType = { + value: undefined, + status: undefined, + error: undefined, + }; + try { + await retryFetch(url, { + method: 'GET', + mode: 'no-cors', + signal: AbortSignal.timeout(2000), + }); + urlValidation.value = url; + urlValidation.status = 0; + } catch (_error) { + return null; + } + return urlValidation; } export async function urlIsReachable(url: string): Promise { - let reachability: ValidationType = { - value: undefined, - status: undefined, - error: `Failed to fetch ${url} after multiple attempts.`, - }; - const corsResponse = await corsRequest(url); - if (corsResponse) { - reachability = corsResponse; - } else { - const opaqueResponse = await opaqueRequest(url); - if (opaqueResponse) { - reachability = opaqueResponse; - } + let reachability: ValidationType = { + value: undefined, + status: undefined, + error: `Failed to fetch ${url} after multiple attempts.`, + }; + const corsResponse = await corsRequest(url); + if (corsResponse) { + reachability = corsResponse; + } else { + const opaqueResponse = await opaqueRequest(url); + if (opaqueResponse) { + reachability = opaqueResponse; } - return reachability; + } + return reachability; } const parseField = ( - parser: { - safeParse: (value: string) => { - success: boolean; - error?: { message?: string }; - }; - }, - value: string, + parser: { + safeParse: (value: string) => { + success: boolean; + error?: { message?: string }; + }; + }, + value: string, ): ValidationType => { - const result = parser.safeParse(value); - return result.success - ? { error: undefined, value, status: undefined } - : { error: result.error?.message, status: undefined, value: undefined }; + const result = parser.safeParse(value); + return result.success + ? { error: undefined, value, status: undefined } + : { error: result.error?.message, status: undefined, value: undefined }; }; diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index 987985ec4..38b3f42ed 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -1,3 +1,4 @@ +version: '3' services: traefik: image: traefik:v2.10 @@ -8,7 +9,6 @@ services: - "--entryPoints.web.address=:80" - "--entrypoints.web.forwardedHeaders.insecure=true" - "--entrypoints.web.proxyProtocol.insecure=true" - - "--log.level=DEBUG" labels: - "traefik.http.middlewares.cors.headers.accessControlAllowOriginList=http://localhost,http://host.docker.internal" - "traefik.http.middlewares.cors.headers.accessControlAllowMethods=GET,OPTIONS" @@ -27,10 +27,6 @@ services: build: context: ${DTAAS_DIR}/ dockerfile: ${DTAAS_DIR}/docker/client.dockerfile - args: - - REACT_APP_IS_DOCKER=true - environment: - - REACT_APP_IS_DOCKER=true restart: unless-stopped volumes: - "${DTAAS_DIR}/client/config/local.js:/dtaas/client/build/env.js" @@ -49,8 +45,8 @@ services: dockerfile: ${DTAAS_DIR}/docker/libms.dockerfile restart: unless-stopped volumes: + - ${DTAAS_DIR}/servers/lib/config/.env.default:/dtaas/libms/.env - ${DTAAS_DIR}/files:/dtaas/libms/files - - ${DTAAS_DIR}/servers/lib/config/libms.dev.yaml:/dtaas/libms/config/libms.yaml labels: - "traefik.enable=true" - "traefik.http.routers.libms.entryPoints=web" @@ -100,20 +96,19 @@ services: image: thomseddon/traefik-forward-auth:latest restart: unless-stopped volumes: - - ${DTAAS_DIR}/docker/conf.dev:/conf + - ${OAUTH_CONF_FILEPATH}:/conf environment: - DEFAULT_PROVIDER=generic-oauth - PROVIDERS_GENERIC_OAUTH_AUTH_URL=${OAUTH_URL}/oauth/authorize - PROVIDERS_GENERIC_OAUTH_TOKEN_URL=${OAUTH_URL}/oauth/token - PROVIDERS_GENERIC_OAUTH_USER_URL=${OAUTH_URL}/api/v4/user - - PROVIDERS_GENERIC_OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID} - - PROVIDERS_GENERIC_OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET} + - PROVIDERS_GENERIC_OAUTH_CLIENT_ID=${CLIENT_ID} + - PROVIDERS_GENERIC_OAUTH_CLIENT_SECRET=${CLIENT_SECRET} - PROVIDERS_GENERIC_OAUTH_SCOPE=read_user - SECRET= ${OAUTH_SECRET} # INSECURE_COOKIE is required if not using a https entrypoint - INSECURE_COOKIE=true - CONFIG=/conf - - LOG_LEVEL=debug labels: - "traefik.enable=true" From 33dd22f7787fa5ab68ef98109b8ee2bd1baf54df Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Thu, 13 Feb 2025 23:18:04 +0100 Subject: [PATCH 24/36] Rise codeclimate mass_thresshold, add + test message for valid userconfig, update compose.dev.yml --- .codeclimate.yml | 2 +- client/src/route/config/Config.tsx | 60 +++++++++---------- client/test/__mocks__/global_mocks.ts | 6 -- client/test/e2e/tests/Config.Verify.test.ts | 4 +- .../integration/Auth/WaitAndNavigate.test.tsx | 8 ++- .../test/integration/Auth/authRedux.test.tsx | 8 ++- .../test/integration/Routes/Signin.test.tsx | 7 ++- .../unit/components/PrivateRoute.test.tsx | 8 ++- client/test/unit/page/Config.test.tsx | 16 +++-- docker/compose.dev.yml | 11 ++-- 10 files changed, 72 insertions(+), 58 deletions(-) diff --git a/.codeclimate.yml b/.codeclimate.yml index a46b329b7..c5faba195 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -10,7 +10,7 @@ plugins: count_threshold: 3 languages: typescript: - mass_threshold: 45 + mass_threshold: 50 javascript: python: python_version: 3 diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index 257b310dc..953769718 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { useEffect, useState } from 'react'; import { getValidationResults, ValidationType } from 'util/configUtil'; import { Paper, Typography } from '@mui/material'; -import { useNavigate } from 'react-router-dom'; import { ConfigItem, loadingComponent } from './ConfigItems'; const paperStyle = { @@ -49,7 +48,7 @@ const DeveloperConfig = (validationResults: { ); -const userConfigTitle: JSX.Element = ( +const userConfigInvalidText: JSX.Element = ( <> Invalid Application Configuration. Please contact the administrator of your DTaaS installation. @@ -60,21 +59,33 @@ const userConfigTitle: JSX.Element = ( ); -const UserConfig = (): JSX.Element => ( - - - {userConfigTitle} - - +const userConfigValidText: JSX.Element = ( + <> +

Configuration appears to be valid.

+ Return to login + ); +const UserConfig = (validationResults: { + [key: string]: ValidationType; +}): JSX.Element => { + const hasConfigErrors = useConfigErrors(validationResults); + return ( + + + {hasConfigErrors ? userConfigInvalidText : userConfigValidText} + + + ); +}; + const useValidationResults = () => { const [validationResults, setValidationResults] = useState<{ [key: string]: ValidationType; @@ -106,29 +117,16 @@ const useConfigErrors = (validationResults: { ); const Config = (props: { role: string }) => { - const navigate = useNavigate(); const { validationResults, isLoading } = useValidationResults(); - const hasConfigErrors = useConfigErrors(validationResults); - const configVerification = - props.role === 'user' ? UserConfig() : DeveloperConfig(validationResults); - - const shouldRedirect = - !isLoading && props.role === 'user' && !hasConfigErrors; - useEffect(() => { - if (shouldRedirect) { - navigate('/'); - } - }, [shouldRedirect, navigate]); + props.role === 'user' + ? UserConfig(validationResults) + : DeveloperConfig(validationResults); if (isLoading) { return loadingComponent(); } - if (shouldRedirect) { - return null; - } - return configVerification; }; diff --git a/client/test/__mocks__/global_mocks.ts b/client/test/__mocks__/global_mocks.ts index 5e5020a14..6c1071c2a 100644 --- a/client/test/__mocks__/global_mocks.ts +++ b/client/test/__mocks__/global_mocks.ts @@ -28,12 +28,6 @@ export const mockUser: mockUserType = { }, }; -export const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ data: 'success' }), -}); - export type mockAuthStateType = { user?: mockUserType | null; isLoading: boolean; diff --git a/client/test/e2e/tests/Config.Verify.test.ts b/client/test/e2e/tests/Config.Verify.test.ts index 6fdbef4ef..f6ae29bc4 100644 --- a/client/test/e2e/tests/Config.Verify.test.ts +++ b/client/test/e2e/tests/Config.Verify.test.ts @@ -33,5 +33,7 @@ test('Developer config is visible', async ({ page }) => { test('User config is visible', async ({ page }) => { await page.goto('./config/user'); await expect(page.getByText('Verifying configuration')).toBeVisible(); - await page.waitForURL('/', { timeout: 12000 }); + await expect( + page.getByText('Configuration appears to be valid.'), + ).toBeVisible({ timeout: 12000 }); }); diff --git a/client/test/integration/Auth/WaitAndNavigate.test.tsx b/client/test/integration/Auth/WaitAndNavigate.test.tsx index 16318f102..b9a48d4f7 100644 --- a/client/test/integration/Auth/WaitAndNavigate.test.tsx +++ b/client/test/integration/Auth/WaitAndNavigate.test.tsx @@ -1,11 +1,15 @@ import { act, screen, waitFor } from '@testing-library/react'; -import { mockAuthState, mockFetch } from 'test/__mocks__/global_mocks'; +import { mockAuthState } from 'test/__mocks__/global_mocks'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; jest.useFakeTimers(); // Bypass the config verification -global.fetch = mockFetch; +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: 'success' }), +}); Object.defineProperty(AbortSignal, 'timeout', { value: jest.fn(), diff --git a/client/test/integration/Auth/authRedux.test.tsx b/client/test/integration/Auth/authRedux.test.tsx index 5b2c9155c..9f3ebb254 100644 --- a/client/test/integration/Auth/authRedux.test.tsx +++ b/client/test/integration/Auth/authRedux.test.tsx @@ -5,7 +5,7 @@ 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 { mockFetch, mockUser } from 'test/__mocks__/global_mocks'; +import { mockUser } from 'test/__mocks__/global_mocks'; import { renderWithRouter } from 'test/unit/unit.testUtil'; jest.mock('util/auth/Authentication', () => ({ @@ -23,7 +23,11 @@ jest.mock('page/Menu', () => ({ })); // Bypass the config verification -global.fetch = mockFetch; +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: 'success' }), +}); Object.defineProperty(AbortSignal, 'timeout', { value: jest.fn(), diff --git a/client/test/integration/Routes/Signin.test.tsx b/client/test/integration/Routes/Signin.test.tsx index c056ae5e4..0a0fd32d4 100644 --- a/client/test/integration/Routes/Signin.test.tsx +++ b/client/test/integration/Routes/Signin.test.tsx @@ -1,10 +1,13 @@ import { screen } from '@testing-library/react'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; -import { mockFetch } from 'test/__mocks__/global_mocks'; import { testPublicLayout } from './routes.testUtil'; // Bypass the config verification -global.fetch = mockFetch; +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: 'success' }), +}); Object.defineProperty(AbortSignal, 'timeout', { value: jest.fn(), diff --git a/client/test/unit/components/PrivateRoute.test.tsx b/client/test/unit/components/PrivateRoute.test.tsx index bfe4c27d3..f4ec55136 100644 --- a/client/test/unit/components/PrivateRoute.test.tsx +++ b/client/test/unit/components/PrivateRoute.test.tsx @@ -3,15 +3,17 @@ import { screen, waitFor } from '@testing-library/react'; import { useAuth } from 'react-oidc-context'; import PrivateRoute from 'route/auth/PrivateRoute'; import { renderWithRouter } from 'test/unit/unit.testUtil'; -import { mockFetch } from 'test/__mocks__/global_mocks'; jest.mock('react-oidc-context', () => ({ useAuth: jest.fn(), })); // Bypass the config verification -global.fetch = mockFetch; - +global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: 'success' }), +}); Object.defineProperty(AbortSignal, 'timeout', { value: jest.fn(), writable: false, diff --git a/client/test/unit/page/Config.test.tsx b/client/test/unit/page/Config.test.tsx index fd83e54af..4e2b3f820 100644 --- a/client/test/unit/page/Config.test.tsx +++ b/client/test/unit/page/Config.test.tsx @@ -53,7 +53,7 @@ describe('Config', () => { ).toBeInTheDocument(); }); - test('renders UserConfig correctly', async () => { + test('renders invalid UserConfig correctly', async () => { // Invalidate one config field to show user config window.env.REACT_APP_GITLAB_SCOPES = 'invalid'; render( @@ -76,7 +76,7 @@ describe('Config', () => { expect(linkToDeveloperConfig).toHaveAttribute('href', './developer'); }); - test('redirects to /', async () => { + test('renders valid UserConfig correctly', async () => { render( @@ -85,9 +85,15 @@ describe('Config', () => { expect(screen.getByText(/Verifying configuration/i)).toBeInTheDocument(); expect(screen.getByTestId('loading-icon')).toBeInTheDocument(); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/'); + await waitFor(() => + expect( + screen.getByText(/Configuration appears to be valid./i), + ).toBeInTheDocument(), + ); + const linkToDeveloperConfig = screen.getByRole('link', { + name: /Return to login/i, }); + expect(linkToDeveloperConfig).toBeInTheDocument(); + expect(linkToDeveloperConfig).toHaveAttribute('href', '/'); }); }); diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index 38b3f42ed..966bf30fa 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -1,4 +1,3 @@ -version: '3' services: traefik: image: traefik:v2.10 @@ -9,6 +8,7 @@ services: - "--entryPoints.web.address=:80" - "--entrypoints.web.forwardedHeaders.insecure=true" - "--entrypoints.web.proxyProtocol.insecure=true" + - "--log.level=DEBUG" labels: - "traefik.http.middlewares.cors.headers.accessControlAllowOriginList=http://localhost,http://host.docker.internal" - "traefik.http.middlewares.cors.headers.accessControlAllowMethods=GET,OPTIONS" @@ -45,8 +45,8 @@ services: dockerfile: ${DTAAS_DIR}/docker/libms.dockerfile restart: unless-stopped volumes: - - ${DTAAS_DIR}/servers/lib/config/.env.default:/dtaas/libms/.env - ${DTAAS_DIR}/files:/dtaas/libms/files + - ${DTAAS_DIR}/servers/lib/config/libms.dev.yaml:/dtaas/libms/config/libms.yaml labels: - "traefik.enable=true" - "traefik.http.routers.libms.entryPoints=web" @@ -96,19 +96,20 @@ services: image: thomseddon/traefik-forward-auth:latest restart: unless-stopped volumes: - - ${OAUTH_CONF_FILEPATH}:/conf + - ${DTAAS_DIR}/docker/conf.dev:/conf environment: - DEFAULT_PROVIDER=generic-oauth - PROVIDERS_GENERIC_OAUTH_AUTH_URL=${OAUTH_URL}/oauth/authorize - PROVIDERS_GENERIC_OAUTH_TOKEN_URL=${OAUTH_URL}/oauth/token - PROVIDERS_GENERIC_OAUTH_USER_URL=${OAUTH_URL}/api/v4/user - - PROVIDERS_GENERIC_OAUTH_CLIENT_ID=${CLIENT_ID} - - PROVIDERS_GENERIC_OAUTH_CLIENT_SECRET=${CLIENT_SECRET} + - PROVIDERS_GENERIC_OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID} + - PROVIDERS_GENERIC_OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET} - PROVIDERS_GENERIC_OAUTH_SCOPE=read_user - SECRET= ${OAUTH_SECRET} # INSECURE_COOKIE is required if not using a https entrypoint - INSECURE_COOKIE=true - CONFIG=/conf + - LOG_LEVEL=debug labels: - "traefik.enable=true" From 6dacf9d6eafe8562d0453a8220f71dc686a3b21a Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Fri, 21 Feb 2025 00:32:27 +0100 Subject: [PATCH 25/36] Memoize Config and useConfigErrors --- client/src/route/config/Config.tsx | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index 953769718..eaca17d1b 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -111,22 +111,29 @@ const useValidationResults = () => { const useConfigErrors = (validationResults: { [key: string]: ValidationType; -}) => - Object.keys(window.env).some( - (key) => key !== undefined && validationResults[key]?.error !== undefined, +}) => { + return React.useMemo(() => + Object.keys(window.env || {}).some( + (key) => key !== undefined && validationResults[key]?.error !== undefined + ), + [validationResults] ); +}; const Config = (props: { role: string }) => { const { validationResults, isLoading } = useValidationResults(); - const configVerification = + + const configVerification = React.useMemo(() => props.role === 'user' ? UserConfig(validationResults) - : DeveloperConfig(validationResults); - + : DeveloperConfig(validationResults), + [validationResults] + ); + if (isLoading) { return loadingComponent(); } - + return configVerification; }; From 36b7837a5baae6f0dbf09ac3d472fc5b5e8a953c Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Wed, 26 Feb 2025 15:34:12 +0100 Subject: [PATCH 26/36] Add props.role as hook dependency to Config --- client/src/route/config/Config.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index eaca17d1b..66020c221 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -111,14 +111,12 @@ const useValidationResults = () => { const useConfigErrors = (validationResults: { [key: string]: ValidationType; -}) => { - return React.useMemo(() => +}) => React.useMemo(() => Object.keys(window.env || {}).some( (key) => key !== undefined && validationResults[key]?.error !== undefined ), [validationResults] ); -}; const Config = (props: { role: string }) => { const { validationResults, isLoading } = useValidationResults(); @@ -127,7 +125,7 @@ const Config = (props: { role: string }) => { props.role === 'user' ? UserConfig(validationResults) : DeveloperConfig(validationResults), - [validationResults] + [validationResults, props.role] ); if (isLoading) { From 0df9a868b961d71a02535c2c7f2b538dcad37850 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Fri, 7 Mar 2025 14:28:09 +0100 Subject: [PATCH 27/36] Update docs and compose.dev.yml --- docker/README.md | 19 +++++++++++++++++-- docker/compose.dev.yml | 2 +- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docker/README.md b/docker/README.md index 5201125f7..3bcc29b74 100644 --- a/docker/README.md +++ b/docker/README.md @@ -7,12 +7,22 @@ use them for development purposes. This readme will explain the building and use of different docker files for development purposes. +## Design + +The docker compose environment creates the following development scenario. + +![developer](developer-docker.png) + ## Folder Structure There are two dockerfiles for building the containers: - **client.dockerfile**: Dockerfile for building the client application container. +- **client.built.dockerfile**: Dockerfile for copying + an already built client application into docker image. + This dockerfile copies `client/build` directory and serves it from + inside the docker container. - **libms.dockerfile**: Dockerfile for building the library microservice container from source code. - **libms.npm.dockerfile**: Dockerfile for building the library @@ -74,6 +84,11 @@ The configuration files to be updated are: ## Development Environment +:warning: There is a problem compiling client (`yarn build`) inside +docker container. Hence, it is required to copy the built +files into the docker container. The docker compose build +command fails if `client/build` directory does not exist. + The development environment requires docker images to be built become the docker compose application can be brought up. @@ -143,7 +158,7 @@ A brief explanation of the packages is given below. ### React Website ```sh -docker build -t intocps/dtaas-web:latest -f ./docker/client.dockerfile . +docker build -t intocps/dtaas-web:latest -f ./docker/client.built.dockerfile . docker tag intocps/dtaas-web:latest intocps/dtaas-web: docker push intocps/dtaas-web:latest docker push intocps/dtaas-web: @@ -189,4 +204,4 @@ To test the library microservice on localhost, please use ```bash docker run -d -v ${PWD}/files:/dtaas/libms/files \ -p 4001:4001 intocps/libms:latest -``` +``` \ No newline at end of file diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index 966bf30fa..0980f8a88 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -26,7 +26,7 @@ services: client: build: context: ${DTAAS_DIR}/ - dockerfile: ${DTAAS_DIR}/docker/client.dockerfile + dockerfile: ${DTAAS_DIR}/docker/client.built.dockerfile restart: unless-stopped volumes: - "${DTAAS_DIR}/client/config/local.js:/dtaas/client/build/env.js" From 070097935971e29b389bee12ee8432314fe8cd02 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Fri, 7 Mar 2025 14:42:56 +0100 Subject: [PATCH 28/36] Rm variant versions from new dependencies --- client/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/package.json b/client/package.json index 77aa9ae31..91ab3c8f1 100644 --- a/client/package.json +++ b/client/package.json @@ -73,8 +73,8 @@ "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", - "http-status-codes": "^2.3.0", + "eslint-plugin-react-hooks": "5.1.0", + "http-status-codes": "2.3.0", "jest-fetch-mock": "^3.0.3", "katex": "^0.16.11", "markdown-it-katex": "^2.0.3", @@ -98,7 +98,7 @@ "serve": "^14.2.1", "styled-components": "^6.1.1", "typescript": "5.1.6", - "zod": "^3.24.1" + "zod": "3.24.1" }, "devDependencies": { "@babel/core": "7.25.8", From 2b2807e46109d7540795db97e4b21427ebbb0a7d Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Fri, 7 Mar 2025 14:43:59 +0100 Subject: [PATCH 29/36] Update yarn --- client/yarn.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/yarn.lock b/client/yarn.lock index 208dcc350..d14c1b615 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -5336,16 +5336,16 @@ eslint-plugin-jsx-a11y@^6.5.1, eslint-plugin-jsx-a11y@^6.8.0: safe-regex-test "^1.0.3" string.prototype.includes "^2.0.1" +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-hooks@^4.3.0: version "4.6.2" 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" @@ -6317,7 +6317,7 @@ http-proxy@^1.18.1: follow-redirects "^1.0.0" requires-port "^1.0.0" -http-status-codes@^2.3.0: +http-status-codes@2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/http-status-codes/-/http-status-codes-2.3.0.tgz#987fefb28c69f92a43aecc77feec2866349a8bfc" integrity sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA== @@ -12045,7 +12045,7 @@ yocto-queue@^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.24.1: +zod@3.24.1: version "3.24.1" resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.1.tgz#27445c912738c8ad1e9de1bea0359fa44d9d35ee" integrity sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A== From 1204f386ef798cae3176548654e365c3cd2371fb Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Fri, 7 Mar 2025 17:56:55 +0100 Subject: [PATCH 30/36] Rm non-top-level memoization call, add catch block in Config.tsx --- client/src/route/config/Config.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index 66020c221..037b309c0 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -97,6 +97,8 @@ const useValidationResults = () => { try { const results = await getValidationResults(); setValidationResults(results); + } catch (_error) { + throw new Error ('Failed to fetch validation results'); } finally { setIsLoading(false); } @@ -111,12 +113,11 @@ const useValidationResults = () => { const useConfigErrors = (validationResults: { [key: string]: ValidationType; -}) => React.useMemo(() => - Object.keys(window.env || {}).some( - (key) => key !== undefined && validationResults[key]?.error !== undefined - ), - [validationResults] - ); +}) => { + return Object.keys(window.env).some( + (key) => validationResults[key]?.error !== undefined + ) + } ; const Config = (props: { role: string }) => { const { validationResults, isLoading } = useValidationResults(); From d108efed59a1d08f0c2c5c90314a885760a0c65b Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Thu, 13 Mar 2025 05:23:36 +0100 Subject: [PATCH 31/36] Fix Codacy issues in ConfigUtil.test.ts --- client/src/route/config/Config.tsx | 28 ++++++++-------- client/src/util/configUtil.ts | 17 +++++++--- client/test/unit/util/ConfigUtil.test.ts | 42 ++++++++++++++---------- 3 files changed, 51 insertions(+), 36 deletions(-) diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index 037b309c0..e61281890 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -98,7 +98,7 @@ const useValidationResults = () => { const results = await getValidationResults(); setValidationResults(results); } catch (_error) { - throw new Error ('Failed to fetch validation results'); + throw new Error('Failed to fetch validation results'); } finally { setIsLoading(false); } @@ -113,26 +113,26 @@ const useValidationResults = () => { const useConfigErrors = (validationResults: { [key: string]: ValidationType; -}) => { - return Object.keys(window.env).some( - (key) => validationResults[key]?.error !== undefined - ) - } ; +}) => + Object.keys(window.env).some( + (key) => validationResults[key]?.error !== undefined, + ); const Config = (props: { role: string }) => { const { validationResults, isLoading } = useValidationResults(); - - const configVerification = React.useMemo(() => - props.role === 'user' - ? UserConfig(validationResults) - : DeveloperConfig(validationResults), - [validationResults, props.role] + + const configVerification = React.useMemo( + () => + props.role === 'user' + ? UserConfig(validationResults) + : DeveloperConfig(validationResults), + [validationResults, props.role], ); - + if (isLoading) { return loadingComponent(); } - + return configVerification; }; diff --git a/client/src/util/configUtil.ts b/client/src/util/configUtil.ts index e80536707..afc449864 100644 --- a/client/src/util/configUtil.ts +++ b/client/src/util/configUtil.ts @@ -1,15 +1,22 @@ import { z } from 'zod'; import { wait } from 'util/auth/Authentication'; -export type ValidationType = { +export interface ValidationType { value?: string; status?: number; error?: string; -}; +} -const EnvironmentEnum = z.enum(['dev', 'local', 'prod', 'test']); -const PathString = z.string(); -const ScopesString = z.literal('openid profile read_user read_repository api'); +const EnvironmentEnum: z.ZodEnum<['dev', 'local', 'prod', 'test']> = z.enum([ + 'dev', + 'local', + 'prod', + 'test', +]); +const PathString: z.ZodString = z.string(); +const ScopesString: z.ZodLiteral = z.literal( + 'openid profile read_user read_repository api', +); const pathKeys = [ 'REACT_APP_URL_BASENAME', diff --git a/client/test/unit/util/ConfigUtil.test.ts b/client/test/unit/util/ConfigUtil.test.ts index ee5115c91..fa60a9270 100644 --- a/client/test/unit/util/ConfigUtil.test.ts +++ b/client/test/unit/util/ConfigUtil.test.ts @@ -2,6 +2,7 @@ import { retryFetch, getValidationResults, urlIsReachable, + ValidationType, } from 'util/configUtil'; jest.deepUnmock('util/configUtil'); @@ -27,21 +28,20 @@ describe('configUtil', () => { const mockResponse = { ok: true, status: 200, - json: async () => ({ data: 'success' }), - }; + json: (): Promise<{ data: string }> => Promise.resolve({ data: 'success' }), + } as Response; describe('retryFetch', () => { test('retryFetch returns a valid response', async () => { global.fetch = jest.fn().mockResolvedValue(mockResponse); - const response = await retryFetch('https://foo.bar', { + const response: Response = await retryFetch('https://foo.bar', { method: 'HEAD', signal: AbortSignal.timeout(1000), }); - expect(response.ok).toBe(true); expect(response.status).toBe(200); - const jsonResult = await response.json(); + const jsonResult: { data: string } = await response.json(); expect(jsonResult).toEqual({ data: 'success' }); }); @@ -52,7 +52,7 @@ describe('configUtil', () => { .mockRejectedValueOnce(networkError) .mockResolvedValueOnce(mockResponse); - const response = await retryFetch( + const response: Response = await retryFetch( 'http://foo.foo', { method: 'HEAD', @@ -68,19 +68,24 @@ describe('configUtil', () => { test('retryFetch retries until failing', async () => { global.fetch = jest.fn().mockRejectedValue(networkError); - await expect(retryFetch('https://bar.com')).rejects.toThrow(networkError); + const fetchPromise: Promise = retryFetch('https://bar.com'); + await expect(fetchPromise).rejects.toThrow(networkError); expect(global.fetch).toHaveBeenCalledTimes(3); }); }); describe('getValidationResults', () => { test('getValidationResults object includes all keys of window.env', async () => { - const results = await getValidationResults(); - const resultKeys = Object.keys(results); - const envKeys = Object.keys(window.env); + const results: ValidationType = await getValidationResults(); + const resultKeys: string[] = Object.keys(results); + const envKeys: string[] = Object.keys(window.env); - const missingKeys = envKeys.filter((key) => !resultKeys.includes(key)); - const unexpectedKeys = resultKeys.filter((key) => !envKeys.includes(key)); + const missingKeys: string[] = envKeys.filter( + (key) => !resultKeys.includes(key), + ); + const unexpectedKeys: string[] = resultKeys.filter( + (key) => !envKeys.includes(key), + ); expect(missingKeys).toEqual([]); expect(unexpectedKeys).toEqual([]); @@ -88,7 +93,8 @@ describe('configUtil', () => { test('getValidationResult AUTH_AUTHORITY has error if it fails reachability', async () => { window.env.REACT_APP_AUTH_AUTHORITY = 'https://foo.bar'; global.fetch = jest.fn().mockRejectedValue(networkError); - const results = await getValidationResults(); + const results: { [key: string]: ValidationType } = + await getValidationResults(); expect(results.REACT_APP_AUTH_AUTHORITY.error).toBeDefined(); expect(results.REACT_APP_AUTH_AUTHORITY.status).toBeUndefined(); expect(results.REACT_APP_AUTH_AUTHORITY.value).toBeUndefined(); @@ -96,14 +102,16 @@ describe('configUtil', () => { test('getValidationResult ENVIRONMENT has error if it fails parse', async () => { window.env.REACT_APP_ENVIRONMENT = 'foo'; - const results = await getValidationResults(); + const results: { [key: string]: ValidationType } = + await getValidationResults(); expect(results.REACT_APP_ENVIRONMENT.error).toBeDefined(); expect(results.REACT_APP_ENVIRONMENT.status).toBeUndefined(); expect(results.REACT_APP_ENVIRONMENT.value).toBeUndefined(); }); test('getValidationResult CLIENT_ID has value if it succeeds parse', async () => { - const results = await getValidationResults(); + const results: { [key: string]: ValidationType } = + await getValidationResults(); expect(results.REACT_APP_CLIENT_ID.error).toBeUndefined(); expect(results.REACT_APP_CLIENT_ID.status).toBeUndefined(); expect(results.REACT_APP_CLIENT_ID.value).toEqual('abc123'); @@ -113,7 +121,7 @@ describe('configUtil', () => { describe('urlIsReachable', () => { test('urlIsReachable object has value if it succeeds', async () => { global.fetch = jest.fn().mockResolvedValue(mockResponse); - const result = await urlIsReachable('https://foo.bar'); + const result: ValidationType = await urlIsReachable('https://foo.bar'); expect(result.error).toBeUndefined(); expect(result.status).toBe(200); expect(result.value).toEqual('https://foo.bar'); @@ -121,7 +129,7 @@ describe('configUtil', () => { test('urlIsReachable object has error if it fails', async () => { global.fetch = jest.fn().mockRejectedValue(networkError); - const result = await urlIsReachable('https://foo.bar'); + const result: ValidationType = await urlIsReachable('https://foo.bar'); expect(result.error).toBeDefined(); expect(result.status).toBeUndefined(); expect(result.value).toBeUndefined(); From 0d6f662533e76a28d9bf7ef72ac0991407f0b3a9 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Thu, 13 Mar 2025 05:42:49 +0100 Subject: [PATCH 32/36] Apply Codacy patch --- client/src/route/config/Config.tsx | 2 +- client/test/integration/Auth/WaitAndNavigate.test.tsx | 2 +- client/test/unit/components/PrivateRoute.test.tsx | 2 +- client/test/unit/page/Config.test.tsx | 2 +- client/test/unit/util/ConfigUtil.test.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index e61281890..8a7e2e215 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -104,7 +104,7 @@ const useValidationResults = () => { } }; - fetchValidationResults(); + void fetchValidationResults(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [window.env]); diff --git a/client/test/integration/Auth/WaitAndNavigate.test.tsx b/client/test/integration/Auth/WaitAndNavigate.test.tsx index b9a48d4f7..cef3ae61e 100644 --- a/client/test/integration/Auth/WaitAndNavigate.test.tsx +++ b/client/test/integration/Auth/WaitAndNavigate.test.tsx @@ -40,7 +40,7 @@ describe('WaitAndNavigate', () => { }); await waitFor(() => - expect(screen.getByText(/Sign In with GitLab/i)).toBeVisible(), + { expect(screen.getByText(/Sign In with GitLab/i)).toBeVisible(); }, ); }); }); diff --git a/client/test/unit/components/PrivateRoute.test.tsx b/client/test/unit/components/PrivateRoute.test.tsx index f4ec55136..790c2606e 100644 --- a/client/test/unit/components/PrivateRoute.test.tsx +++ b/client/test/unit/components/PrivateRoute.test.tsx @@ -54,7 +54,7 @@ describe('PrivateRoute', () => { }); await waitFor( - () => expect(screen.getByText('Signin')).toBeInTheDocument(), + () => { expect(screen.getByText('Signin')).toBeInTheDocument(); }, { timeout: 60000, }, diff --git a/client/test/unit/page/Config.test.tsx b/client/test/unit/page/Config.test.tsx index 4e2b3f820..0c44edecb 100644 --- a/client/test/unit/page/Config.test.tsx +++ b/client/test/unit/page/Config.test.tsx @@ -42,7 +42,7 @@ describe('Config', () => { expect(screen.getByText(/Verifying configuration/i)).toBeInTheDocument(); expect(screen.getByTestId('loading-icon')).toBeInTheDocument(); await waitFor(() => - expect(screen.getByText(/Config verification/i)).toBeInTheDocument(), + { expect(screen.getByText(/Config verification/i)).toBeInTheDocument(); }, ); expect(screen.getByText(/REACT_APP_URL_BASENAME/i)).toBeInTheDocument(); expect( diff --git a/client/test/unit/util/ConfigUtil.test.ts b/client/test/unit/util/ConfigUtil.test.ts index fa60a9270..9c703f995 100644 --- a/client/test/unit/util/ConfigUtil.test.ts +++ b/client/test/unit/util/ConfigUtil.test.ts @@ -93,7 +93,7 @@ describe('configUtil', () => { test('getValidationResult AUTH_AUTHORITY has error if it fails reachability', async () => { window.env.REACT_APP_AUTH_AUTHORITY = 'https://foo.bar'; global.fetch = jest.fn().mockRejectedValue(networkError); - const results: { [key: string]: ValidationType } = + const results: Record = await getValidationResults(); expect(results.REACT_APP_AUTH_AUTHORITY.error).toBeDefined(); expect(results.REACT_APP_AUTH_AUTHORITY.status).toBeUndefined(); From ba3b13c1b7594e14f2dfefb3a10b0fe3b9a5cdc2 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Thu, 13 Mar 2025 06:10:56 +0100 Subject: [PATCH 33/36] Fix syntax errors --- client/package.json | 4 +- client/src/route/config/Config.tsx | 4 +- .../integration/Auth/WaitAndNavigate.test.tsx | 6 +- .../unit/components/PrivateRoute.test.tsx | 4 +- client/test/unit/page/Config.test.tsx | 6 +- client/yarn.lock | 112 ++++++++++++++---- docker/README.md | 2 +- 7 files changed, 101 insertions(+), 37 deletions(-) diff --git a/client/package.json b/client/package.json index 91ab3c8f1..52429a0b7 100644 --- a/client/package.json +++ b/client/package.json @@ -62,8 +62,8 @@ "@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", + "@typescript-eslint/eslint-plugin": "^8.26.1", + "@typescript-eslint/parser": "^8.26.1", "cross-env": "^7.0.3", "dotenv": "^16.1.4", "eslint": "^8.2.0", diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index 8a7e2e215..74162b7a3 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -104,7 +104,9 @@ const useValidationResults = () => { } }; - void fetchValidationResults(); + fetchValidationResults().catch((error) => { + throw new Error(`Failed to fetch validation results: ${error}`); + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [window.env]); diff --git a/client/test/integration/Auth/WaitAndNavigate.test.tsx b/client/test/integration/Auth/WaitAndNavigate.test.tsx index cef3ae61e..6f45c20e9 100644 --- a/client/test/integration/Auth/WaitAndNavigate.test.tsx +++ b/client/test/integration/Auth/WaitAndNavigate.test.tsx @@ -39,8 +39,8 @@ describe('WaitAndNavigate', () => { jest.advanceTimersByTime(5000); }); - await waitFor(() => - { expect(screen.getByText(/Sign In with GitLab/i)).toBeVisible(); }, - ); + await waitFor(() => { + expect(screen.getByText(/Sign In with GitLab/i)).toBeVisible(); + }); }); }); diff --git a/client/test/unit/components/PrivateRoute.test.tsx b/client/test/unit/components/PrivateRoute.test.tsx index 790c2606e..44493298d 100644 --- a/client/test/unit/components/PrivateRoute.test.tsx +++ b/client/test/unit/components/PrivateRoute.test.tsx @@ -54,7 +54,9 @@ describe('PrivateRoute', () => { }); await waitFor( - () => { expect(screen.getByText('Signin')).toBeInTheDocument(); }, + () => { + expect(screen.getByText('Signin')).toBeInTheDocument(); + }, { timeout: 60000, }, diff --git a/client/test/unit/page/Config.test.tsx b/client/test/unit/page/Config.test.tsx index 0c44edecb..951577a66 100644 --- a/client/test/unit/page/Config.test.tsx +++ b/client/test/unit/page/Config.test.tsx @@ -41,9 +41,9 @@ describe('Config', () => { expect(screen.getByText(/Verifying configuration/i)).toBeInTheDocument(); expect(screen.getByTestId('loading-icon')).toBeInTheDocument(); - await waitFor(() => - { expect(screen.getByText(/Config verification/i)).toBeInTheDocument(); }, - ); + await waitFor(() => { + expect(screen.getByText(/Config verification/i)).toBeInTheDocument(); + }); expect(screen.getByText(/REACT_APP_URL_BASENAME/i)).toBeInTheDocument(); expect( screen.getByText(/REACT_APP_WORKBENCHLINK_JUPYTERLAB/i), diff --git a/client/yarn.lock b/client/yarn.lock index d14c1b615..f251f60d2 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -1402,7 +1402,12 @@ dependencies: eslint-visitor-keys "^3.3.0" -"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.4.0", "@eslint-community/regexpp@^4.6.1": +"@eslint-community/regexpp@^4.10.0": + version "4.12.1" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" + integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== + +"@eslint-community/regexpp@^4.4.0", "@eslint-community/regexpp@^4.6.1": version "4.11.1" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.11.1.tgz#a547badfc719eb3e5f4b556325e542fbe9d7a18f" integrity sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q== @@ -2861,20 +2866,20 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/eslint-plugin@^8.7.0": - version "8.11.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz#c3f087d20715fa94310b30666c08b3349e0ab084" - integrity sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA== +"@typescript-eslint/eslint-plugin@^8.26.1": + version "8.26.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.1.tgz#3e48eb847924161843b092c87a9b65176b53782f" + integrity sha512-2X3mwqsj9Bd3Ciz508ZUtoQQYpOhU/kWoUqIf49H8Z0+Vbh6UF/y0OEYp0Q0axOGzaBGs7QxRwq0knSQ8khQNA== dependencies: "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "8.11.0" - "@typescript-eslint/type-utils" "8.11.0" - "@typescript-eslint/utils" "8.11.0" - "@typescript-eslint/visitor-keys" "8.11.0" + "@typescript-eslint/scope-manager" "8.26.1" + "@typescript-eslint/type-utils" "8.26.1" + "@typescript-eslint/utils" "8.26.1" + "@typescript-eslint/visitor-keys" "8.26.1" graphemer "^1.4.0" ignore "^5.3.1" natural-compare "^1.4.0" - ts-api-utils "^1.3.0" + ts-api-utils "^2.0.1" "@typescript-eslint/experimental-utils@^5.0.0": version "5.62.0" @@ -2893,15 +2898,15 @@ "@typescript-eslint/typescript-estree" "5.62.0" debug "^4.3.4" -"@typescript-eslint/parser@^8.7.0": - version "8.11.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.11.0.tgz#2ad1481388dc1c937f50b2d138c9ca57cc6c5cce" - integrity sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg== +"@typescript-eslint/parser@^8.26.1": + version "8.26.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.26.1.tgz#0e2f915a497519fc43f52cf2ecbfa607ff56f72e" + integrity sha512-w6HZUV4NWxqd8BdeFf81t07d7/YV9s7TCWrQQbG5uhuvGUAW+fq1usZ1Hmz9UPNLniFnD8GLSsDpjP0hm1S4lQ== dependencies: - "@typescript-eslint/scope-manager" "8.11.0" - "@typescript-eslint/types" "8.11.0" - "@typescript-eslint/typescript-estree" "8.11.0" - "@typescript-eslint/visitor-keys" "8.11.0" + "@typescript-eslint/scope-manager" "8.26.1" + "@typescript-eslint/types" "8.26.1" + "@typescript-eslint/typescript-estree" "8.26.1" + "@typescript-eslint/visitor-keys" "8.26.1" debug "^4.3.4" "@typescript-eslint/scope-manager@5.62.0": @@ -2920,6 +2925,14 @@ "@typescript-eslint/types" "8.11.0" "@typescript-eslint/visitor-keys" "8.11.0" +"@typescript-eslint/scope-manager@8.26.1": + version "8.26.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.26.1.tgz#5e6ad0ac258ccf79462e91c3f43a3f1f7f31a6cc" + integrity sha512-6EIvbE5cNER8sqBu6V7+KeMZIC1664d2Yjt+B9EWUXrsyWpxx4lEZrmvxgSKRC6gX+efDL/UY9OpPZ267io3mg== + dependencies: + "@typescript-eslint/types" "8.26.1" + "@typescript-eslint/visitor-keys" "8.26.1" + "@typescript-eslint/type-utils@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz#286f0389c41681376cdad96b309cedd17d70346a" @@ -2930,15 +2943,15 @@ debug "^4.3.4" tsutils "^3.21.0" -"@typescript-eslint/type-utils@8.11.0": - version "8.11.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.11.0.tgz#b7f9e6120c1ddee8a1a07615646642ad85fc91b5" - integrity sha512-ItiMfJS6pQU0NIKAaybBKkuVzo6IdnAhPFZA/2Mba/uBjuPQPet/8+zh5GtLHwmuFRShZx+8lhIs7/QeDHflOg== +"@typescript-eslint/type-utils@8.26.1": + version "8.26.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.26.1.tgz#462f0bae09de72ac6e8e1af2ebe588c23224d7f8" + integrity sha512-Kcj/TagJLwoY/5w9JGEFV0dclQdyqw9+VMndxOJKtoFSjfZhLXhYjzsQEeyza03rwHx2vFEGvrJWJBXKleRvZg== dependencies: - "@typescript-eslint/typescript-estree" "8.11.0" - "@typescript-eslint/utils" "8.11.0" + "@typescript-eslint/typescript-estree" "8.26.1" + "@typescript-eslint/utils" "8.26.1" debug "^4.3.4" - ts-api-utils "^1.3.0" + ts-api-utils "^2.0.1" "@typescript-eslint/types@5.62.0": version "5.62.0" @@ -2950,6 +2963,11 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.11.0.tgz#7c766250502097f49bbc2e651132e6bf489e20b8" integrity sha512-tn6sNMHf6EBAYMvmPUaKaVeYvhUsrE6x+bXQTxjQRp360h1giATU0WvgeEys1spbvb5R+VpNOZ+XJmjD8wOUHw== +"@typescript-eslint/types@8.26.1": + version "8.26.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.26.1.tgz#d5978721670cff263348d5062773389231a64132" + integrity sha512-n4THUQW27VmQMx+3P+B0Yptl7ydfceUj4ON/AQILAASwgYdZ/2dhfymRMh5egRUrvK5lSmaOm77Ry+lmXPOgBQ== + "@typescript-eslint/typescript-estree@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b" @@ -2977,6 +2995,20 @@ semver "^7.6.0" ts-api-utils "^1.3.0" +"@typescript-eslint/typescript-estree@8.26.1": + version "8.26.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.1.tgz#eb0e4ce31753683d83be53441a409fd5f0b34afd" + integrity sha512-yUwPpUHDgdrv1QJ7YQal3cMVBGWfnuCdKbXw1yyjArax3353rEJP1ZA+4F8nOlQ3RfS2hUN/wze3nlY+ZOhvoA== + dependencies: + "@typescript-eslint/types" "8.26.1" + "@typescript-eslint/visitor-keys" "8.26.1" + debug "^4.3.4" + fast-glob "^3.3.2" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^2.0.1" + "@typescript-eslint/utils@5.62.0", "@typescript-eslint/utils@^5.58.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86" @@ -2991,7 +3023,17 @@ eslint-scope "^5.1.1" semver "^7.3.7" -"@typescript-eslint/utils@8.11.0", "@typescript-eslint/utils@^6.0.0 || ^7.0.0 || ^8.0.0": +"@typescript-eslint/utils@8.26.1": + version "8.26.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.26.1.tgz#54cc58469955f25577f659753b71a0e117a0539f" + integrity sha512-V4Urxa/XtSUroUrnI7q6yUTD3hDtfJ2jzVfeT3VK0ciizfK2q/zGC0iDh1lFMUZR8cImRrep6/q0xd/1ZGPQpg== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + "@typescript-eslint/scope-manager" "8.26.1" + "@typescript-eslint/types" "8.26.1" + "@typescript-eslint/typescript-estree" "8.26.1" + +"@typescript-eslint/utils@^6.0.0 || ^7.0.0 || ^8.0.0": version "8.11.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.11.0.tgz#4480d1e9f2bb18ea3510c79f870a1aefc118103d" integrity sha512-CYiX6WZcbXNJV7UNB4PLDIBtSdRmRI/nb0FMyqHPTQD1rMjA0foPLaPUV39C/MxkTd/QKSeX+Gb34PPsDVC35g== @@ -3017,6 +3059,14 @@ "@typescript-eslint/types" "8.11.0" eslint-visitor-keys "^3.4.3" +"@typescript-eslint/visitor-keys@8.26.1": + version "8.26.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.1.tgz#c5267fcc82795cf10280363023837deacad2647c" + integrity sha512-AjOC3zfnxd6S4Eiy3jwktJPclqhFHNyd8L6Gycf9WUPoKZpgM5PjkxY1X7uSy61xVpiJDhhk7XT2NVsN3ALTWg== + dependencies: + "@typescript-eslint/types" "8.26.1" + eslint-visitor-keys "^4.2.0" + "@ungap/structured-clone@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" @@ -5408,6 +5458,11 @@ eslint-visitor-keys@^4.1.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz#1f785cc5e81eb7534523d85922248232077d2f8c" integrity sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg== +eslint-visitor-keys@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz#687bacb2af884fcdda8a6e7d65c606f46a14cd45" + integrity sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw== + eslint-webpack-plugin@^3.1.1: version "3.2.0" resolved "https://registry.yarnpkg.com/eslint-webpack-plugin/-/eslint-webpack-plugin-3.2.0.tgz#1978cdb9edc461e4b0195a20da950cf57988347c" @@ -11085,6 +11140,11 @@ ts-api-utils@^1.3.0: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1" integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ== +ts-api-utils@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.0.1.tgz#660729385b625b939aaa58054f45c058f33f10cd" + integrity sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w== + ts-interface-checker@^0.1.9: version "0.1.13" resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" diff --git a/docker/README.md b/docker/README.md index 3bcc29b74..c14d21e60 100644 --- a/docker/README.md +++ b/docker/README.md @@ -204,4 +204,4 @@ To test the library microservice on localhost, please use ```bash docker run -d -v ${PWD}/files:/dtaas/libms/files \ -p 4001:4001 intocps/libms:latest -``` \ No newline at end of file +``` From a0df3d5b60ef7d124353907333df59b6055e60af Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Thu, 13 Mar 2025 09:25:34 +0100 Subject: [PATCH 34/36] Use record types --- client/src/route/config/Config.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index 74162b7a3..a514e270e 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -19,9 +19,7 @@ const typographyStyle = { padding: 'clamp(0, 4vw, 5%)', }; -const DeveloperConfig = (validationResults: { - [key: string]: ValidationType; -}): JSX.Element => ( +const DeveloperConfig = (validationResults: Record): JSX.Element => ( ); -const UserConfig = (validationResults: { - [key: string]: ValidationType; -}): JSX.Element => { +const UserConfig = (validationResults: Record): JSX.Element => { const hasConfigErrors = useConfigErrors(validationResults); return ( { - const [validationResults, setValidationResults] = useState<{ - [key: string]: ValidationType; - }>({}); + const [validationResults, setValidationResults] = useState>({}); const [isLoading, setIsLoading] = useState(true); useEffect(() => { @@ -113,9 +107,7 @@ const useValidationResults = () => { return { validationResults, isLoading }; }; -const useConfigErrors = (validationResults: { - [key: string]: ValidationType; -}) => +const useConfigErrors = (validationResults: Record) => Object.keys(window.env).some( (key) => validationResults[key]?.error !== undefined, ); From 61d23b98c71e8f19ac58a8df27ce56b4915cbdcf Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Sat, 15 Mar 2025 04:51:30 +0100 Subject: [PATCH 35/36] Fix more any errors --- client/src/route/config/Config.tsx | 14 ++++++++---- client/src/util/configUtil.ts | 26 ++++++++------------- client/test/unit/util/ConfigUtil.test.ts | 29 +++++++++++++----------- 3 files changed, 35 insertions(+), 34 deletions(-) diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index a514e270e..0036e553e 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -19,7 +19,9 @@ const typographyStyle = { padding: 'clamp(0, 4vw, 5%)', }; -const DeveloperConfig = (validationResults: Record): JSX.Element => ( +const DeveloperConfig = ( + validationResults: Record, +): JSX.Element => ( ); -const UserConfig = (validationResults: Record): JSX.Element => { +const UserConfig = ( + validationResults: Record, +): JSX.Element => { const hasConfigErrors = useConfigErrors(validationResults); return ( ): JSX.Elem }; const useValidationResults = () => { - const [validationResults, setValidationResults] = useState>({}); + const [validationResults, setValidationResults] = useState< + Record + >({}); const [isLoading, setIsLoading] = useState(true); useEffect(() => { @@ -98,7 +104,7 @@ const useValidationResults = () => { } }; - fetchValidationResults().catch((error) => { + fetchValidationResults().catch((error: unknown) => { throw new Error(`Failed to fetch validation results: ${error}`); }); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/client/src/util/configUtil.ts b/client/src/util/configUtil.ts index afc449864..710f049db 100644 --- a/client/src/util/configUtil.ts +++ b/client/src/util/configUtil.ts @@ -59,22 +59,14 @@ function getValidationPromises(): Record> { }; } -export const getValidationResults = async (): Promise<{ - [key: string]: ValidationType; -}> => { - const validationPromises: Record< - string, - Promise - > = getValidationPromises(); - - return ( - await Promise.all( - Object.entries(validationPromises).map(async ([key, task]) => ({ - [key]: await task, - })), - ) - ).reduce((acc, result) => ({ ...acc, ...result }), {}); -}; +export const getValidationResults = async (): Promise<{ [key: string]: ValidationType }> => { + const validationPromises: Record> = getValidationPromises(); + const entries = Object.entries(validationPromises); + const resolvedEntries = await Promise.all( + entries.map(async ([key, promise]) => [key, await promise] as [string, ValidationType]) + ); + return Object.fromEntries(resolvedEntries) as { [key: string]: ValidationType }; + }; export async function retryFetch( url: string, @@ -85,7 +77,7 @@ export async function retryFetch( return await fetch(url, options); } catch (error) { if (retries <= 0) { - return Promise.reject(error); + throw error; } await wait(1000); return retryFetch(url, options, retries - 1); diff --git a/client/test/unit/util/ConfigUtil.test.ts b/client/test/unit/util/ConfigUtil.test.ts index 9c703f995..e5a85dbaa 100644 --- a/client/test/unit/util/ConfigUtil.test.ts +++ b/client/test/unit/util/ConfigUtil.test.ts @@ -34,14 +34,14 @@ describe('configUtil', () => { describe('retryFetch', () => { test('retryFetch returns a valid response', async () => { global.fetch = jest.fn().mockResolvedValue(mockResponse); - const response: Response = await retryFetch('https://foo.bar', { + const response: Response = (await retryFetch('https://foo.bar', { method: 'HEAD', signal: AbortSignal.timeout(1000), - }); + })); expect(response.ok).toBe(true); expect(response.status).toBe(200); - const jsonResult: { data: string } = await response.json(); + const jsonResult = await response.json() as { data: string }; expect(jsonResult).toEqual({ data: 'success' }); }); @@ -52,7 +52,7 @@ describe('configUtil', () => { .mockRejectedValueOnce(networkError) .mockResolvedValueOnce(mockResponse); - const response: Response = await retryFetch( + const response: Response = (await retryFetch( 'http://foo.foo', { method: 'HEAD', @@ -60,7 +60,7 @@ describe('configUtil', () => { signal: AbortSignal.timeout(1500), }, 3, - ); + )); expect(response).toBe(mockResponse); expect(global.fetch).toHaveBeenCalledTimes(3); }); @@ -110,18 +110,19 @@ describe('configUtil', () => { }); test('getValidationResult CLIENT_ID has value if it succeeds parse', async () => { - const results: { [key: string]: ValidationType } = - await getValidationResults(); - expect(results.REACT_APP_CLIENT_ID.error).toBeUndefined(); - expect(results.REACT_APP_CLIENT_ID.status).toBeUndefined(); - expect(results.REACT_APP_CLIENT_ID.value).toEqual('abc123'); - }); + const results = await getValidationResults(); + expect(results.REACT_APP_CLIENT_ID.error).toBeUndefined(); + expect(results.REACT_APP_CLIENT_ID.status).toBeUndefined(); + expect(results.REACT_APP_CLIENT_ID.value).toEqual('abc123'); + }); }); describe('urlIsReachable', () => { test('urlIsReachable object has value if it succeeds', async () => { global.fetch = jest.fn().mockResolvedValue(mockResponse); - const result: ValidationType = await urlIsReachable('https://foo.bar'); + const result: ValidationType = (await urlIsReachable( + 'https://foo.bar', + )); expect(result.error).toBeUndefined(); expect(result.status).toBe(200); expect(result.value).toEqual('https://foo.bar'); @@ -129,7 +130,9 @@ describe('configUtil', () => { test('urlIsReachable object has error if it fails', async () => { global.fetch = jest.fn().mockRejectedValue(networkError); - const result: ValidationType = await urlIsReachable('https://foo.bar'); + const result: ValidationType = (await urlIsReachable( + 'https://foo.bar', + )); expect(result.error).toBeDefined(); expect(result.status).toBeUndefined(); expect(result.value).toBeUndefined(); From cc34f6e60e619fe6e543297d732e7ca5c0ea9543 Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Sat, 15 Mar 2025 08:20:19 +0100 Subject: [PATCH 36/36] Appropriate yarn syntax suggestions --- client/src/route/config/Config.tsx | 7 +++--- client/src/util/configUtil.ts | 24 ++++++++++++------- client/test/unit/page/Config.test.tsx | 12 +++++----- client/test/unit/util/ConfigUtil.test.ts | 30 ++++++++++-------------- 4 files changed, 39 insertions(+), 34 deletions(-) diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx index 0036e553e..c13f57823 100644 --- a/client/src/route/config/Config.tsx +++ b/client/src/route/config/Config.tsx @@ -114,9 +114,10 @@ const useValidationResults = () => { }; const useConfigErrors = (validationResults: Record) => - Object.keys(window.env).some( - (key) => validationResults[key]?.error !== undefined, - ); + Object.keys(window.env).some((key) => { + const result = validationResults[key]; + return result && 'error' in result && result.error !== undefined; + }); const Config = (props: { role: string }) => { const { validationResults, isLoading } = useValidationResults(); diff --git a/client/src/util/configUtil.ts b/client/src/util/configUtil.ts index 710f049db..a5033658e 100644 --- a/client/src/util/configUtil.ts +++ b/client/src/util/configUtil.ts @@ -59,14 +59,22 @@ function getValidationPromises(): Record> { }; } -export const getValidationResults = async (): Promise<{ [key: string]: ValidationType }> => { - const validationPromises: Record> = getValidationPromises(); - const entries = Object.entries(validationPromises); - const resolvedEntries = await Promise.all( - entries.map(async ([key, promise]) => [key, await promise] as [string, ValidationType]) - ); - return Object.fromEntries(resolvedEntries) as { [key: string]: ValidationType }; - }; +export const getValidationResults = async (): Promise< + Record +> => { + const validationPromises: Record< + string, + Promise + > = getValidationPromises(); + const entries = Object.entries(validationPromises); + const resolvedEntries = await Promise.all( + entries.map( + async ([key, promise]) => + [key, await promise] as [string, ValidationType], + ), + ); + return Object.fromEntries(resolvedEntries) as Record; +}; export async function retryFetch( url: string, diff --git a/client/test/unit/page/Config.test.tsx b/client/test/unit/page/Config.test.tsx index 951577a66..7c53d7b51 100644 --- a/client/test/unit/page/Config.test.tsx +++ b/client/test/unit/page/Config.test.tsx @@ -64,11 +64,11 @@ describe('Config', () => { expect(screen.getByText(/Verifying configuration/i)).toBeInTheDocument(); expect(screen.getByTestId('loading-icon')).toBeInTheDocument(); - await waitFor(() => + await waitFor(() => { expect( screen.getByText(/Invalid Application Configuration/i), - ).toBeInTheDocument(), - ); + ).toBeInTheDocument(); + }); const linkToDeveloperConfig = screen.getByRole('link', { name: /Inspect configuration/i, }); @@ -85,11 +85,11 @@ describe('Config', () => { expect(screen.getByText(/Verifying configuration/i)).toBeInTheDocument(); expect(screen.getByTestId('loading-icon')).toBeInTheDocument(); - await waitFor(() => + await waitFor(() => { expect( screen.getByText(/Configuration appears to be valid./i), - ).toBeInTheDocument(), - ); + ).toBeInTheDocument(); + }); const linkToDeveloperConfig = screen.getByRole('link', { name: /Return to login/i, }); diff --git a/client/test/unit/util/ConfigUtil.test.ts b/client/test/unit/util/ConfigUtil.test.ts index e5a85dbaa..7d7ffe98f 100644 --- a/client/test/unit/util/ConfigUtil.test.ts +++ b/client/test/unit/util/ConfigUtil.test.ts @@ -34,14 +34,14 @@ describe('configUtil', () => { describe('retryFetch', () => { test('retryFetch returns a valid response', async () => { global.fetch = jest.fn().mockResolvedValue(mockResponse); - const response: Response = (await retryFetch('https://foo.bar', { + const response: Response = await retryFetch('https://foo.bar', { method: 'HEAD', signal: AbortSignal.timeout(1000), - })); + }); expect(response.ok).toBe(true); expect(response.status).toBe(200); - const jsonResult = await response.json() as { data: string }; + const jsonResult = (await response.json()) as { data: string }; expect(jsonResult).toEqual({ data: 'success' }); }); @@ -52,7 +52,7 @@ describe('configUtil', () => { .mockRejectedValueOnce(networkError) .mockResolvedValueOnce(mockResponse); - const response: Response = (await retryFetch( + const response: Response = await retryFetch( 'http://foo.foo', { method: 'HEAD', @@ -60,7 +60,7 @@ describe('configUtil', () => { signal: AbortSignal.timeout(1500), }, 3, - )); + ); expect(response).toBe(mockResponse); expect(global.fetch).toHaveBeenCalledTimes(3); }); @@ -102,7 +102,7 @@ describe('configUtil', () => { test('getValidationResult ENVIRONMENT has error if it fails parse', async () => { window.env.REACT_APP_ENVIRONMENT = 'foo'; - const results: { [key: string]: ValidationType } = + const results: Record = await getValidationResults(); expect(results.REACT_APP_ENVIRONMENT.error).toBeDefined(); expect(results.REACT_APP_ENVIRONMENT.status).toBeUndefined(); @@ -110,19 +110,17 @@ describe('configUtil', () => { }); test('getValidationResult CLIENT_ID has value if it succeeds parse', async () => { - const results = await getValidationResults(); - expect(results.REACT_APP_CLIENT_ID.error).toBeUndefined(); - expect(results.REACT_APP_CLIENT_ID.status).toBeUndefined(); - expect(results.REACT_APP_CLIENT_ID.value).toEqual('abc123'); - }); + const results = await getValidationResults(); + expect(results.REACT_APP_CLIENT_ID.error).toBeUndefined(); + expect(results.REACT_APP_CLIENT_ID.status).toBeUndefined(); + expect(results.REACT_APP_CLIENT_ID.value).toEqual('abc123'); + }); }); describe('urlIsReachable', () => { test('urlIsReachable object has value if it succeeds', async () => { global.fetch = jest.fn().mockResolvedValue(mockResponse); - const result: ValidationType = (await urlIsReachable( - 'https://foo.bar', - )); + const result: ValidationType = await urlIsReachable('https://foo.bar'); expect(result.error).toBeUndefined(); expect(result.status).toBe(200); expect(result.value).toEqual('https://foo.bar'); @@ -130,9 +128,7 @@ describe('configUtil', () => { test('urlIsReachable object has error if it fails', async () => { global.fetch = jest.fn().mockRejectedValue(networkError); - const result: ValidationType = (await urlIsReachable( - 'https://foo.bar', - )); + const result: ValidationType = await urlIsReachable('https://foo.bar'); expect(result.error).toBeDefined(); expect(result.status).toBeUndefined(); expect(result.value).toBeUndefined();