diff --git a/.eslintrc.js b/.eslintrc.js index 2ede77591..6825127d3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,7 +2,7 @@ module.exports = { parser: "@babel/eslint-parser", // https://github.com/babel/babel/tree/main/eslint/babel-eslint-parser parserOptions: { babelOptions: { - configFile: "./.babelrc.json" + configFile: "./babel.config.json" }, ecmaFeatures: { arrowFunctions: true, @@ -25,7 +25,10 @@ module.exports = { }, plugins: [ "perfectionist", // https://github.com/azat-io/eslint-plugin-perfectionist - "react" // https://github.com/yannickcr/eslint-plugin-react + "react", // https://github.com/yannickcr/eslint-plugin-react + "jest", // https://github.com/jest-community/eslint-plugin-jest + "jest-dom", // https://github.com/testing-library/eslint-plugin-jest-dom + "testing-library" // https://github.com/testing-library/eslint-plugin-testing-library ], extends: [ "eslint:recommended", @@ -36,6 +39,12 @@ module.exports = { browser: true, // browser global variables node: true // Node.js global variables and Node.js-specific rules }, + overrides: [ + { + files: ["tests/**/*"], + env: { jest: true } + } + ], settings: { react: { version: "detect" diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml index 1a9ebde1f..bf0928dff 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint.yml @@ -31,15 +31,12 @@ jobs: uses: actions/checkout@v3 - name: Install ESLint - run: | - npm install eslint@8.56.0 - npm install @microsoft/eslint-formatter-sarif@3.0.0 - npm install eslint-plugin-react@7.33.2 + run: yarn install - name: Run ESLint - run: npx eslint . - --config .eslintrc.js - --ext .js,.jsx,.ts,.tsx + env: + SARIF_ESLINT_IGNORE_SUPPRESSED: true + run: yarn lint --format @microsoft/eslint-formatter-sarif --output-file eslint-results.sarif continue-on-error: true diff --git a/.github/workflows/jest.yml b/.github/workflows/jest.yml new file mode 100644 index 000000000..c15f1369e --- /dev/null +++ b/.github/workflows/jest.yml @@ -0,0 +1,24 @@ +name: Jest + +on: + push: + branches: [ "master" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "master" ] + schedule: + - cron: '29 3 * * 5' + +jobs: + eslint: + name: Run jest tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install Jest + run: yarn install + + - name: Run Jest + run: yarn test diff --git a/.gitignore b/.gitignore index aa1c83173..399c86878 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules/ +coverage/ +.idea/ npm-debug.log # On a fresh install with yarn 3.3.0 these extra files were generated. diff --git a/.babelrc.json b/babel.config.json similarity index 61% rename from .babelrc.json rename to babel.config.json index fe63b8a84..89c452411 100644 --- a/.babelrc.json +++ b/babel.config.json @@ -4,12 +4,7 @@ "@babel/plugin-transform-object-rest-spread" ], "presets": [ - [ - "@babel/preset-env", - { - "modules": false - } - ], + "@babel/preset-env", "@babel/preset-react" ] } diff --git a/components/StandardApp.jsx b/components/StandardApp.jsx index 5f773882a..a8ae3f561 100644 --- a/components/StandardApp.jsx +++ b/components/StandardApp.jsx @@ -130,7 +130,8 @@ class AppInitComponent extends React.Component { initialView = { center: coords, zoom: zoom, - crs: params.crs || theme.mapCrs}; + crs: params.crs || theme.mapCrs + }; } } else if (params.e) { const bounds = params.e.split(/[;,]/g).map(x => parseFloat(x)); @@ -223,6 +224,10 @@ export default class StandardApp extends React.Component { ); } setupTouchEvents = (el) => { + if (el === null) { + // Do nothing when unmounting + return; + } el.addEventListener('touchstart', ev => { this.touchY = ev.targetTouches[0].clientY; }, { passive: false }); diff --git a/jest.config.json b/jest.config.json new file mode 100644 index 000000000..070c3c04c --- /dev/null +++ b/jest.config.json @@ -0,0 +1,20 @@ +{ + "collectCoverageFrom": [ + "./**/*.{js,jsx}" + ], + "coverageDirectory": "coverage", + "moduleNameMapper": { + "^openlayers$": "/libs/openlayers.js", + "\\.(css|less|svg|png)$": "/tests/mocks/AssetMock.js" + }, + "setupFilesAfterEnv": [ + "/tests/jest.setup.js" + ], + "testEnvironment": "jsdom", + "testMatch": [ + "/tests/**/*.test.(js|jsx|ts|tsx)" + ], + "transformIgnorePatterns": [ + "node_modules/(?!(color-name|color-parse|color-rgba|color-space|flat|ol|ol-ext)/)" + ] +} diff --git a/package.json b/package.json index 630893c4b..20d5bb6c0 100644 --- a/package.json +++ b/package.json @@ -73,16 +73,29 @@ "@babel/preset-env": "^7.24.5", "@babel/preset-react": "^7.24.1", "@furkot/webfonts-generator": "^2.0.2", + "@microsoft/eslint-formatter-sarif": "^3.1.0", + "@testing-library/dom": "^10.1.0", + "@testing-library/jest-dom": "^6.4.6", + "@testing-library/react": "^16.0.0", "@types/react": "^18.3.1", "eslint": "^8.56.0", + "eslint-plugin-jest": "^28.6.0", + "eslint-plugin-jest-dom": "^5.4.0", "eslint-plugin-perfectionist": "^2.10.0", - "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react": "^7.34.1", + "eslint-plugin-testing-library": "^6.2.2", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "mkdirp": "^3.0.1", "object-path": "^0.11.8", "react-docgen": "^5.4.3", + "redux-mock-store": "^1.5.4", "typescript": "^5.4.5" }, "scripts": { - "plugindoc": "node scripts/gen-plugin-docs.js" + "plugindoc": "node scripts/gen-plugin-docs.js", + "lint": "eslint . --config .eslintrc.js --ext .js,.jsx,.ts,.tsx", + "test": "jest", + "coverage": "jest --coverage" } } diff --git a/tests/components/CoordinateDisplayer.test.jsx b/tests/components/CoordinateDisplayer.test.jsx new file mode 100644 index 000000000..63d7746e7 --- /dev/null +++ b/tests/components/CoordinateDisplayer.test.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import {Provider} from "react-redux"; + +import {render, screen} from '@testing-library/react'; +import configureStore from 'redux-mock-store'; + +import CoordinateDisplayer from '../../components/CoordinateDisplayer'; + +const mockStore = configureStore(); + +test('current coordinates are shown', () => { + const store = mockStore({ + map: { projection: 'EPSG:4326' }, + mousePosition: { position: { coordinate: [123, 456] } } + }); + + render( + + + + ); + + expect(screen.getByRole('textbox')).toHaveValue('123.0000 456.0000'); +}); diff --git a/tests/components/SearchBox.test.jsx b/tests/components/SearchBox.test.jsx new file mode 100644 index 000000000..f67665b3e --- /dev/null +++ b/tests/components/SearchBox.test.jsx @@ -0,0 +1,76 @@ +import React from 'react'; +import {Provider} from "react-redux"; + +import {act, fireEvent, render, screen} from '@testing-library/react'; +import configureStore from 'redux-mock-store'; + +import SearchBox from '../../components/SearchBox'; + +const mockStore = configureStore(); + +const searchProviders = { + testing: { + onSearch: (text, searchParams, callback) => callback({ + results: [ + { + id: 'layer1', + title: 'Layer 1', + items: [ + { + id: 'item1', + text: 'Item 1' + }, + { + id: 'item2', + text: 'Item 2' + } + ] + }, + { + id: 'layer2', + title: 'Layer 2', + items: [ + { + id: 'item3', + text: 'Item 3' + } + ] + } + ] + }) + } +}; + +// eslint-disable-next-line +const Search = SearchBox(searchProviders); + +test('search results are visible', () => { + const store = mockStore({ + map: { projection: 'EPSG:4326' }, + layers: { flat: [] }, + theme: { current: { searchProviders: ['testing'] } } + }); + + const searchOptions = { + allowSearchFilters: false + }; + + render( + + + + ); + + const input = screen.getByRole('input'); + expect(input).toHaveValue(''); + + fireEvent.change(input, { target: { value: 'Test' } }); + act(() => input.focus()); + + expect(input).toHaveValue('Test'); + expect(screen.getByText('Layer 1')).toBeInTheDocument(); + expect(screen.getByText('Layer 2')).toBeInTheDocument(); + expect(screen.getByText('Item 1')).toBeInTheDocument(); + expect(screen.getByText('Item 2')).toBeInTheDocument(); + expect(screen.getByText('Item 3')).toBeInTheDocument(); +}); diff --git a/tests/components/StandardApp.test.jsx b/tests/components/StandardApp.test.jsx new file mode 100644 index 000000000..7227d9257 --- /dev/null +++ b/tests/components/StandardApp.test.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import {Provider} from "react-redux"; + +import {render} from '@testing-library/react'; +import configureStore from 'redux-mock-store'; + +import StandardApp from "../../components/StandardApp"; + +const mockStore = configureStore(); + +test('app is running w/o plugins', () => { + const store = mockStore({}); + + const appConfig = { + initialState: { + defaultState: {} + }, + pluginsDef: { + plugins: {} + } + }; + + render( + + + + ); +}); diff --git a/tests/jest.setup.js b/tests/jest.setup.js new file mode 100644 index 000000000..a9cf5db4f --- /dev/null +++ b/tests/jest.setup.js @@ -0,0 +1,12 @@ +import LocaleUtils from "../utils/LocaleUtils"; +import MapUtils from "../utils/MapUtils"; +import MockMap from "./mocks/MockMap"; + +import '@testing-library/jest-dom'; + +// Mock translation function, just return the message key +LocaleUtils.tr = (key) => key; +LocaleUtils.lang = () => 'en'; + +// Mock the Map object globally +MapUtils.registerHook(MapUtils.GET_MAP, new MockMap()); diff --git a/tests/libs/openlayers.test.js b/tests/libs/openlayers.test.js new file mode 100644 index 000000000..fe31e0658 --- /dev/null +++ b/tests/libs/openlayers.test.js @@ -0,0 +1,5 @@ +import ol from 'openlayers'; + +test('import openlayers', () => { + expect(ol).not.toBe(undefined); +}); diff --git a/tests/mocks/AssetMock.js b/tests/mocks/AssetMock.js new file mode 100644 index 000000000..f053ebf79 --- /dev/null +++ b/tests/mocks/AssetMock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/tests/mocks/MockMap.js b/tests/mocks/MockMap.js new file mode 100644 index 000000000..a66cb3b30 --- /dev/null +++ b/tests/mocks/MockMap.js @@ -0,0 +1,5 @@ +export default class MockMap { + addLayer = () => null; + removeLayer = () => null; + getViewport = () => document.createElement('div'); +}