Преглед изворни кода

Merge pull request #113 from openshift/main

OCPBUGS-77804: update release-4.22 with 4.22 SDK changes
openshift-merge-bot[bot] пре 3 месеци
родитељ
комит
1fdacd51f6

+ 6 - 2
.gitignore

@@ -2,8 +2,12 @@
 **/dist
 **/dist
 **/.DS_Store
 **/.DS_Store
 .devcontainer/dev.env
 .devcontainer/dev.env
-integration-tests/videos
-integration-tests/screenshots
+integration-tests/.auth
+integration-tests/results
+
+# Jest
+coverage/
+jest-junit.xml
 
 
 # Yarn v4 (Berry)
 # Yarn v4 (Berry)
 .yarn/*
 .yarn/*

+ 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
+}

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
.yarn/releases/yarn-4.14.1.cjs


+ 19 - 1
.yarnrc.yml

@@ -1,3 +1,21 @@
+enableInlineBuilds: true
+
+enableScripts: false
+
+enableTelemetry: false
+
+httpTimeout: 600000
+
 nodeLinker: node-modules
 nodeLinker: node-modules
 
 
-yarnPath: .yarn/releases/yarn-4.13.0.cjs
+supportedArchitectures:
+  cpu:
+    - x64
+    - arm64
+    - s390x
+    - ppc64
+  os:
+    - linux
+    - darwin
+
+yarnPath: .yarn/releases/yarn-4.14.1.cjs

+ 12 - 15
AGENTS.md

@@ -12,11 +12,11 @@ This is a **template repository** for creating OpenShift Console dynamic plugins
 > **Only make changes that should be standard practice for ALL plugins created from this template.** If a change is specific to one plugin use case, it belongs in the instantiated plugin repository, not in this template.
 > **Only make changes that should be standard practice for ALL plugins created from this template.** If a change is specific to one plugin use case, it belongs in the instantiated plugin repository, not in this template.
 
 
 **Key Technologies:**
 **Key Technologies:**
-- TypeScript + React 17
+- TypeScript + React 18
 - PatternFly 6 (UI component library)
 - PatternFly 6 (UI component library)
 - Webpack 5 with Module Federation
 - Webpack 5 with Module Federation
 - react-i18next for internationalization
 - react-i18next for internationalization
-- Cypress for e2e testing
+- Playwright for e2e testing
 - Helm for deployment
 - Helm for deployment
 
 
 **Compatibility:** Requires OpenShift 4.12+ (uses ConsolePlugin CRD v1 API)
 **Compatibility:** Requires OpenShift 4.12+ (uses ConsolePlugin CRD v1 API)
@@ -81,7 +81,7 @@ tsconfig.json          # TypeScript config (strict: false currently)
 webpack.config.ts      # Module federation + build config
 webpack.config.ts      # Module federation + build config
 locales/               # i18n translation files
 locales/               # i18n translation files
 charts/                # Helm chart for deployment
 charts/                # Helm chart for deployment
-integration-tests/     # Cypress e2e tests
+integration-tests/     # Playwright e2e tests
 ```
 ```
 
 
 ## Development Workflow
 ## Development Workflow
@@ -98,18 +98,16 @@ integration-tests/     # Cypress e2e tests
 - Follow existing code patterns in the repo
 - Follow existing code patterns in the repo
 
 
 ### Testing
 ### Testing
-- `yarn test-cypress` - opens Cypress UI
-- `yarn test-cypress-headless` - runs Cypress in CI mode
+- `yarn test` - runs Jest unit tests
+- `yarn test-e2e` - opens Playwright in headed mode
+- `yarn test-e2e-headless` - runs Playwright in headless mode
 - Add e2e tests for new pages/features
 - Add e2e tests for new pages/features
 
 
 ## TypeScript Configuration
 ## TypeScript Configuration
 
 
-Current config has `strict: false` but enforces:
+Current config has `strict: true` and enforces:
 - `noUnusedLocals: true`
 - `noUnusedLocals: true`
 - All files should use `.tsx` extension
 - All files should use `.tsx` extension
-- Target: ES2020
-
-**Modernization opportunity:** When touching files, consider enabling stricter TypeScript checks.
 
 
 ## Common Development Tasks
 ## Common Development Tasks
 
 
@@ -177,8 +175,7 @@ helm upgrade -i my-plugin charts/openshift-console-plugin \
 4. **Module federation requires exact module mapping** - `exposedModules` must match `$codeRef` values
 4. **Module federation requires exact module mapping** - `exposedModules` must match `$codeRef` values
 5. **PatternFly CSS variables only** - hex colors break dark mode
 5. **PatternFly CSS variables only** - hex colors break dark mode
 6. **No webpack HMR for extensions** - changes to `console-extensions.json` require restart
 6. **No webpack HMR for extensions** - changes to `console-extensions.json` require restart
-7. **TypeScript not in strict mode** - legacy choice, can be modernized
-8. **React 17, not 18** - matches console's React version
+7. **React 18** - matches console's React version
 
 
 ## Extension Points
 ## Extension Points
 
 
@@ -204,9 +201,9 @@ See [Console Plugin SDK README](https://github.com/openshift/console/tree/master
 
 
 ## Testing Strategy
 ## Testing Strategy
 
 
-- **E2E tests (Cypress):** For user flows and page rendering
-- **Component tests:** Add when components have complex logic
-- **Test data attributes:** Use `data-test` attributes for selectors
+- **E2E tests (Playwright):** For user flows and page rendering
+- **Unit tests (Jest):** For component logic and plugin metadata
+- **Test data attributes:** Use `data-test` attributes for selectors (`testIdAttribute` is configured in `playwright.config.ts`)
 - Run tests locally before opening PRs
 - Run tests locally before opening PRs
 
 
 ## References
 ## References
@@ -223,5 +220,5 @@ See [Console Plugin SDK README](https://github.com/openshift/console/tree/master
 - **Add a page?** Update console-extensions.json + exposedModules + create component
 - **Add a page?** Update console-extensions.json + exposedModules + create component
 - **Style something?** Use PatternFly components and CSS variables, prefix custom classes
 - **Style something?** Use PatternFly components and CSS variables, prefix custom classes
 - **Add translations?** Use `t()` function, run `yarn i18n` after
 - **Add translations?** Use `t()` function, run `yarn i18n` after
-- **Test changes?** Run locally with `yarn start` + `yarn start-console`, add Cypress tests
+- **Test changes?** Run locally with `yarn start` + `yarn start-console`, add Playwright tests
 - **Deploy?** Build image, push to registry, install via Helm chart
 - **Deploy?** Build image, push to registry, install via Helm chart

+ 5 - 3
Dockerfile

@@ -1,11 +1,13 @@
 FROM registry.access.redhat.com/ubi9/nodejs-22:latest AS build
 FROM registry.access.redhat.com/ubi9/nodejs-22:latest AS build
 USER root
 USER root
-ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
-RUN npm i -g corepack && corepack enable
+
+ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
 
 
 ADD . /usr/src/app
 ADD . /usr/src/app
 WORKDIR /usr/src/app
 WORKDIR /usr/src/app
-RUN yarn install --immutable && yarn build
+
+RUN LOCAL_YARN="node $(awk '/yarnPath:/{print $2}' .yarnrc.yml)" && \
+    $LOCAL_YARN install --immutable && $LOCAL_YARN build
 
 
 FROM registry.access.redhat.com/ubi9/nginx-120:latest
 FROM registry.access.redhat.com/ubi9/nginx-120:latest
 
 

+ 0 - 8
README.md

@@ -215,14 +215,6 @@ best practice is to prefix your CSS class names with your plugin name to avoid
 conflicts. Please don't disable these rules without understanding how they can
 conflicts. Please don't disable these rules without understanding how they can
 break console styles!
 break console styles!
 
 
-## Reporting
-
-Steps to generate reports
-
-1. In command prompt, navigate to root folder and execute the command `yarn run cypress-merge`
-2. Then execute command `yarn run cypress-generate`
-The cypress-report.html file is generated and should be in (/integration-tests/screenshots) directory.
-
 ## References
 ## References
 
 
 - [Console Plugin SDK README](https://github.com/openshift/console/tree/main/frontend/packages/console-dynamic-plugin-sdk)
 - [Console Plugin SDK README](https://github.com/openshift/console/tree/main/frontend/packages/console-dynamic-plugin-sdk)

+ 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 - 15
eslint.config.mjs

@@ -3,7 +3,11 @@ import tseslint from 'typescript-eslint';
 import react from 'eslint-plugin-react';
 import react from 'eslint-plugin-react';
 import prettier from 'eslint-plugin-prettier/recommended';
 import prettier from 'eslint-plugin-prettier/recommended';
 import reactHooks from 'eslint-plugin-react-hooks';
 import reactHooks from 'eslint-plugin-react-hooks';
-import cypress from 'eslint-plugin-cypress';
+import importX from 'eslint-plugin-import-x';
+import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript';
+import playwright from 'eslint-plugin-playwright';
+import jest from 'eslint-plugin-jest';
+import testingLibrary from 'eslint-plugin-testing-library';
 import globals from 'globals';
 import globals from 'globals';
 
 
 export default tseslint.config(
 export default tseslint.config(
@@ -11,48 +15,70 @@ export default tseslint.config(
     ignores: ['dist/', 'node_modules/'],
     ignores: ['dist/', 'node_modules/'],
   },
   },
   eslint.configs.recommended,
   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}'],
     files: ['src/**/*.{ts,tsx}'],
     plugins: {
     plugins: {
       react,
       react,
     },
     },
     rules: {
     rules: {
-      ...eslint.configs.recommended.rules,
-      ...tseslint.configs.recommended.rules,
       ...react.configs.recommended.rules,
       ...react.configs.recommended.rules,
       ...react.configs['jsx-runtime'].rules,
       ...react.configs['jsx-runtime'].rules,
+      '@typescript-eslint/consistent-type-imports': 'error',
     },
     },
     languageOptions: {
     languageOptions: {
       globals: globals.browser,
       globals: globals.browser,
       parserOptions: {
       parserOptions: {
+        projectService: true,
         ecmaFeatures: {
         ecmaFeatures: {
           jsx: true,
           jsx: true,
         },
         },
-      }
+      },
     },
     },
     settings: {
     settings: {
       react: {
       react: {
         version: 'detect',
         version: 'detect',
       },
       },
-    }
+    },
   },
   },
   {
   {
-    files: ['integration-tests/**/*.{ts,tsx,js}'],
-    ...cypress.configs.recommended,
+    files: ['src/**/*.spec.{ts,tsx}'],
+    plugins: {
+      ...jest.configs['flat/recommended'].plugins,
+      ...jest.configs['flat/style'].plugins,
+      ...testingLibrary.configs['flat/react'].plugins,
+    },
     languageOptions: {
     languageOptions: {
+      ...jest.configs['flat/recommended'].languageOptions,
+      ...jest.configs['flat/style'].languageOptions,
       globals: {
       globals: {
-        require: 'readonly',
-        module: 'writable',
+        ...jest.configs['flat/recommended'].languageOptions?.globals,
+        ...globals.node,
       },
       },
     },
     },
     rules: {
     rules: {
-      ...cypress.configs.recommended.rules,
+      ...jest.configs['flat/recommended'].rules,
+      ...jest.configs['flat/style'].rules,
+      ...testingLibrary.configs['flat/react'].rules,
+    },
+  },
+  {
+    ...playwright.configs['flat/recommended'],
+    files: ['integration-tests/**/*.ts'],
+    ...tseslint.configs.disableTypeChecked,
+    rules: {
+      ...playwright.configs['flat/recommended'].rules,
+      ...tseslint.configs.disableTypeChecked.rules,
       'no-console': 'off',
       'no-console': 'off',
-      '@typescript-eslint/no-namespace': 'off',
-      '@typescript-eslint/no-require-imports': 'off',
-      '@typescript-eslint/no-unused-expressions': 'off',
     },
     },
   },
   },
   prettier,
   prettier,

