Sfoglia il codice sorgente

NO-JIRA: Add Jest unit testing infrastructure

- Replace ts-loader with `swc-loader` and `fork-ts-checker-webpack-plugin`
- Add Jest, React Testing Library, eslint rules for jest and testing-library
- Tighten up eslint config
- Add CI integration via jest-junit in test-frontend.sh
- Add example tests for ExamplePage and i18n namespace consistency
logonoff 4 mesi fa
parent
commit
f99626cdfe

+ 4 - 0
.gitignore

@@ -5,6 +5,10 @@
 integration-tests/videos
 integration-tests/screenshots
 
+# Jest
+coverage/
+jest-junit.xml
+
 # Yarn v4 (Berry)
 .yarn/*
 install-state.gz

+ 22 - 0
.swcrc

@@ -0,0 +1,22 @@
+{
+  "$schema": "https://swc.rs/schema.json",
+  "jsc": {
+    "parser": {
+      "syntax": "typescript",
+      "jsx": true,
+      "tsx": true
+    },
+    "transform": {
+      "react": {
+        "runtime": "automatic"
+      }
+    },
+    "target": "es2021",
+    "baseUrl": "."
+  },
+  "module": {
+    "type": "es6"
+  },
+  "sourceMaps": true,
+  "minify": true
+}

+ 16 - 0
__mocks__/@openshift-console/dynamic-plugin-sdk.tsx

@@ -0,0 +1,16 @@
+/*
+ * A majority of the OpenShift Console's dynamic plugin SDK components and API
+ * implementations are only available at runtime as they are provided using
+ * module federation.
+ *
+ * As a result, no implementations of these components and APIs are available
+ * when running tests in your plugin.
+ *
+ * To workaround this, you may add minimal stub implementations of components
+ * and APIs you use in your plugin here to allow your tests to run.
+ */
+import type * as SDK from '@openshift-console/dynamic-plugin-sdk';
+
+export const ListPageHeader: typeof SDK.ListPageHeader = ({ title }) => <h1>{title}</h1>;
+
+export const DocumentTitle: typeof SDK.DocumentTitle = () => null;

+ 1 - 0
__mocks__/fileMock.ts

@@ -0,0 +1 @@
+export default 'test-file-stub';

+ 5 - 0
__mocks__/react-i18next.ts

@@ -0,0 +1,5 @@
+import type { FC, PropsWithChildren } from 'react';
+
+export const useTranslation = () => ({ t: (key: string) => key });
+
+export const Trans: FC<PropsWithChildren> = ({ children }) => children;

+ 1 - 0
__mocks__/styleMock.ts

@@ -0,0 +1 @@
+module.exports = {};

+ 41 - 6
eslint.config.mjs

@@ -3,7 +3,11 @@ import tseslint from 'typescript-eslint';
 import react from 'eslint-plugin-react';
 import prettier from 'eslint-plugin-prettier/recommended';
 import reactHooks from 'eslint-plugin-react-hooks';
+import importX from 'eslint-plugin-import-x';
+import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript';
 import cypress from 'eslint-plugin-cypress';
