diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs index 995793350..c66a8e0f0 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"; @@ -21,7 +22,7 @@ export default [{ ignores: [ "**/api/", "**/build/", - "**/config/", + "client/config/", "**/node_modules/", "**/script/", "**/coverage/", @@ -40,8 +41,9 @@ export default [{ plugins: { "jsx-a11y": jsxA11Y, react, + "react-hooks": reactHooks, jest, - "@typescript-eslint": typescriptEslint, + "@typescript-eslint": typescriptEslint }, languageOptions: { @@ -86,9 +88,11 @@ export default [{ "@typescript-eslint/no-unused-vars": [ "error", { - "caughtErrorsIgnorePattern": "^_", + "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/jest.config.json b/client/jest.config.json index 7db0ff83f..1e803bd4c 100644 --- a/client/jest.config.json +++ b/client/jest.config.json @@ -4,21 +4,11 @@ "transform": { "^.+\\.tsx?$": "ts-jest" }, - "transformIgnorePatterns": [ - "/node_modules/(?![d3-shape|recharts]).+\\.js$" - ], + "transformIgnorePatterns": ["/node_modules/(?![d3-shape|recharts]).+\\.js$"], "collectCoverage": true, - "coverageReporters": [ - "text", - "cobertura", - "clover", - "lcov", - "json" - ], + "coverageReporters": ["text", "cobertura", "clover", "lcov", "json"], "testTimeout": 15000, - "collectCoverageFrom": [ - "src/**/*.{ts,tsx}" - ], + "collectCoverageFrom": ["src/**/*.{ts,tsx}"], "coveragePathIgnorePatterns": [ "node_modules", "build", @@ -27,11 +17,7 @@ "src/store/store.ts", "src/preview/util/gitlabDriver.ts" ], - "modulePathIgnorePatterns": [ - "test/e2e", - "mocks", - "config" - ], + "modulePathIgnorePatterns": ["test/e2e", "mocks", "config"], "coverageDirectory": "/coverage/", "globals": { "window.ENV.SERVER_HOSTNAME": "localhost", @@ -39,9 +25,7 @@ }, "verbose": true, "testRegex": "/test/.*\\.test.tsx?$", - "modulePaths": [ - "/src/" - ], + "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 7989f5acf..77aa9ae31 100644 --- a/client/package.json +++ b/client/package.json @@ -1,21 +1,23 @@ { "name": "@into-cps-association/dtaas-web", - "version": "0.8.0", + "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", + "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", @@ -23,16 +25,16 @@ "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}\"", + "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", + "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: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" @@ -71,6 +73,8 @@ "eslint-plugin-jest": "^28.8.3", "eslint-plugin-jsx-a11y": "^6.8.0", "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^5.1.0", + "http-status-codes": "^2.3.0", "jest-fetch-mock": "^3.0.3", "katex": "^0.16.11", "markdown-it-katex": "^2.0.3", @@ -93,7 +97,8 @@ "resize-observer-polyfill": "^1.5.1", "serve": "^14.2.1", "styled-components": "^6.1.1", - "typescript": "5.1.6" + "typescript": "5.1.6", + "zod": "^3.24.1" }, "devDependencies": { "@babel/core": "7.25.8", diff --git a/client/playwright.config.ts b/client/playwright.config.ts index ee8021393..6e2eb5a9e 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: 60 * 1000, + timeout: process.env.CI ? 1000 : 30 * 1000, globalTimeout: 10 * 60 * 1000, testDir: './test/e2e/tests', testMatch: /.*\.test\.ts/, @@ -50,6 +51,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/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/preview/route/digitaltwins/editor/PreviewTab.tsx b/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx index 31acd62f8..1a96f5db2 100644 --- a/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx +++ b/client/src/preview/route/digitaltwins/editor/PreviewTab.tsx @@ -6,38 +6,38 @@ import * as RemarkableKatex from 'remarkable-katex'; import SyntaxHighlighter from 'react-syntax-highlighter'; interface PreviewProps { - fileContent: string; - fileType: string; + fileContent: string; + fileType: string; } function PreviewTab({ fileContent, fileType }: PreviewProps) { - if (fileType === 'md') { - const md = new Remarkable({ - html: true, - typographer: true, - }).use(RemarkableKatex); + if (fileType === 'md') { + const md = new Remarkable({ + html: true, + typographer: true, + }).use(RemarkableKatex); - const renderedMarkdown = md.render(fileContent); + const renderedMarkdown = md.render(fileContent); - return ( -
-
- -
- ); - } +
+ ); + } - if (fileType === 'json') { - return {fileContent}; - } - if (fileType === 'yaml' || fileType === 'yml') { - return {fileContent}; - } - return {fileContent}; + if (fileType === 'json') { + return {fileContent}; + } + if (fileType === 'yaml' || fileType === 'yml') { + return {fileContent}; + } + return {fileContent}; + return null; } export default PreviewTab; diff --git a/client/src/route/auth/Signin.tsx b/client/src/route/auth/Signin.tsx index a113ab265..71429ec24 100644 --- a/client/src/route/auth/Signin.tsx +++ b/client/src/route/auth/Signin.tsx @@ -2,7 +2,6 @@ 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'; @@ -13,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; diff --git a/client/src/route/config/Config.tsx b/client/src/route/config/Config.tsx new file mode 100644 index 000000000..ef3c5e185 --- /dev/null +++ b/client/src/route/config/Config.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { getValidationResults, ValidationType } from 'util/configUtil'; +import { Paper, Typography } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import { ConfigItem, loadingComponent } from './ConfigItems'; + +const paperStyle = { + p: 2, + marginTop: '2%', + position: 'relative', + marginLeft: 'auto', + marginRight: 'auto', + display: 'flex', + flexDirection: 'column', +}; + +const typographyStyle = { + fontSize: 'clamp(0.2rem, 4vw, 1.6rem)', + padding: 'clamp(0, 4vw, 5%)', +}; + +const DeveloperConfig = (validationResults: { + [key: string]: ValidationType; +}): JSX.Element => ( + + + {'Config verification'} + +
+ {Object.entries(window.env).map(([key, value]) => ( + + ))} +
+
+); + +const userConfigTitle: JSX.Element = ( + <> + Invalid Application Configuration. Please contact the administrator of your + DTaaS installation. +
+ + Inspect configuration + + +); + +const UserConfig = (): JSX.Element => ( + + + {userConfigTitle} + + + ); + +const useValidationResults = () => { + const [validationResults, setValidationResults] = useState<{ + [key: string]: ValidationType; + }>({}); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchValidationResults = async () => { + try { + const results = await getValidationResults(); + setValidationResults(results); + } finally { + setIsLoading(false); + } + }; + + fetchValidationResults(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [window.env]); + + return { validationResults, isLoading }; +}; + +const useConfigErrors = (validationResults: { + [key: string]: ValidationType; +}) => Object.keys(window.env).some( + (key) => key !== undefined && validationResults[key]?.error !== undefined, + ); + +const Config = (props: { role: string }) => { + const navigate = useNavigate(); + const { validationResults, isLoading } = useValidationResults(); + const hasConfigErrors = useConfigErrors(validationResults); + + const configVerification = + props.role === 'user' ? UserConfig() : DeveloperConfig(validationResults); + + const shouldRedirect = + !isLoading && props.role === 'user' && !hasConfigErrors; + useEffect(() => { + if (shouldRedirect) { + navigate('/'); + } + }, [shouldRedirect, navigate]); + + if (isLoading) { + return loadingComponent(); + } + + if (shouldRedirect) { + return null; + } + + return configVerification; +}; + +export default Config; diff --git a/client/src/route/config/ConfigItems.tsx b/client/src/route/config/ConfigItems.tsx new file mode 100644 index 000000000..1214ac61a --- /dev/null +++ b/client/src/route/config/ConfigItems.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; +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'; +import { StatusCodes } from 'http-status-codes'; + +const ConfigIcon = (toolTipTitle: string, icon: JSX.Element): JSX.Element => ( + + {icon} + +); + +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}`, + }; + } + + // If status is undefined then the validation is derived from a parsing. + 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 => { + const { icon, hoverTip } = getValidationIconConfig(validation, label); + return ConfigIcon(hoverTip, 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 loadingComponent = (): React.ReactNode => ( + + Verifying configuration + + +); diff --git a/client/src/routes.tsx b/client/src/routes.tsx index 6a28666cd..59895fb22 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 Config from './route/config/Config'; export const routes = [ { @@ -18,6 +19,22 @@ export const routes = [ ), }, + { + path: 'config/developer', + element: ( + + + + ), + }, + { + path: 'config/user', + element: ( + + + + ), + }, { path: 'library', element: ( diff --git a/client/src/util/auth/Authentication.ts b/client/src/util/auth/Authentication.ts index 5e2262032..8cc7c6c2d 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() { @@ -66,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 new file mode 100644 index 000000000..3553079d8 --- /dev/null +++ b/client/src/util/configUtil.ts @@ -0,0 +1,176 @@ +import { z } from 'zod'; +import { wait } from 'util/auth/Authentication'; + +export type ValidationType = { + value?: string; + status?: number; + error?: string; +}; + +const EnvironmentEnum = z.enum(['dev', 'local', 'prod', 'test']); +const PathString = z.string(); +const ScopesString = z.literal('openid profile read_user read_repository api'); + +const pathKeys = [ + 'REACT_APP_URL_BASENAME', + 'REACT_APP_URL_DTLINK', + 'REACT_APP_URL_LIBLINK', + 'REACT_APP_WORKBENCHLINK_VNCDESKTOP', + 'REACT_APP_WORKBENCHLINK_VSCODE', + 'REACT_APP_WORKBENCHLINK_JUPYTERLAB', + 'REACT_APP_WORKBENCHLINK_JUPYTERLAB', + 'REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK', + 'REACT_APP_CLIENT_ID', + 'REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW', + 'REACT_APP_WORKBENCHLINK_DT_PREVIEW', +]; + +const urlKeys = [ + 'REACT_APP_URL', + 'REACT_APP_REDIRECT_URI', + 'REACT_APP_LOGOUT_REDIRECT_URI', + 'REACT_APP_AUTH_AUTHORITY', +]; + +function getValidationPromises(): Record> { + const isDocker = process.env.REACT_APP_IS_DOCKER === 'true'; + return { + REACT_APP_ENVIRONMENT: Promise.resolve( + parseField(EnvironmentEnum, window.env.REACT_APP_ENVIRONMENT), + ), + REACT_APP_GITLAB_SCOPES: Promise.resolve( + parseField(ScopesString, window.env.REACT_APP_GITLAB_SCOPES), + ), + ...Object.fromEntries( + pathKeys.map((key) => [ + key, + parseField(PathString, window.env[key] ?? ''), + ]), + ), + ...Object.fromEntries( + urlKeys.map((key) => { + const url = window.env[key] ?? ''; + if (isDocker) { + window.env[key] = url.replace( + /https?:\/\/localhost(:\d*)?/i, + 'http://host.docker.internal:80', + ); + } + return [key, urlIsReachable(window.env[key] ?? '')]; + }), + ), + }; +} + +export const getValidationResults = async (): Promise<{ + [key: string]: ValidationType; +}> => { + const validationPromises: Record< + string, + Promise + > = getValidationPromises(); + + return ( + await Promise.all( + Object.entries(validationPromises).map(async ([key, task]) => ({ + [key]: await task, + })), + ) + ).reduce((acc, result) => ({ ...acc, ...result }), {}); +}; + +export async function retryFetch( + url: string, + options: RequestInit = {}, + retries = 2, +): Promise { + try { + return await fetch(url, options); + } catch (error) { + if (retries <= 0) { + return Promise.reject(error); + } + await wait(1000); + return retryFetch(url, options, retries - 1); + } +} + +async function corsRequest(url: string): Promise { + const urlValidation: ValidationType = { + value: undefined, + status: undefined, + error: undefined, + }; + try { + const response = await retryFetch(url, { + method: 'GET', + signal: AbortSignal.timeout(2000), + headers: { + Accept: '*/*', + Origin: window.location.origin, + }, + redirect: 'manual', + }); + const responseIsAcceptable = response.ok || response.redirected; + if (responseIsAcceptable) { + urlValidation.value = url; + urlValidation.status = response.status; + } + } catch (_error) { + return null; + } + return urlValidation; +} + +async function opaqueRequest(url: string): Promise { + const urlValidation: ValidationType = { + value: undefined, + status: undefined, + error: undefined, + }; + try { + await retryFetch(url, { + method: 'GET', + mode: 'no-cors', + signal: AbortSignal.timeout(2000), + }); + urlValidation.value = url; + urlValidation.status = 0; + } catch (_error) { + return null; + } + return urlValidation; +} + +export async function urlIsReachable(url: string): Promise { + let reachability: ValidationType = { + value: undefined, + status: undefined, + error: `Failed to fetch ${url} after multiple attempts.`, + }; + const corsResponse = await corsRequest(url); + if (corsResponse) { + reachability = corsResponse; + } else { + const opaqueResponse = await opaqueRequest(url); + if (opaqueResponse) { + reachability = opaqueResponse; + } + } + return reachability; +} + +const parseField = ( + parser: { + safeParse: (value: string) => { + success: boolean; + error?: { message?: string }; + }; + }, + value: string, +): ValidationType => { + const result = parser.safeParse(value); + return result.success + ? { error: undefined, value, status: undefined } + : { error: result.error?.message, status: undefined, value: undefined }; +}; diff --git a/client/test/__mocks__/global_mocks.ts b/client/test/__mocks__/global_mocks.ts index 19303efae..5e5020a14 100644 --- a/client/test/__mocks__/global_mocks.ts +++ b/client/test/__mocks__/global_mocks.ts @@ -28,6 +28,12 @@ export const mockUser: mockUserType = { }, }; +export const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: 'success' }), +}); + export type mockAuthStateType = { user?: mockUserType | null; isLoading: boolean; @@ -42,6 +48,27 @@ export const mockAuthState: mockAuthStateType = { user: mockUser, }; +window.env = { + ...window.env, + 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_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', +}; + jest.mock('util/envUtil', () => ({ ...jest.requireActual('util/envUtil'), useAppURL: () => mockAppURL, diff --git a/client/test/e2e/tests/Auth.test.ts b/client/test/e2e/tests/Auth.test.ts index 14afe3eb9..b62b13770 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 }) => { @@ -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/Config.Verify.test.ts b/client/test/e2e/tests/Config.Verify.test.ts new file mode 100644 index 000000000..6fdbef4ef --- /dev/null +++ b/client/test/e2e/tests/Config.Verify.test.ts @@ -0,0 +1,37 @@ +import test from 'test/e2e/setup/fixtures'; +import { expect } from '@playwright/test'; + +test('Developer config is visible', async ({ page }) => { + await page.goto('./config/developer'); + await expect(page.getByText('Verifying configuration')).toBeVisible(); + + await page.waitForSelector('[data-testid="success-icon"]', { + timeout: 12000, + state: 'visible', + }); + + 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 + .getByLabel('REACT_APP_ENVIRONMENT field is configured correctly.') + .locator('path'), + ).toBeVisible(); + + await expect(page.getByTestId('error-icon')).toBeHidden(); +}); + +test('User config is visible', async ({ page }) => { + await page.goto('./config/user'); + await expect(page.getByText('Verifying configuration')).toBeVisible(); + await page.waitForURL('/', { timeout: 12000 }); +}); diff --git a/client/test/e2e/tests/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/auth.setup.ts b/client/test/e2e/tests/auth.setup.ts index dcb555bea..cf6aba5c1 100644 --- a/client/test/e2e/tests/auth.setup.ts +++ b/client/test/e2e/tests/auth.setup.ts @@ -14,7 +14,7 @@ setup('authenticate', async ({ page }) => { await page.goto('./'); 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/integration/Auth/WaitAndNavigate.test.tsx b/client/test/integration/Auth/WaitAndNavigate.test.tsx index afd27df2e..16318f102 100644 --- a/client/test/integration/Auth/WaitAndNavigate.test.tsx +++ b/client/test/integration/Auth/WaitAndNavigate.test.tsx @@ -1,9 +1,17 @@ -import { act, screen } from '@testing-library/react'; -import { mockAuthState } from 'test/__mocks__/global_mocks'; +import { act, screen, waitFor } from '@testing-library/react'; +import { mockAuthState, mockFetch } from 'test/__mocks__/global_mocks'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; jest.useFakeTimers(); +// Bypass the config verification +global.fetch = mockFetch; + +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', { @@ -15,11 +23,11 @@ Object.defineProperty(window, 'location', { }); describe('WaitAndNavigate', () => { - beforeEach(async () => { - 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(); @@ -27,6 +35,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 b086aa7c8..5b2c9155c 100644 --- a/client/test/integration/Auth/authRedux.test.tsx +++ b/client/test/integration/Auth/authRedux.test.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; import { createStore } from 'redux'; -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 Library from 'route/library/Library'; import authReducer from 'store/auth.slice'; -import { mockUser } from 'test/__mocks__/global_mocks'; +import { mockFetch, mockUser } from 'test/__mocks__/global_mocks'; import { renderWithRouter } from 'test/unit/unit.testUtil'; jest.mock('util/auth/Authentication', () => ({ @@ -22,6 +22,14 @@ jest.mock('page/Menu', () => ({ default: () =>
, })); +// Bypass the config verification +global.fetch = mockFetch; + +Object.defineProperty(AbortSignal, 'timeout', { + value: jest.fn(), + writable: false, +}); + const store = createStore(authReducer); type AuthState = { @@ -63,12 +71,14 @@ describe('Redux and Authentication integration test', () => { }; }); - it('renders undefined username when not authenticated', () => { + it('renders undefined username when not authenticated', async () => { 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, ); @@ -84,7 +94,7 @@ describe('Redux and Authentication integration test', () => { expect(store.getState().userName).toBe('username'); }); - it('renders undefined username after ending authentication', () => { + it('renders undefined username after ending authentication', async () => { setupTest({ isAuthenticated: true, }); @@ -94,7 +104,9 @@ describe('Redux and Authentication integration test', () => { 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 e26828eb3..c056ae5e4 100644 --- a/client/test/integration/Routes/Signin.test.tsx +++ b/client/test/integration/Routes/Signin.test.tsx @@ -1,7 +1,16 @@ import { screen } from '@testing-library/react'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; +import { mockFetch } from 'test/__mocks__/global_mocks'; import { testPublicLayout } from './routes.testUtil'; +// Bypass the config verification +global.fetch = mockFetch; + +Object.defineProperty(AbortSignal, 'timeout', { + value: jest.fn(), + writable: false, +}); + const setup = () => setupIntegrationTest('/'); describe('Signin', () => { 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/integration/jest.setup.ts b/client/test/integration/jest.setup.ts index a609475d2..f48d08d20 100644 --- a/client/test/integration/jest.setup.ts +++ b/client/test/integration/jest.setup.ts @@ -5,9 +5,3 @@ import 'test/__mocks__/global_mocks'; beforeEach(() => { jest.resetAllMocks(); }); - -global.window.env = { - ...global.window.env, - REACT_APP_AUTH_AUTHORITY: - process.env.REACT_APP_AUTH_AUTHORITY || 'https://example.com', -}; diff --git a/client/test/preview/__mocks__/global_mocks.ts b/client/test/preview/__mocks__/global_mocks.ts index 8d9e0d057..ada39a2f9 100644 --- a/client/test/preview/__mocks__/global_mocks.ts +++ b/client/test/preview/__mocks__/global_mocks.ts @@ -180,3 +180,24 @@ jest.mock('util/envUtil', () => ({ { key: '3', link: 'link3' }, ], })); + +window.env = { + ...window.env, + 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_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/components/PrivateRoute.test.tsx b/client/test/unit/components/PrivateRoute.test.tsx index 87dddc9b7..bfe4c27d3 100644 --- a/client/test/unit/components/PrivateRoute.test.tsx +++ b/client/test/unit/components/PrivateRoute.test.tsx @@ -1,13 +1,22 @@ 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'; +import { mockFetch } from 'test/__mocks__/global_mocks'; jest.mock('react-oidc-context', () => ({ useAuth: jest.fn(), })); +// Bypass the config verification +global.fetch = mockFetch; + +Object.defineProperty(AbortSignal, 'timeout', { + value: jest.fn(), + writable: false, +}); + const TestComponent = () =>
Test Component
; type AuthState = { @@ -34,38 +43,45 @@ const setupTest = (authState: AuthState) => { ); }; -test('renders loading and redirects correctly when authenticated/not authentic', () => { - 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, + }); - expect(screen.getByText('Signin')).toBeInTheDocument(); + await waitFor( + () => expect(screen.getByText('Signin')).toBeInTheDocument(), + { + timeout: 60000, + }, + ); - setupTest({ - isLoading: true, - error: null, - isAuthenticated: false, - }); + setupTest({ + isLoading: true, + error: null, + isAuthenticated: false, + }); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); - expect(screen.getByText('Loading...')).toBeInTheDocument(); + setupTest({ + isLoading: false, + error: null, + isAuthenticated: true, + }); - setupTest({ - isLoading: false, - error: null, - isAuthenticated: true, + expect(screen.getByText('Test Component')).toBeInTheDocument(); }); - expect(screen.getByText('Test Component')).toBeInTheDocument(); -}); + test('renders error', () => { + setupTest({ + isLoading: false, + error: new Error('Test error'), + isAuthenticated: false, + }); -test('renders error', () => { - setupTest({ - isLoading: false, - error: new Error('Test error'), - isAuthenticated: false, + expect(screen.getByText('Oops... Test error')).toBeInTheDocument(); }); - - 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..fd83e54af --- /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', './developer'); + }); + + test('redirects to /', async () => { + render( + + + , + ); + + expect(screen.getByText(/Verifying configuration/i)).toBeInTheDocument(); + expect(screen.getByTestId('loading-icon')).toBeInTheDocument(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + }); +}); 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(); + }); +}); 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/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/test/unit/util/ConfigUtil.test.ts b/client/test/unit/util/ConfigUtil.test.ts new file mode 100644 index 000000000..ee5115c91 --- /dev/null +++ b/client/test/unit/util/ConfigUtil.test.ts @@ -0,0 +1,130 @@ +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(() => { + global.fetch = jest.fn().mockResolvedValue(mockResponse); + networkError = new Error('Network error'); + }); + + afterEach(() => { + window.env = { ...initialEnv }; + jest.resetAllMocks(); + }); + + const mockResponse = { + ok: true, + status: 200, + json: async () => ({ data: 'success' }), + }; + + 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), + }); + + 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(3); + }); + }); + + describe('getValidationResults', () => { + test('getValidationResults object includes all keys of window.env', async () => { + const results = await getValidationResults(); + const resultKeys = Object.keys(results); + const envKeys = Object.keys(window.env); + + const missingKeys = envKeys.filter((key) => !resultKeys.includes(key)); + const unexpectedKeys = resultKeys.filter((key) => !envKeys.includes(key)); + + 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('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'); + }); + }); + + 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('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(); + }); + }); +}); 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"] +} diff --git a/client/yarn.lock b/client/yarn.lock index f4deaf909..208dcc350 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" @@ -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" @@ -6312,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" @@ -8307,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" @@ -12034,3 +12044,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.24.1: + version "3.24.1" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.1.tgz#27445c912738c8ad1e9de1bea0359fa44d9d35ee" + integrity sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A== diff --git a/docker/client.built.dockerfile b/docker/client.built.dockerfile new file mode 100644 index 000000000..9af75d94a --- /dev/null +++ b/docker/client.built.dockerfile @@ -0,0 +1,19 @@ +#! docker should be run from the root directory of the project +FROM node:20.10.0-slim as build + +ARG REACT_APP_IS_DOCKER +ENV REACT_APP_IS_DOCKER=$REACT_APP_IS_DOCKER + +# Set the working directory inside the container +WORKDIR /dtaas/client + +# Copy package.json and package-lock.json to the working directory +COPY ./client/package.json ./ + +# Copy the rest of the application code to the working directory +COPY ./client/ . + +WORKDIR /dtaas/client +RUN npm i -g serve +# Define the command to run your app +CMD ["yarn", "start"] \ No newline at end of file diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index 830ecf3cb..987985ec4 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -9,6 +9,12 @@ services: - "--entrypoints.web.forwardedHeaders.insecure=true" - "--entrypoints.web.proxyProtocol.insecure=true" - "--log.level=DEBUG" + labels: + - "traefik.http.middlewares.cors.headers.accessControlAllowOriginList=http://localhost,http://host.docker.internal" + - "traefik.http.middlewares.cors.headers.accessControlAllowMethods=GET,OPTIONS" + - "traefik.http.middlewares.cors.headers.accessControlAllowCredentials=true" + - "traefik.http.routers.myservice.rule=Host(`myservice.localhost`)" + - "traefik.http.services.myservice.loadbalancer.server.port=80" ports: - "80:80" volumes: @@ -21,6 +27,10 @@ services: build: context: ${DTAAS_DIR}/ dockerfile: ${DTAAS_DIR}/docker/client.dockerfile + args: + - REACT_APP_IS_DOCKER=true + environment: + - REACT_APP_IS_DOCKER=true restart: unless-stopped volumes: - "${DTAAS_DIR}/client/config/local.js:/dtaas/client/build/env.js"