+ 0 - 28
integration-tests/cypress.config.js

@@ -1,28 +0,0 @@
-const { defineConfig } = require('cypress');
-
-module.exports = defineConfig({
-  viewportWidth: 1920,
-  viewportHeight: 1080,
-  screenshotsFolder: './screenshots/screenshots',
-  videosFolder: './screenshots/videos',
-  video: true,
-  reporter: '../../node_modules/cypress-multi-reporters',
-  reporterOptions: {
-    configFile: 'reporter-config.json',
-  },
-  fixturesFolder: 'fixtures',
-  defaultCommandTimeout: 30000,
-  retries: {
-    runMode: 1,
-    openMode: 0,
-  },
-  e2e: {
-    setupNodeEvents(on, config) {
-      return require('./plugins/index.ts')(on, config);
-    },
-    specPattern: 'tests/**/*.cy.{js,jsx,ts,tsx}',
-    supportFile: 'support/index.ts',
-    testIsolation: false,
-    injectDocumentDomain: true,
-  },
-});

+ 0 - 5
integration-tests/fixtures/example.json

@@ -1,5 +0,0 @@
-{
-  "name": "Using fixtures to represent data",
-  "email": "hello@cypress.io",
-  "body": "Fixtures are a great way to mock data for responses to routes"
-}

