ソースを参照

Merge pull request #107 from logonoff/yarn

NO-JIRA: 4.22 breaking changes and assorted upstream alignment
openshift-merge-bot[bot] 4 ヶ月 前
コミット
3a06152cff

+ 6 - 2
.gitignore

@@ -2,8 +2,12 @@
 **/dist
 **/.DS_Store
 .devcontainer/dev.env
-integration-tests/videos
-integration-tests/screenshots
+integration-tests/.auth
+integration-tests/results
+
+# Jest
+coverage/
+jest-junit.xml
 
 # Yarn v4 (Berry)
 .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
 
-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.
 
 **Key Technologies:**
-- TypeScript + React 17
+- TypeScript + React 18
 - PatternFly 6 (UI component library)
 - Webpack 5 with Module Federation
 - react-i18next for internationalization
-- Cypress for e2e testing
+- Playwright for e2e testing
 - Helm for deployment
 
 **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
 locales/               # i18n translation files
 charts/                # Helm chart for deployment
-integration-tests/     # Cypress e2e tests
+integration-tests/     # Playwright e2e tests
 ```
 
 ## Development Workflow
@@ -98,18 +98,16 @@ integration-tests/     # Cypress e2e tests
 - Follow existing code patterns in the repo
 
 ### 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
 
 ## TypeScript Configuration
 
-Current config has `strict: false` but enforces:
+Current config has `strict: true` and enforces:
 - `noUnusedLocals: true`
 - All files should use `.tsx` extension
-- Target: ES2020
-
-**Modernization opportunity:** When touching files, consider enabling stricter TypeScript checks.
 
 ## 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
 5. **PatternFly CSS variables only** - hex colors break dark mode
 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
 
@@ -204,9 +201,9 @@ See [Console Plugin SDK README](https://github.com/openshift/console/tree/master
 
 ## 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
 
 ## 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
 - **Style something?** Use PatternFly components and CSS variables, prefix custom classes
 - **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

+ 5 - 3
Dockerfile

@@ -1,11 +1,13 @@
 FROM registry.access.redhat.com/ubi9/nodejs-22:latest AS build
 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
 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
 

+ 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
 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
 
 - [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 prettier from 'eslint-plugin-prettier/recommended';
 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';
 
 export default tseslint.config(
@@ -11,48 +15,70 @@ 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: ['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: {
+      ...jest.configs['flat/recommended'].languageOptions,
+      ...jest.configs['flat/style'].languageOptions,
       globals: {
-        require: 'readonly',
-        module: 'writable',
+        ...jest.configs['flat/recommended'].languageOptions?.globals,
+        ...globals.node,
       },
     },
     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',
-      '@typescript-eslint/no-namespace': 'off',
-      '@typescript-eslint/no-require-imports': 'off',
-      '@typescript-eslint/no-unused-expressions': 'off',
     },
   },
   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",
     "compilerOptions": {
         "noEmit": true,
-        "types":["cypress","node"],
+        "types": ["node"],
         "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;

+ 51 - 40
package.json

@@ -12,67 +12,78 @@
     "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-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"
   },
   "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/preset-env": "^7.29.3",
+    "@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.59.1",
+    "@playwright/test": "^1.59.1",
+    "@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": "^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",
     "css-loader": "^7.1.4",
-    "cypress": "^15.11.0",
-    "cypress-multi-reporters": "^2.0.5",
     "eslint": "^9.7.0",
     "eslint-config-prettier": "^10.1.8",
-    "eslint-plugin-cypress": "^6.1.0",
+    "eslint-import-resolver-typescript": "^4.4.4",
+    "eslint-plugin-import-x": "^4.16.2",
+    "eslint-plugin-jest": "^29.15.2",
+    "eslint-plugin-playwright": "^2.10.2",
     "eslint-plugin-prettier": "^5.5.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",
-    "mocha": "^11.7.5",
-    "mocha-junit-reporter": "^2.2.1",
-    "mochawesome": "^7.1.4",
-    "mochawesome-merge": "^5.1.1",
+    "jest": "^30.3.0",
+    "jest-environment-jsdom": "^30.3.0",
+    "jest-junit": "^17.0.0",
     "pluralize": "^8.0.0",
-    "prettier": "^3.8.1",
+    "prettier": "^3.8.3",
     "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",
-    "stylelint": "^17.4.0",
+    "stylelint": "^17.9.1",
     "stylelint-config-standard": "^40.0.0",
-    "ts-loader": "^9.5.4",
+    "swc-loader": "^0.2.7",
     "ts-node": "^10.9.2",
     "typescript": "^5.9.3",
-    "typescript-eslint": "^8.56.1",
-    "webpack": "^5.100.0",
-    "webpack-cli": "^6.0.1",
+    "typescript-eslint": "^8.59.1",
+    "webpack": "^5.106.2",
+    "webpack-cli": "^7.0.2",
     "webpack-dev-server": "^5.2.3"
   },
+  "dependenciesMeta": {
+    "@playwright/browser-chromium": {
+      "built": true
+    }
+  },
   "consolePlugin": {
     "name": "console-plugin-template",
     "version": "0.0.1",
@@ -82,8 +93,8 @@
       "ExamplePage": "./components/ExamplePage"
     },
     "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 { 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

+ 6 - 6
test-prow-e2e.sh

@@ -3,16 +3,16 @@
 set -exuo pipefail
 
 ARTIFACT_DIR=${ARTIFACT_DIR:=/tmp/artifacts}
-SCREENSHOTS_DIR=integration-tests/screenshots
+TEST_RESULTS_DIR=integration-tests/results
 INSTALLER_DIR=${INSTALLER_DIR:=${ARTIFACT_DIR}/installer}
 
 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."
     else
       echo "Copying artifacts from $(pwd)..."
-      cp -r "$SCREENSHOTS_DIR" "${ARTIFACT_DIR}/screenshots"
+      cp -r "$TEST_RESULTS_DIR" "${ARTIFACT_DIR}"
     fi
   fi
 }
@@ -33,5 +33,5 @@ if [ ! -d node_modules ]; then
   yarn install --immutable
 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": {
-    "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' }],
     }),

ファイルの差分が大きいため隠しています
+ 750 - 191
yarn.lock


この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません