From fecd5f062c9d25d6adf57f312ab69daf5b2a34fb Mon Sep 17 00:00:00 2001 From: atomicgamedev Date: Tue, 17 Dec 2024 18:46:20 +0100 Subject: [PATCH 01/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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;