+ 62 - 0
integration-tests/pages/login.ts

@@ -0,0 +1,62 @@
+import type { Locator, Page } from '@playwright/test';
+
+declare global {
+  interface Window {
+    SERVER_FLAGS?: {
+      authDisabled?: boolean;
+    };
+  }
+}
+
+export const KUBEADMIN_USERNAME = 'kubeadmin';
+
+export class LoginPage {
+  constructor(private readonly page: Page) {}
+
+  private async isAuthDisabled() {
+    return this.page.evaluate(() => window.SERVER_FLAGS?.authDisabled);
+  }
+
+  // Fill a field via CDP to avoid exposing the value in Playwright traces
+  // https://github.com/microsoft/playwright/issues/19992#issuecomment-4078945450
+  private async fillSensitive(locator: Locator, text: string) {
+    await locator.focus();
+    const cdpSession = await this.page.context().newCDPSession(this.page);
+    await cdpSession.send('Input.dispatchKeyEvent', {
+      type: 'keyDown',
+      key: 'a',
+      commands: ['selectAll'],
+    });
+    await cdpSession.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'a' });
+    await cdpSession.send('Input.insertText', { text });
+    await cdpSession.detach();
+  }
+
+  async login(
+    username: string = KUBEADMIN_USERNAME,
+    password: string = process.env.BRIDGE_KUBEADMIN_PASSWORD ?? '',
+  ) {
+    await this.page.context().clearCookies();
+    await this.page.goto('/');
+
+    if (await this.isAuthDisabled()) {
+      return;
+    }
+
+    await this.page.locator('[data-test-id="login"]').waitFor({ state: 'visible' });
+    await this.page.locator('#inputUsername').fill(username);
+    await this.fillSensitive(this.page.locator('#inputPassword'), password);
+    await this.page.locator('button[type=submit]').click();
+    await this.page.getByTestId('username').waitFor({ state: 'attached' });
+  }
+
+  async logout() {
+    if (await this.isAuthDisabled()) {
+      return;
+    }
+    await this.page.getByTestId('username').click();
+    await this.page.getByTestId('log-out').waitFor({ state: 'visible' });
+    // eslint-disable-next-line playwright/no-force-option -- dropdown may be covered by overlay
+    await this.page.getByTestId('log-out').click({ force: true });
+  }
+}

+ 0 - 27
integration-tests/plugins/index.ts