+import jest from 'eslint-plugin-jest';
+import testingLibrary from 'eslint-plugin-testing-library';
 import globals from 'globals';
 
 export default tseslint.config(
@@ -11,35 +15,65 @@ export default tseslint.config(
     ignores: ['dist/', 'node_modules/'],
   },
   eslint.configs.recommended,
-  tseslint.configs.recommended,
-  reactHooks.configs.flat.recommended,
+  tseslint.configs.strictTypeChecked,
+  tseslint.configs.stylisticTypeChecked,
+  reactHooks.configs.flat['recommended-latest'],
+  importX.flatConfigs.recommended,
+  importX.flatConfigs.typescript,
+  {
+    settings: {
+      'import-x/resolver-next': [createTypeScriptImportResolver()],
+    },
+  },
   {
     files: ['src/**/*.{ts,tsx}'],
     plugins: {
       react,
     },
     rules: {
-      ...eslint.configs.recommended.rules,
-      ...tseslint.configs.recommended.rules,
       ...react.configs.recommended.rules,
       ...react.configs['jsx-runtime'].rules,
+      '@typescript-eslint/consistent-type-imports': 'error',
     },
     languageOptions: {
       globals: globals.browser,
       parserOptions: {
+        projectService: true,
         ecmaFeatures: {
           jsx: true,
         },
-      }
+      },
     },
     settings: {
       react: {
         version: 'detect',
       },
-    }
+    },
+  },
+  {
+    files: ['src/**/*.spec.{ts,tsx}'],
+    plugins: {
+      ...jest.configs['flat/recommended'].plugins,
+      ...jest.configs['flat/style'].plugins,
+      ...testingLibrary.configs['flat/react'].plugins,
+    },
+    languageOptions: {
+      ...jest.configs['flat/recommended'].languageOptions,
+      ...jest.configs['flat/style'].languageOptions,
+      globals: {
+        ...jest.configs['flat/recommended'].languageOptions?.globals,
+        ...globals.node,
+      },
+    },
+    rules: {
+      ...jest.configs['flat/recommended'].rules,
+      ...jest.configs['flat/style'].rules,
+      ...testingLibrary.configs['flat/react'].rules,
+    },
   },
   {
     files: ['integration-tests/**/*.{ts,tsx,js}'],
+    ...tseslint.configs.disableTypeChecked,
     ...cypress.configs.recommended,
     languageOptions: {
       globals: {
@@ -48,6 +82,7 @@ export default tseslint.config(
       },
     },
     rules: {
+      ...tseslint.configs.disableTypeChecked.rules,
       ...cypress.configs.recommended.rules,
       'no-console': 'off',
       '@typescript-eslint/no-namespace': 'off',

+ 1 - 2
integration-tests/plugins/index.ts

@@ -10,8 +10,7 @@ const config: Cypress.PluginConfig = (on, config) => {
         rules: [
           {
             test: /\.tsx?$/,
-            loader: 'ts-loader',
-            options: { happyPackMode: true, transpileOnly: true },
+            loader: 'swc-loader',
           },
         ],
       },

+ 1 - 0
integration-tests/support/login.ts

@@ -22,6 +22,7 @@ Cypress.Commands.add(
     username: string = KUBEADMIN_USERNAME,
     password: string = Cypress.env('BRIDGE_KUBEADMIN_PASSWORD'),
   ) => {
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const baseURL = Cypress.config('baseUrl')!;
 
     // Make sure we clear the cookie in case a previous test failed to logout.

+ 1 - 0
integration-tests/tests/example-page.cy.ts

@@ -3,6 +3,7 @@ import { checkErrors } from '../support';
 const PLUGIN_TEMPLATE_NAME = 'console-plugin-template';
 const PLUGIN_TEMPLATE_PULL_SPEC = Cypress.env('PLUGIN_TEMPLATE_PULL_SPEC');
 
+// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
 export const isLocalDevEnvironment = Cypress.config('baseUrl')!.includes('localhost');
 
 const installHelmChart = (path: string) => {

+ 27 - 0
jest.config.ts

@@ -0,0 +1,27 @@
+import type { Config } from 'jest';
+
+const config: Config = {
+  testEnvironment: 'jsdom',
+  testRegex: '.*\\.spec\\.(ts|tsx|js|jsx)$',
+  moduleNameMapper: {
+    '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
+    '<rootDir>/__mocks__/fileMock.ts',
+    '\\.css$': '<rootDir>/__mocks__/styleMock.ts',
+  },
+  transform: {
+    '^.+\\.[jt]sx?$': [
+      '@swc/jest',
+      {
+        module: {
+          type: 'commonjs',
+          noInterop: true,
+        },
+        minify: false,
+      },
+    ],
+  },
+  setupFilesAfterEnv: ['./setup-tests.ts'],
+  testPathIgnorePatterns: ['integration-tests'],
+};
+
+export default config;

+ 19 - 2
package.json

@@ -12,10 +12,12 @@
     "clean": "rm -rf dist",
     "build": "yarn clean && NODE_ENV=production yarn webpack",
     "build-dev": "yarn clean && yarn webpack",
+    "coverage": "jest --coverage",
     "start": "yarn webpack serve --progress",
     "start-console": "./start-console.sh",
     "i18n": "./i18n-scripts/build-i18n.sh && node ./i18n-scripts/set-english-defaults.js",
-    "lint": "yarn eslint src integration-tests --fix && stylelint 'src/**/*.css' --allow-empty-input --fix",
+    "test": "LANG=en_US.UTF-8 jest",
+    "lint": "yarn eslint src integration-tests --fix --max-warnings 0 && stylelint 'src/**/*.css' --allow-empty-input --fix",
     "test-cypress": "cd integration-tests && cypress open",
     "test-cypress-headless": "cd integration-tests && node --max-old-space-size=4096 ../node_modules/.bin/cypress run --browser ${BRIDGE_E2E_BROWSER_NAME:-electron}",
     "cypress-merge": "mochawesome-merge ./integration-tests/screenshots/cypress_report*.json > ./integration-tests/screenshots/cypress.json",
@@ -32,6 +34,13 @@
     "@patternfly/react-core": "^6.4.3",
     "@patternfly/react-icons": "^6.4.0",
     "@patternfly/react-table": "^6.4.3",
+    "@swc/core": "^1.15.32",
+    "@swc/jest": "^0.2.39",
+    "@testing-library/dom": "^10.4.1",
+    "@testing-library/jest-dom": "^6.9.1",
+    "@testing-library/react": "^16.3.2",
+    "@testing-library/user-event": "^14.6.1",
+    "@types/jest": "^30.0.0",
     "@types/node": "^22.0.0",
     "@types/react": "^18.3.28",
     "babel-loader": "^10.1.1",
@@ -41,13 +50,21 @@
     "cypress-multi-reporters": "^2.0.5",
     "eslint": "^9.7.0",
     "eslint-config-prettier": "^10.1.8",
+    "eslint-import-resolver-typescript": "^4.4.4",
     "eslint-plugin-cypress": "^6.4.0",
+    "eslint-plugin-import-x": "^4.16.2",
+    "eslint-plugin-jest": "^29.15.2",
     "eslint-plugin-prettier": "^5.5.5",
     "eslint-plugin-react": "^7.37.5",
     "eslint-plugin-react-hooks": "^7.1.1",
+    "eslint-plugin-testing-library": "^7.16.2",
+    "fork-ts-checker-webpack-plugin": "^9.1.0",
     "globals": "^17.6.0",
     "i18next": "^25.8.18",
     "i18next-parser": "^9.4.0",
+    "jest": "^30.3.0",
+    "jest-environment-jsdom": "^30.3.0",
+    "jest-junit": "^17.0.0",
     "mocha": "^11.7.5",
     "mocha-junit-reporter": "^2.2.1",
     "mochawesome": "^7.1.4",
@@ -62,7 +79,7 @@
     "style-loader": "^4.0.0",
     "stylelint": "^17.9.1",
     "stylelint-config-standard": "^40.0.0",
-    "ts-loader": "^9.5.7",
+    "swc-loader": "^0.2.7",
     "ts-node": "^10.9.2",
     "typescript": "^5.9.3",
     "typescript-eslint": "^8.59.1",

+ 4 - 0
setup-tests.ts

@@ -0,0 +1,4 @@
+import '@testing-library/jest-dom';
+import { configure } from '@testing-library/react';
+
+configure({ testIdAttribute: 'data-test' });

+ 14 - 0
src/components/ExamplePage.spec.tsx

@@ -0,0 +1,14 @@
+import { render, screen } from '@testing-library/react';
+import ExamplePage from './ExamplePage';
+
+describe('ExamplePage', () => {
+  it('renders the page heading', () => {
+    render(<ExamplePage />);
+    expect(screen.getByRole('heading', { name: 'Hello, plugin!' })).toBeInTheDocument();
+  });
+
+  it('renders the success message', () => {
+    render(<ExamplePage />);
+    expect(screen.getByText('Your plugin is working.')).toBeInTheDocument();
+  });
+});

+ 6 - 2
src/components/ExamplePage.tsx

@@ -2,9 +2,11 @@ import { DocumentTitle, ListPageHeader } from '@openshift-console/dynamic-plugin
 import { Trans, useTranslation } from 'react-i18next';
 import { Content, PageSection } from '@patternfly/react-core';
 import { CheckCircleIcon } from '@patternfly/react-icons';
+import type { FC } from 'react';
+
 import './example.css';
 
-export default function ExamplePage() {
+const ExamplePage: FC = () => {
   const { t } = useTranslation('plugin__console-plugin-template');
 
   return (
@@ -36,4 +38,6 @@ export default function ExamplePage() {
       </PageSection>
     </>
   );
-}
+};
+
+export default ExamplePage;

+ 19 - 0
src/plugin-metadata.spec.ts

@@ -0,0 +1,19 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import type { ConsolePluginBuildMetadata } from '@openshift-console/dynamic-plugin-sdk-webpack';
+
+const ROOT = path.resolve(__dirname, '..');
+const packageJson = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8')) as {
+  name: string;
+  consolePlugin: ConsolePluginBuildMetadata;
+};
+const localeFile = path.join(ROOT, `locales/en/plugin__${packageJson.consolePlugin.name}.json`);
+
+describe('plugin metadata', () => {
+  it('has a matching i18n locale file, package name, and consolePlugin name', () => {
+    if (!fs.existsSync(localeFile)) {
+      return;
+    }
+    expect(packageJson.name).toBe(packageJson.consolePlugin.name);
+  });
+});

+ 1 - 0
src/types/css.d.ts

@@ -0,0 +1 @@
+declare module '*.css';

+ 6 - 0
test-frontend.sh

@@ -20,3 +20,9 @@ if ! yarn dedupe --strategy highest --check ; then
   git --no-pager diff
   exit 1
 fi
+
+if [ "$OPENSHIFT_CI" = true ]; then
+  JEST_SUITE_NAME="Plugin unit tests" JEST_JUNIT_OUTPUT_DIR="$ARTIFACT_DIR" yarn run test --ci --maxWorkers=2 --reporters=default --reporters=jest-junit
+else
+  yarn run test
+fi

+ 3 - 5
tsconfig.json

@@ -1,17 +1,15 @@
 {
   "compilerOptions": {
-    "baseUrl": ".",
     "module": "esnext",
     "moduleResolution": "node",
-    "target": "es2021",
-    "sourceMap": true,
     "jsx": "react-jsx",
     "allowJs": true,
     "strict": true,
-    "noUnusedLocals": true
+    "noUnusedLocals": true,
+    "types": ["jest", "@testing-library/jest-dom", "node"]
   },
   "include": ["src"],
-  "exclude": ["node_modules"],
+  "exclude": ["node_modules", ".yarn"],
   "ts-node": {
     "files": true,
     "transpileOnly": true,

+ 7 - 8
webpack.config.ts

@@ -6,6 +6,7 @@ import { Configuration as WebpackDevServerConfiguration } from 'webpack-dev-serv
 import { ConsoleRemotePlugin } from '@openshift-console/dynamic-plugin-sdk-webpack';
 
 const CopyWebpackPlugin = require('copy-webpack-plugin');
+const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
 
 const isProd = process.env.NODE_ENV === 'production';
 
@@ -31,14 +32,7 @@ const config: Configuration = {
       {
         test: /\.(jsx?|tsx?)$/,
         exclude: /\/node_modules\//,
-        use: [
-          {
-            loader: 'ts-loader',
-            options: {
-              configFile: path.resolve(__dirname, 'tsconfig.json'),
-            },
-          },
-        ],
+        use: ['swc-loader'],
       },
       {
         test: /\.(css)$/,
@@ -75,6 +69,11 @@ const config: Configuration = {
   },
   plugins: [
     new ConsoleRemotePlugin(),
+    new ForkTsCheckerWebpackPlugin({
+      typescript: {
+        configFile: path.resolve(__dirname, 'tsconfig.json'),
+      },
+    }),
     new CopyWebpackPlugin({
       patterns: [{ from: path.resolve(__dirname, 'locales'), to: 'locales' }],
     }),

File diff suppressed because it is too large
+ 844 - 122
yarn.lock


Some files were not shown because too many files changed in this diff