@@ -1,27 +0,0 @@
-const wp = require('@cypress/webpack-preprocessor');
-
-const config: Cypress.PluginConfig = (on, config) => {
-  const options = {
-    webpackOptions: {
-      resolve: {
-        extensions: ['.ts', '.tsx', '.js'],
-      },
-      module: {
-        rules: [
-          {
-            test: /\.tsx?$/,
-            loader: 'ts-loader',
-            options: { happyPackMode: true, transpileOnly: true },
-          },
-        ],
-      },
-    },
-  };
-  on('file:preprocessor', wp(options));
-  // `config` is the resolved Cypress config
-  config.baseUrl = `${process.env.BRIDGE_BASE_ADDRESS || 'http://localhost:9000/'}`;
-  config.env.BRIDGE_KUBEADMIN_PASSWORD = process.env.BRIDGE_KUBEADMIN_PASSWORD;
-  return config;
-};
-
-module.exports = config;

+ 0 - 14
integration-tests/reporter-config.json

@@ -1,14 +0,0 @@
-{
-    "reporterEnabled": "mocha-junit-reporter, mochawesome",
-    "mochaJunitReporterReporterOptions": {
-      "mochaFile": "./screenshots/junit_cypress-[hash].xml",
-      "toConsole": false
-    },
-    "mochawesomeReporterOptions": {
-      "reportDir": "./screenshots/",
-      "reportFilename": "cypress_report",
-      "overwrite": false,
-      "html": false,
-      "json": true
-    }
-  }

+ 8 - 5
integration-tests/support/index.ts

@@ -1,6 +1,9 @@
-import './login';
+import type { Page } from '@playwright/test';
+import { expect } from '@playwright/test';
 
 
-export const checkErrors = () =>
-  cy.window().then((win) => {
-    assert.isTrue(!win.windowError, win.windowError);
-  });
+export async function checkErrors(page: Page) {
+  const windowError = await page.evaluate(
+    () => (window as Window & { windowError?: string }).windowError,
+  );
+  expect(windowError, 'Console JS error detected').toBeUndefined();
+}

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

@@ -1,70 +0,0 @@
-declare global {
-  namespace Cypress {
-    interface Chainable {
-      login(username?: string, password?: string): Chainable<Element>;
-      logout(): Chainable<Element>;
-    }
-  }
-  interface Window {
-    SERVER_FLAGS?: {
-      authDisabled?: boolean;
-    };
-  }
-}
-
-export const KUBEADMIN_USERNAME = 'kubeadmin';
-
-// This will add 'cy.login(...)'
-// ex: cy.login('my-user', 'my-password')
-Cypress.Commands.add(
-  'login',
-  (
-    username: string = KUBEADMIN_USERNAME,
-    password: string = Cypress.env('BRIDGE_KUBEADMIN_PASSWORD'),
-  ) => {
-    const baseURL = Cypress.config('baseUrl')!;
-
-    // Make sure we clear the cookie in case a previous test failed to logout.
-    cy.clearCookie('openshift-session-token');
-
-    cy.visit(baseURL);
-
-    cy.session(
-      username,
-      () => {
-        // Check if auth is disabled (for a local development environment).
-        cy.window().then((win) => {
-          if (win.SERVER_FLAGS?.authDisabled) {
-            return;
-          }
-        });
-
-        cy.visit(baseURL);
-
-        cy.get('[data-test-id="login"]').should('be.visible');
-        cy.get('#inputUsername').type(username);
-        cy.get('#inputPassword').type(password);
-        cy.get('button[type=submit]').click();
-      },
-      {
-        cacheAcrossSpecs: true,
-        validate() {
-          cy.visit(baseURL);
-          cy.get('[data-test="username"]').should('exist');
-        },
-      },
-    );
-  },
-);
-
-Cypress.Commands.add('logout', () => {
-  // Check if auth is disabled (for a local development environment).
-  cy.window().then((win) => {
-    if (win.SERVER_FLAGS?.authDisabled) {
-      return;
-    }
-    cy.get('[data-test="username"]').click();
-    cy.get('[data-test="log-out"]').should('be.visible');
-    cy.get('[data-test="log-out"]').click({ force: true });
-  });
-});

+ 12 - 0
integration-tests/tests/auth.setup.ts

@@ -0,0 +1,12 @@
+import { test as setup } from '@playwright/test';
+import { LoginPage } from '../pages/login';
+
+// eslint-disable-next-line playwright/expect-expect -- setup test saves storageState, no assertions needed
+setup('authenticate', async ({ page }) => {
+  const loginPage = new LoginPage(page);
+  await loginPage.login();
+
+  await page.getByTestId('tour-step-footer-secondary').filter({ hasText: 'Skip tour' }).click();
+
+  await page.context().storageState({ path: 'integration-tests/.auth/user.json' });
+});

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

@@ -1,88 +0,0 @@
-import { checkErrors } from '../support';
-
-const PLUGIN_TEMPLATE_NAME = 'console-plugin-template';
-const PLUGIN_TEMPLATE_PULL_SPEC = Cypress.env('PLUGIN_TEMPLATE_PULL_SPEC');
-
-export const isLocalDevEnvironment = Cypress.config('baseUrl')!.includes('localhost');
-
-const installHelmChart = (path: string) => {
-  // Install the plugin in a dedicated namespace using the helm chart from this repo
-  cy.exec(
-    `cd ../../console-plugin-template && ${path} upgrade -i ${PLUGIN_TEMPLATE_NAME} charts/openshift-console-plugin -n ${PLUGIN_TEMPLATE_NAME} --create-namespace --set plugin.image=${PLUGIN_TEMPLATE_PULL_SPEC}`,
-    {
-      failOnNonZeroExit: false,
-    },
-  ).then((result) => {
-    result.stderr && cy.log('Error installing helm chart: ', result.stderr);
-    result.stdout && cy.log('Successfully installed helm chart: ', result.stdout);
-  });
-
-  // Wait for the plugin deployment to be ready
-  cy.exec(
-    `oc rollout status -n ${PLUGIN_TEMPLATE_NAME} deploy/${PLUGIN_TEMPLATE_NAME} -w --timeout=300s`,
-    { timeout: 360000, failOnNonZeroExit: false },
-  );
-
-  // Wait for console pods to restart with the new plugin
-  cy.exec('oc rollout status -w deploy/console -n openshift-console --timeout=300s', {
-    timeout: 360000,
-    failOnNonZeroExit: false,
-  });
-
-  cy.visit('/k8s/cluster/operator.openshift.io~v1~Console/cluster/console-plugins');
-  cy.get(`[data-test="${PLUGIN_TEMPLATE_NAME}-status"]`).should('include.text', 'Loaded');
-};
-const deleteHelmChart = (path: string) => {
-  cy.exec(
-    `cd ../../console-plugin-template && ${path} uninstall ${PLUGIN_TEMPLATE_NAME} -n ${PLUGIN_TEMPLATE_NAME} && oc delete namespaces ${PLUGIN_TEMPLATE_NAME}`,
-    {
-      failOnNonZeroExit: false,
-    },
-  ).then((result) => {
-    cy.log('Error uninstalling helm chart: ', result.stderr);
-    cy.log('Successfully uninstalled helm chart: ', result.stdout);
-  });
-};
-
-describe('Console plugin template test', () => {
-  before(() => {
-    cy.login();
-    cy.get(`[data-test="tour-step-footer-secondary"]`).contains('Skip tour').click();
-    if (!isLocalDevEnvironment) {
-      console.log('this is not a local env, installing helm');
-
-      cy.exec('cd ../../console-plugin-template && ./install_helm.sh', {
-        failOnNonZeroExit: false,
-      }).then((result) => {
-        cy.log('Error installing helm binary: ', result.stderr);
-        cy.log('Successfully installed helm binary in "/tmp" directory: ', result.stdout);
-
-        installHelmChart('/tmp/helm');
-      });
-    } else {
-      console.log('this is a local env, not installing helm');
-
-      installHelmChart('helm');
-    }
-  });
-
-  afterEach(() => {
-    checkErrors();
-  });
-
-  after(() => {
-    if (!isLocalDevEnvironment) {
-      deleteHelmChart('/tmp/helm');
-    } else {
-      deleteHelmChart('helm');
-    }
-    cy.logout();
-  });
-
-  it('Verify the example page title', () => {
-    cy.get('[data-quickstart-id="qs-nav-home"]').click();
-    cy.get('[data-test="nav"]').contains('Plugin example').click();
-    cy.url().should('include', '/example');
-    cy.get('title').should('contain', 'Hello, plugin!');
-  });
-});

+ 73 - 0
integration-tests/tests/example-page.spec.ts

@@ -0,0 +1,73 @@
+import { execSync } from 'child_process';
+import { test, expect } from '@playwright/test';
+import { checkErrors } from '../support';
+
+const PLUGIN_TEMPLATE_NAME = 'console-plugin-template';
+// Defined in openshift/release ci-operator config as CYPRESS_PLUGIN_TEMPLATE_PULL_SPEC
+const PLUGIN_TEMPLATE_PULL_SPEC =
+  process.env.PLUGIN_TEMPLATE_PULL_SPEC ?? process.env.CYPRESS_PLUGIN_TEMPLATE_PULL_SPEC;
+
+const isLocalDevEnvironment = (process.env.BRIDGE_BASE_ADDRESS ?? 'http://localhost:9000').includes(
+  'localhost',
+);
+
+function exec(command: string, timeoutMs = 360000) {
+  try {
+    return execSync(command, { timeout: timeoutMs, encoding: 'utf-8' });
+  } catch (e) {
+    console.error('Command failed:', command, e);
+    return '';
+  }
+}
+
+function installHelmChart(helmPath: string) {
+  const result = exec(
+    `${helmPath} upgrade -i ${PLUGIN_TEMPLATE_NAME} charts/openshift-console-plugin -n ${PLUGIN_TEMPLATE_NAME} --create-namespace --set plugin.image=${PLUGIN_TEMPLATE_PULL_SPEC}`,
+  );
+  console.log('Helm install:', result);
+
+  exec(
+    `oc rollout status -n ${PLUGIN_TEMPLATE_NAME} deploy/${PLUGIN_TEMPLATE_NAME} -w --timeout=300s`,
+  );
+  exec('oc rollout status -w deploy/console -n openshift-console --timeout=300s');
+}
+
+function deleteHelmChart(helmPath: string) {
+  const result = exec(
+    `${helmPath} uninstall ${PLUGIN_TEMPLATE_NAME} -n ${PLUGIN_TEMPLATE_NAME} && oc delete namespaces ${PLUGIN_TEMPLATE_NAME}`,
+  );
+  console.log('Helm uninstall:', result);
+}
+
+test.describe('Console plugin template test', () => {
+  test.beforeAll(() => {
+    if (!isLocalDevEnvironment) {
+      console.log('this is not a local env, installing helm');
+      exec('./install_helm.sh');
+      installHelmChart('/tmp/helm');
+    } else {
+      console.log('this is a local env, not installing helm');
+      installHelmChart('helm');
+    }
+  });
+
+  test.afterEach(async ({ page }) => {
+    await checkErrors(page);
+  });
+
+  test.afterAll(() => {
+    if (!isLocalDevEnvironment) {
+      deleteHelmChart('/tmp/helm');
+    } else {
+      deleteHelmChart('helm');
+    }
+  });
+
+  test('Verify the example page title', async ({ page }) => {
+    await page.goto('/');
+    await page.locator('[data-quickstart-id="qs-nav-home"]').click();
+    await page.getByTestId('nav').getByText('Plugin example').click();
+    await expect(page).toHaveURL(/\/example/);
+    await expect(page).toHaveTitle(/Hello, plugin!/);
+  });
+});

+ 4 - 3
integration-tests/tsconfig.json

@@ -2,8 +2,9 @@
     "extends": "../tsconfig.json",
     "extends": "../tsconfig.json",
     "compilerOptions": {
     "compilerOptions": {
         "noEmit": true,
         "noEmit": true,
-        "types":["cypress","node"],
+        "types": ["node"],
         "isolatedModules": false
         "isolatedModules": false
     },
     },
-    "include": ["../node_modules/cypress", "./**/*.ts"]
-}
+    "include": ["./**/*.ts"],
+    "exclude": ["screenshots", ".auth"]
+}

+ 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;

+ 54 - 43
package.json

@@ -12,66 +12,77 @@
     "clean": "rm -rf dist",
     "clean": "rm -rf dist",
     "build": "yarn clean && NODE_ENV=production yarn webpack",
     "build": "yarn clean && NODE_ENV=production yarn webpack",
     "build-dev": "yarn clean && yarn webpack",
     "build-dev": "yarn clean && yarn webpack",
+    "coverage": "jest --coverage",
     "start": "yarn webpack serve --progress",
     "start": "yarn webpack serve --progress",
     "start-console": "./start-console.sh",
     "start-console": "./start-console.sh",
     "i18n": "./i18n-scripts/build-i18n.sh && node ./i18n-scripts/set-english-defaults.js",
     "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-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",
-    "cypress-generate": "marge -o ./integration-tests/screenshots/ -f cypress-report -t 'OpenShift Console Plugin Template Cypress Test Results' -p 'OpenShift Cypress Plugin Template Test Results' --showPassed false --assetsDir ./integration-tests/screenshots/cypress/assets ./integration-tests/screenshots/cypress.json",
-    "cypress-postreport": "yarn cypress-merge && yarn cypress-generate",
+    "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-e2e": "playwright test --headed",
+    "test-e2e-headless": "playwright test",
     "webpack": "node -r ts-node/register ./node_modules/.bin/webpack"
     "webpack": "node -r ts-node/register ./node_modules/.bin/webpack"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@babel/core": "^7.29.0",
-    "@babel/preset-env": "^7.29.0",
-    "@cypress/webpack-preprocessor": "^7.0.2",
-    "@openshift-console/dynamic-plugin-sdk": "4.21-latest",
-    "@openshift-console/dynamic-plugin-sdk-webpack": "4.21-latest",
-    "@patternfly/react-core": "^6.2.2",
-    "@patternfly/react-icons": "^6.2.2",
-    "@patternfly/react-table": "^6.2.2",
+    "@babel/core": "^7.29.7",
+    "@babel/preset-env": "^7.29.7",
+    "@openshift-console/dynamic-plugin-sdk": "4.22-latest",
+    "@openshift-console/dynamic-plugin-sdk-webpack": "4.22-latest",
+    "@patternfly/react-core": "^6.4.3",
+    "@patternfly/react-icons": "^6.4.0",
+    "@patternfly/react-table": "^6.4.3",
+    "@playwright/browser-chromium": "^1.60.0",
+    "@playwright/test": "^1.60.0",
+    "@swc/core": "^1.15.40",
+    "@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/node": "^22.0.0",
-    "@types/react": "^17.0.37",
-    "@types/react-router-dom": "^5.3.3",
-    "babel-loader": "^10.1.0",
+    "@types/react": "^18.3.28",
+    "babel-loader": "^10.1.1",
     "copy-webpack-plugin": "^14.0.0",
     "copy-webpack-plugin": "^14.0.0",
     "css-loader": "^7.1.4",
     "css-loader": "^7.1.4",
-    "cypress": "^15.11.0",
-    "cypress-multi-reporters": "^2.0.5",
     "eslint": "^9.7.0",
     "eslint": "^9.7.0",
     "eslint-config-prettier": "^10.1.8",
     "eslint-config-prettier": "^10.1.8",
-    "eslint-plugin-cypress": "^6.1.0",
-    "eslint-plugin-prettier": "^5.5.5",
+    "eslint-import-resolver-typescript": "^4.4.5",
+    "eslint-plugin-import-x": "^4.16.2",
+    "eslint-plugin-jest": "^29.15.2",
+    "eslint-plugin-playwright": "^2.10.4",
+    "eslint-plugin-prettier": "^5.5.6",
     "eslint-plugin-react": "^7.37.5",
     "eslint-plugin-react": "^7.37.5",
-    "eslint-plugin-react-hooks": "^7.0.1",
-    "globals": "^17.4.0",
-    "i18next": "^23.11.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",
     "i18next-parser": "^9.4.0",
-    "mocha": "^11.7.5",
-    "mocha-junit-reporter": "^2.2.1",
-    "mochawesome": "^7.1.4",
-    "mochawesome-merge": "^5.1.1",
+    "jest": "^30.4.2",
+    "jest-environment-jsdom": "^30.4.1",
+    "jest-junit": "^17.0.0",
     "pluralize": "^8.0.0",
     "pluralize": "^8.0.0",
-    "prettier": "^3.8.1",
+    "prettier": "^3.8.3",
     "prettier-stylelint": "^0.4.2",
     "prettier-stylelint": "^0.4.2",
-    "react": "^17.0.1",
-    "react-dom": "^17.0.1",
-    "react-i18next": "^11.7.3",
-    "react-router": "5.3.x",
-    "react-router-dom": "5.3.x",
-    "react-router-dom-v5-compat": "^6.11.2",
+    "react": "^18.3.1",
+    "react-dom": "^18.3.1",
+    "react-i18next": "~16.5.8",
+    "react-router": "~7.13.1",
     "style-loader": "^4.0.0",
     "style-loader": "^4.0.0",
-    "stylelint": "^17.4.0",
+    "stylelint": "^17.13.0",
     "stylelint-config-standard": "^40.0.0",
     "stylelint-config-standard": "^40.0.0",
-    "ts-loader": "^9.5.4",
+    "swc-loader": "^0.2.7",
     "ts-node": "^10.9.2",
     "ts-node": "^10.9.2",
     "typescript": "^5.9.3",
     "typescript": "^5.9.3",
-    "typescript-eslint": "^8.56.1",
-    "webpack": "^5.100.0",
-    "webpack-cli": "^6.0.1",
-    "webpack-dev-server": "^5.2.3"
+    "typescript-eslint": "^8.61.0",
+    "webpack": "^5.107.2",
+    "webpack-cli": "^7.0.3",
+    "webpack-dev-server": "^5.2.4"
+  },
+  "dependenciesMeta": {
+    "@playwright/browser-chromium": {
+      "built": true
+    }
   },
   },
   "consolePlugin": {
   "consolePlugin": {
     "name": "console-plugin-template",
     "name": "console-plugin-template",
@@ -82,8 +93,8 @@
       "ExamplePage": "./components/ExamplePage"
       "ExamplePage": "./components/ExamplePage"
     },
     },
     "dependencies": {
     "dependencies": {
-      "@console/pluginAPI": "^4.21.0"
+      "@console/pluginAPI": ">=4.22.0-0"
     }
     }
   },
   },
-  "packageManager": "yarn@4.13.0"
+  "packageManager": "yarn@4.14.1"
 }
 }

+ 32 - 0
playwright.config.ts

@@ -0,0 +1,32 @@
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+  testDir: './integration-tests/tests',
+  timeout: 30000,
+  retries: 1,
+  use: {
+    baseURL: process.env.BRIDGE_BASE_ADDRESS ?? 'http://localhost:9000',
+    viewport: { width: 1920, height: 1080 },
+    screenshot: 'on',
+    ignoreHTTPSErrors: true,
+    video: 'retain-on-failure',
+    trace: 'on',
+    testIdAttribute: 'data-test',
+  },
+  projects: [
+    { name: 'setup', testMatch: /auth\.setup\.ts/ },
+    {
+      name: 'chromium',
+      use: {
+        browserName: 'chromium',
+        storageState: 'integration-tests/.auth/user.json',
+      },
+      dependencies: ['setup'],
+    },
+  ],
+  reporter: [
+    ['list'],
+    ['html', { outputFolder: 'integration-tests/results/html', open: 'never' }],
+    ['junit', { outputFile: 'integration-tests/results/junit-results.xml' }],
+  ],
+});

+ 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 { Trans, useTranslation } from 'react-i18next';
 import { Content, PageSection } from '@patternfly/react-core';
 import { Content, PageSection } from '@patternfly/react-core';
 import { CheckCircleIcon } from '@patternfly/react-icons';
 import { CheckCircleIcon } from '@patternfly/react-icons';
+import type { FC } from 'react';
+
 import './example.css';
 import './example.css';
 
 
-export default function ExamplePage() {
+const ExamplePage: FC = () => {
   const { t } = useTranslation('plugin__console-plugin-template');
   const { t } = useTranslation('plugin__console-plugin-template');
 
 
   return (
   return (
@@ -36,4 +38,6 @@ export default function ExamplePage() {
       </PageSection>
       </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';

+ 10 - 0
test-frontend.sh

@@ -6,6 +6,10 @@ set -euo pipefail
 OPENSHIFT_CI=${OPENSHIFT_CI:=false}
 OPENSHIFT_CI=${OPENSHIFT_CI:=false}
 ARTIFACT_DIR=${ARTIFACT_DIR:=/tmp/artifacts}
 ARTIFACT_DIR=${ARTIFACT_DIR:=/tmp/artifacts}
 
 
+if [ ! -d node_modules ]; then
+  yarn install --immutable
+fi
+
 yarn i18n
 yarn i18n
 GIT_STATUS="$(git status --short --untracked-files -- locales)"
 GIT_STATUS="$(git status --short --untracked-files -- locales)"
 if [ -n "$GIT_STATUS" ]; then
 if [ -n "$GIT_STATUS" ]; then
@@ -20,3 +24,9 @@ if ! yarn dedupe --strategy highest --check ; then
   git --no-pager diff
   git --no-pager diff
   exit 1
   exit 1
 fi
 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

+ 6 - 6
test-prow-e2e.sh

@@ -3,16 +3,16 @@
 set -exuo pipefail
 set -exuo pipefail
 
 
 ARTIFACT_DIR=${ARTIFACT_DIR:=/tmp/artifacts}
 ARTIFACT_DIR=${ARTIFACT_DIR:=/tmp/artifacts}
-SCREENSHOTS_DIR=integration-tests/screenshots
+TEST_RESULTS_DIR=integration-tests/results
 INSTALLER_DIR=${INSTALLER_DIR:=${ARTIFACT_DIR}/installer}
 INSTALLER_DIR=${INSTALLER_DIR:=${ARTIFACT_DIR}/installer}
 
 
 function copyArtifacts {
 function copyArtifacts {
-  if [ -d "$ARTIFACT_DIR" ] && [ -d "$SCREENSHOTS_DIR" ]; then
-    if [[ -z "$(ls -A -- "$SCREENSHOTS_DIR")" ]]; then
+  if [ -d "$ARTIFACT_DIR" ] && [ -d "$TEST_RESULTS_DIR" ]; then
+    if [[ -z "$(ls -A -- "$TEST_RESULTS_DIR")" ]]; then
       echo "No artifacts were copied."
       echo "No artifacts were copied."
     else
     else
       echo "Copying artifacts from $(pwd)..."
       echo "Copying artifacts from $(pwd)..."
-      cp -r "$SCREENSHOTS_DIR" "${ARTIFACT_DIR}/screenshots"
+      cp -r "$TEST_RESULTS_DIR" "${ARTIFACT_DIR}"
     fi
     fi
   fi
   fi
 }
 }
@@ -33,5 +33,5 @@ if [ ! -d node_modules ]; then
   yarn install --immutable
   yarn install --immutable
 fi
 fi
 
 
-echo "Runs Cypress tests in headless mode"
-yarn test-cypress-headless
+echo "Runs Playwright tests in headless mode"
+yarn test-e2e-headless

+ 3 - 5
tsconfig.json

@@ -1,17 +1,15 @@
 {
 {
   "compilerOptions": {
   "compilerOptions": {
-    "baseUrl": ".",
     "module": "esnext",
     "module": "esnext",
     "moduleResolution": "node",
     "moduleResolution": "node",
-    "target": "es2021",
-    "sourceMap": true,
     "jsx": "react-jsx",
     "jsx": "react-jsx",
     "allowJs": true,
     "allowJs": true,
     "strict": true,
     "strict": true,
-    "noUnusedLocals": true
+    "noUnusedLocals": true,
+    "types": ["jest", "@testing-library/jest-dom", "node"]
   },
   },
   "include": ["src"],
   "include": ["src"],
-  "exclude": ["node_modules"],
+  "exclude": ["node_modules", ".yarn"],
   "ts-node": {
   "ts-node": {
     "files": true,
     "files": true,
     "transpileOnly": 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';
 import { ConsoleRemotePlugin } from '@openshift-console/dynamic-plugin-sdk-webpack';
 
 
 const CopyWebpackPlugin = require('copy-webpack-plugin');
 const CopyWebpackPlugin = require('copy-webpack-plugin');
+const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
 
 
 const isProd = process.env.NODE_ENV === 'production';
 const isProd = process.env.NODE_ENV === 'production';
 
 
@@ -31,14 +32,7 @@ const config: Configuration = {
       {
       {
         test: /\.(jsx?|tsx?)$/,
         test: /\.(jsx?|tsx?)$/,
         exclude: /\/node_modules\//,
         exclude: /\/node_modules\//,
-        use: [
-          {
-            loader: 'ts-loader',
-            options: {
-              configFile: path.resolve(__dirname, 'tsconfig.json'),
-            },
-          },
-        ],
+        use: ['swc-loader'],
       },
       },
       {
       {
         test: /\.(css)$/,
         test: /\.(css)$/,
@@ -75,6 +69,11 @@ const config: Configuration = {
   },
   },
   plugins: [
   plugins: [
     new ConsoleRemotePlugin(),
     new ConsoleRemotePlugin(),
+    new ForkTsCheckerWebpackPlugin({
+      typescript: {
+        configFile: path.resolve(__dirname, 'tsconfig.json'),
+      },
+    }),
     new CopyWebpackPlugin({
     new CopyWebpackPlugin({
       patterns: [{ from: path.resolve(__dirname, 'locales'), to: 'locales' }],
       patterns: [{ from: path.resolve(__dirname, 'locales'), to: 'locales' }],
     }),
     }),

Разлика између датотеке није приказан због своје велике величине
+ 746 - 537
yarn.lock


Неке датотеке нису приказане због велике количине промена