From dbb739f12022280159b62c7ba442cd37acd02352 Mon Sep 17 00:00:00 2001 From: stefanonardo Date: Thu, 23 Apr 2026 13:28:49 +0200 Subject: [PATCH 1/2] Fix js-yaml type errors by pinning @types/js-yaml to v3 The hoisted @types/js-yaml v4 (from @kubernetes/client-node) removed safeLoad/safeDump/safeLoadAll from the type definitions, causing TS2305 errors even though the runtime js-yaml is still v3. Pin @types/js-yaml to ^3.12.7 and add type assertions to all safeLoad/safeDump call sites to match the patterns from OCPBUGS-78980. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/package.json | 8 + .../components/editor/yaml-download-utils.ts | 3 +- .../src/hooks/useResourceSidebarSamples.ts | 2 +- .../components/deployments/EditDeployment.tsx | 2 +- .../src/catalog/providers/useHelmCharts.tsx | 2 +- .../CreateHelmChartRepository.tsx | 2 +- .../HelmChartVersionDropdown.tsx | 2 +- .../HelmInstallUpgradePage.tsx | 2 +- .../url-chart/HelmURLChartInstallPage.tsx | 2 +- .../src/components/add/brokers/AddBroker.tsx | 4 +- .../src/utils/create-channel-utils.ts | 2 +- .../vsphere-plugin/src/components/persist.ts | 2 +- frontend/public/components/edit-yaml.tsx | 8 +- .../alertmanager/alertmanager-utils.tsx | 4 +- .../alert-manager-receiver-forms.tsx | 2 +- frontend/yarn.lock | 298 ++++++++++++++++-- 16 files changed, 292 insertions(+), 53 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 8c41d94edc6..185a694d344 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -49,6 +49,10 @@ "test-cypress-topology": "cd packages/topology/integration-tests && yarn run test-cypress", "test-cypress-topology-headless": "cd packages/topology/integration-tests && yarn run test-cypress-headless", "test-cypress-topology-nightly": "cd packages/topology/integration-tests && yarn run test-cypress-headless-all", + "test-playwright": "playwright test", + "test-playwright-headed": "playwright test --headed", + "test-playwright-debug": "playwright test --debug", + "test-playwright-ui": "playwright test --ui", "test-puppeteer-csp": "yarn ts-node ./test-puppeteer-csp.ts", "cypress-merge": "mochawesome-merge ./gui_test_screenshots/cypress_report*.json > ./gui_test_screenshots/cypress.json", "cypress-generate": "marge -o ./gui_test_screenshots/ -f cypress-report -t 'OpenShift Console Cypress Test Results' -p 'OpenShift Cypress Test Results' --showPassed false --assetsDir ./gui_test_screenshots/cypress/assets ./gui_test_screenshots/cypress.json", @@ -230,6 +234,8 @@ "@graphql-codegen/typescript": "^1.15.1", "@graphql-codegen/typescript-graphql-files-modules": "^1.15.1", "@graphql-codegen/typescript-operations": "^1.15.1", + "@kubernetes/client-node": "^1.4.0", + "@playwright/test": "^1.59.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.2", "@swc/core": "^1.15.18", "@swc/jest": "^0.2.39", @@ -241,6 +247,7 @@ "@types/glob": "7.x", "@types/immutable": "3.x", "@types/jest": "^30.0.0", + "@types/js-yaml": "^3.12.7", "@types/json-schema": "^7.0.7", "@types/lodash-es": "^4.17.12", "@types/node": "22.x", @@ -263,6 +270,7 @@ "cypress-cucumber-preprocessor": "^4.3.1", "cypress-jest-adapter": "^0.1.1", "cypress-multi-reporters": "^2.0.5", + "dotenv": "^17.4.2", "esbuild-loader": "^4.4.2", "file-loader": "6.2.0", "find-up": "4.x", diff --git a/frontend/packages/console-shared/src/components/editor/yaml-download-utils.ts b/frontend/packages/console-shared/src/components/editor/yaml-download-utils.ts index 4fd1f44cab8..3e54bcb4b93 100644 --- a/frontend/packages/console-shared/src/components/editor/yaml-download-utils.ts +++ b/frontend/packages/console-shared/src/components/editor/yaml-download-utils.ts @@ -1,11 +1,12 @@ import { saveAs } from 'file-saver'; import { safeLoad } from 'js-yaml'; +import type { K8sResourceKind } from '@console/dynamic-plugin-sdk/src'; export const downloadYaml = (data: BlobPart) => { const blob = new Blob([data], { type: 'text/yaml;charset=utf-8' }); let filename = 'k8s-object.yaml'; try { - const obj = safeLoad(data); + const obj = safeLoad(String(data)) as K8sResourceKind; if (obj.kind) { filename = `${obj.kind.toLowerCase()}-${obj.metadata.name}.yaml`; } diff --git a/frontend/packages/console-shared/src/hooks/useResourceSidebarSamples.ts b/frontend/packages/console-shared/src/hooks/useResourceSidebarSamples.ts index 1c160e09e21..f6f50186537 100644 --- a/frontend/packages/console-shared/src/hooks/useResourceSidebarSamples.ts +++ b/frontend/packages/console-shared/src/hooks/useResourceSidebarSamples.ts @@ -1,5 +1,5 @@ import { Map as ImmutableMap } from 'immutable'; -import YAML from 'js-yaml'; +import * as YAML from 'js-yaml'; import * as _ from 'lodash'; import { useTranslation } from 'react-i18next'; import { PodDisruptionBudgetModel } from '@console/app/src/models'; diff --git a/frontend/packages/dev-console/src/components/deployments/EditDeployment.tsx b/frontend/packages/dev-console/src/components/deployments/EditDeployment.tsx index ac67081b176..6703e1680db 100644 --- a/frontend/packages/dev-console/src/components/deployments/EditDeployment.tsx +++ b/frontend/packages/dev-console/src/components/deployments/EditDeployment.tsx @@ -46,7 +46,7 @@ const EditDeployment: FC = ({ heading, resource, namespace, const resourceType = getResourcesType(resource); if (values.editorType === EditorType.YAML) { try { - deploymentRes = safeLoad(values.yamlData); + deploymentRes = safeLoad(values.yamlData) as K8sResourceKind; if (!deploymentRes?.metadata?.namespace) { deploymentRes.metadata.namespace = namespace; } diff --git a/frontend/packages/helm-plugin/src/catalog/providers/useHelmCharts.tsx b/frontend/packages/helm-plugin/src/catalog/providers/useHelmCharts.tsx index a1de47c9a90..c529e02d6d2 100644 --- a/frontend/packages/helm-plugin/src/catalog/providers/useHelmCharts.tsx +++ b/frontend/packages/helm-plugin/src/catalog/providers/useHelmCharts.tsx @@ -52,7 +52,7 @@ const useHelmCharts: ExtensionHook = ({ .then(async (res) => { if (mounted) { const yaml = await res.text(); - const json = safeLoad(yaml); + const json = safeLoad(yaml) as { entries: HelmChartEntries }; setHelmCharts(json.entries); } }) diff --git a/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/CreateHelmChartRepository.tsx b/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/CreateHelmChartRepository.tsx index 1453b045587..4e36f8c4d92 100644 --- a/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/CreateHelmChartRepository.tsx +++ b/frontend/packages/helm-plugin/src/components/forms/HelmChartRepository/CreateHelmChartRepository.tsx @@ -91,7 +91,7 @@ const CreateHelmChartRepository: FC = ({ if (values.editorType === EditorType.YAML) { try { - HelmChartRepositoryRes = safeLoad(values.yamlData); + HelmChartRepositoryRes = safeLoad(values.yamlData) as HelmChartRepositoryType; if ( HelmChartRepositoryRes && HelmChartRepositoryRes.kind === 'ProjectHelmChartRepository' && diff --git a/frontend/packages/helm-plugin/src/components/forms/install-upgrade/HelmChartVersionDropdown.tsx b/frontend/packages/helm-plugin/src/components/forms/install-upgrade/HelmChartVersionDropdown.tsx index ef43ca4a71c..5b19c5caab8 100644 --- a/frontend/packages/helm-plugin/src/components/forms/install-upgrade/HelmChartVersionDropdown.tsx +++ b/frontend/packages/helm-plugin/src/components/forms/install-upgrade/HelmChartVersionDropdown.tsx @@ -125,7 +125,7 @@ const HelmChartVersionDropdown: FC = ({ try { const response = await coFetch(`/api/helm/charts/index.yaml?namespace=${namespace}`); const yaml = await response.text(); - json = safeLoad(yaml); + json = safeLoad(yaml) as { entries: HelmChartEntries }; } catch { if (ignore) return; } diff --git a/frontend/packages/helm-plugin/src/components/forms/install-upgrade/HelmInstallUpgradePage.tsx b/frontend/packages/helm-plugin/src/components/forms/install-upgrade/HelmInstallUpgradePage.tsx index 60ad8b2477c..d98fd3ce704 100644 --- a/frontend/packages/helm-plugin/src/components/forms/install-upgrade/HelmInstallUpgradePage.tsx +++ b/frontend/packages/helm-plugin/src/components/forms/install-upgrade/HelmInstallUpgradePage.tsx @@ -167,7 +167,7 @@ const HelmInstallUpgradePage: FC = () => { } } else if (yamlData) { try { - valuesObj = safeLoad(yamlData); + valuesObj = safeLoad(yamlData) as any; } catch (err) { actions.setStatus({ submitError: t('helm-plugin~Invalid YAML - {{errorText}}', { errorText: err.toString() }), diff --git a/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLChartInstallPage.tsx b/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLChartInstallPage.tsx index 81677aa01e8..116e4d7b9b7 100644 --- a/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLChartInstallPage.tsx +++ b/frontend/packages/helm-plugin/src/components/forms/url-chart/HelmURLChartInstallPage.tsx @@ -136,7 +136,7 @@ const HelmURLChartInstallPage: FunctionComponent = () => { } } else if (yamlData) { try { - valuesObj = safeLoad(yamlData); + valuesObj = safeLoad(yamlData) as Record; } catch (err) { actions.setStatus({ submitError: t('helm-plugin~Invalid YAML - {{errorText}}', { errorText: err.toString() }), diff --git a/frontend/packages/knative-plugin/src/components/add/brokers/AddBroker.tsx b/frontend/packages/knative-plugin/src/components/add/brokers/AddBroker.tsx index 436ad405b67..db5536ef310 100644 --- a/frontend/packages/knative-plugin/src/components/add/brokers/AddBroker.tsx +++ b/frontend/packages/knative-plugin/src/components/add/brokers/AddBroker.tsx @@ -37,13 +37,13 @@ const AddBroker: FC = ({ namespace, selectedApplication }) => { const createResources = ( formValues: AddBrokerFormYamlValues, actions: FormikHelpers, - ): Promise => { + ): Promise => { let broker: K8sResourceKind; if (formValues.editorType === EditorType.Form) { broker = convertFormToBrokerYaml(formValues.formData); } else { try { - broker = safeLoad(formValues.yamlData); + broker = safeLoad(formValues.yamlData) as K8sResourceKind; if (!broker.metadata?.namespace) { broker.metadata.namespace = formValues.formData.project.name; } diff --git a/frontend/packages/knative-plugin/src/utils/create-channel-utils.ts b/frontend/packages/knative-plugin/src/utils/create-channel-utils.ts index 1ad4a387a77..a5ab4203e82 100644 --- a/frontend/packages/knative-plugin/src/utils/create-channel-utils.ts +++ b/frontend/packages/knative-plugin/src/utils/create-channel-utils.ts @@ -167,7 +167,7 @@ export const useDefaultChannelConfiguration = (namespace: string): [string, bool ); let defaultConfiguredChannel = EVENTING_IMC_KIND; if (configMap && defaultConfiguredChannelLoaded) { - const cfg = safeLoad(configMap.data?.['default-ch-config']); + const cfg = safeLoad(configMap.data?.['default-ch-config']) as Record; defaultConfiguredChannel = _.hasIn(cfg?.namespaceDefaults, namespace) ? cfg?.namespaceDefaults[namespace].kind diff --git a/frontend/packages/vsphere-plugin/src/components/persist.ts b/frontend/packages/vsphere-plugin/src/components/persist.ts index 790210ec652..428e0da3850 100644 --- a/frontend/packages/vsphere-plugin/src/components/persist.ts +++ b/frontend/packages/vsphere-plugin/src/components/persist.ts @@ -132,7 +132,7 @@ const updateYamlFormat = ( ): string => { let cmCfg: ProviderCM; try { - cmCfg = safeLoad(cloudProviderConfig.data.config); + cmCfg = safeLoad(cloudProviderConfig.data.config) as ProviderCM; } catch (e) { throw new PersistError( t('Failed to parse cloud provider config {{cm}}', { cm: cloudProviderConfig.metadata.name }), diff --git a/frontend/public/components/edit-yaml.tsx b/frontend/public/components/edit-yaml.tsx index 11918bc469f..d3ca6a1668b 100644 --- a/frontend/public/components/edit-yaml.tsx +++ b/frontend/public/components/edit-yaml.tsx @@ -76,7 +76,7 @@ const generateObjToLoad = ( ) => { const sampleObj: K8sResourceKind = safeLoad( yaml ? yaml : getYAMLTemplates(templateExtensions).getIn([kind, id]), - ); + ) as K8sResourceKind; if (_.has(sampleObj.metadata, 'namespace')) { sampleObj.metadata.namespace = namespace; } @@ -336,7 +336,7 @@ const EditYAMLInner: FC = (props) => { const getResourceKindfromYAML = useCallback( (yaml) => { try { - const obj = safeLoad(yaml); + const obj = safeLoad(yaml) as Record; return getModel(obj)?.kind; } catch (e) { return 'unknown'; @@ -378,7 +378,7 @@ const EditYAMLInner: FC = (props) => { return false; } try { - safeLoad(str); + safeLoad(str) as Record; return true; } catch { return false; @@ -595,7 +595,7 @@ const EditYAMLInner: FC = (props) => { } try { - obj = safeLoad(editorMounted && getEditor()?.getValue()); + obj = safeLoad(editorMounted && getEditor()?.getValue()) as Record; } catch (e) { handleError(t('public~Error parsing YAML: {{e}}', { e })); return; diff --git a/frontend/public/components/monitoring/alertmanager/alertmanager-utils.tsx b/frontend/public/components/monitoring/alertmanager/alertmanager-utils.tsx index 4e3a596e0a8..2ec36059dca 100644 --- a/frontend/public/components/monitoring/alertmanager/alertmanager-utils.tsx +++ b/frontend/public/components/monitoring/alertmanager/alertmanager-utils.tsx @@ -44,7 +44,7 @@ export const getAlertmanagerConfig = ( ): { config: AlertmanagerConfig; errorMessage?: string } => { const parsedAlertmanagerYAML = getAlertmanagerYAML(secret); try { - const config = safeLoad(parsedAlertmanagerYAML.yaml); + const config = safeLoad(parsedAlertmanagerYAML.yaml) as AlertmanagerConfig; return { config, errorMessage: parsedAlertmanagerYAML.errorMessage }; } catch (e) { return { config: null, errorMessage: `Error loading alertmanager.yaml: ${e}` }; @@ -55,7 +55,7 @@ export const patchAlertmanagerConfig = ( secret: K8sResourceKind, yaml: object | string, ): Promise => { - const yamlString = _.isObject(yaml) ? safeDump(yaml) : yaml; + const yamlString = _.isObject(yaml) ? safeDump(yaml) : String(yaml); const yamlEncodedString = Base64.encode(yamlString); const patch = [{ op: 'replace', path: '/data/alertmanager.yaml', value: yamlEncodedString }]; return k8sPatch(SecretModel, secret, patch); diff --git a/frontend/public/components/monitoring/receiver-forms/alert-manager-receiver-forms.tsx b/frontend/public/components/monitoring/receiver-forms/alert-manager-receiver-forms.tsx index e4fd7abcd3a..45f8021f950 100644 --- a/frontend/public/components/monitoring/receiver-forms/alert-manager-receiver-forms.tsx +++ b/frontend/public/components/monitoring/receiver-forms/alert-manager-receiver-forms.tsx @@ -543,7 +543,7 @@ const ReceiverWrapper = memo(({ obj, ...props }) => { setLoadError({ message: 'alertmanager.v2.status.config.original not found.' }); } else { try { - const { global } = safeLoad(originalAlertmanagerConfigJSON); + const { global } = safeLoad(originalAlertmanagerConfigJSON) as Record; setAlertmanagerGlobals(global); setLoaded(true); } catch (error) { diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 9a3414f6d43..c14626facc0 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3226,6 +3226,30 @@ __metadata: languageName: node linkType: hard +"@kubernetes/client-node@npm:^1.4.0": + version: 1.4.0 + resolution: "@kubernetes/client-node@npm:1.4.0" + dependencies: + "@types/js-yaml": "npm:^4.0.1" + "@types/node": "npm:^24.0.0" + "@types/node-fetch": "npm:^2.6.13" + "@types/stream-buffers": "npm:^3.0.3" + form-data: "npm:^4.0.0" + hpagent: "npm:^1.2.0" + isomorphic-ws: "npm:^5.0.0" + js-yaml: "npm:^4.1.0" + jsonpath-plus: "npm:^10.3.0" + node-fetch: "npm:^2.7.0" + openid-client: "npm:^6.1.3" + rfc4648: "npm:^1.3.0" + socks-proxy-agent: "npm:^8.0.4" + stream-buffers: "npm:^3.0.2" + tar-fs: "npm:^3.0.9" + ws: "npm:^8.18.2" + checksum: 10c0/060bd78ee976c3af65319c2d0881adc25981d295d5ff585dad31b68dcf91c9b85f8f2c6ce6a43771cf6276a2fd3146421304f75467060183f5cf5e87bae7ff17 + languageName: node + linkType: hard + "@leichtgewicht/ip-codec@npm:^2.0.1": version: 2.0.5 resolution: "@leichtgewicht/ip-codec@npm:2.0.5" @@ -3764,6 +3788,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:^1.59.1": + version: 1.59.1 + resolution: "@playwright/test@npm:1.59.1" + dependencies: + playwright: "npm:1.59.1" + bin: + playwright: cli.js + checksum: 10c0/8c2d94a860d3c254a0b114df2f888ad0a0e9310f45b6059bd5d4da196d965cadf6922267cef0881cfa9784d4bef6d78363d2c2d94caa64be67ff644c41162137 + languageName: node + linkType: hard + "@pmmmwh/react-refresh-webpack-plugin@npm:^0.6.2": version: 0.6.2 resolution: "@pmmmwh/react-refresh-webpack-plugin@npm:0.6.2" @@ -4809,6 +4844,20 @@ __metadata: languageName: node linkType: hard +"@types/js-yaml@npm:^3.12.7": + version: 3.12.10 + resolution: "@types/js-yaml@npm:3.12.10" + checksum: 10c0/50ac81eb199342a18c9dfbb53ebddef27e0bc40e39de74b357dff820589fe9a752d7d146f4a2b9dd831e3ed3225d5e593c28be1aceaf122a8ce1c320b1bd08e9 + languageName: node + linkType: hard + +"@types/js-yaml@npm:^4.0.1": + version: 4.0.9 + resolution: "@types/js-yaml@npm:4.0.9" + checksum: 10c0/24de857aa8d61526bbfbbaa383aa538283ad17363fcd5bb5148e2c7f604547db36646440e739d78241ed008702a8920665d1add5618687b6743858fae00da211 + languageName: node + linkType: hard + "@types/jsdom@npm:^21.1.7": version: 21.1.7 resolution: "@types/jsdom@npm:21.1.7" @@ -4871,6 +4920,16 @@ __metadata: languageName: node linkType: hard +"@types/node-fetch@npm:^2.6.13": + version: 2.6.13 + resolution: "@types/node-fetch@npm:2.6.13" + dependencies: + "@types/node": "npm:*" + form-data: "npm:^4.0.4" + checksum: 10c0/6313c89f62c50bd0513a6839cdff0a06727ac5495ccbb2eeda51bb2bbbc4f3c0a76c0393a491b7610af703d3d2deb6cf60e37e59c81ceeca803ffde745dbf309 + languageName: node + linkType: hard + "@types/node-forge@npm:^1.3.0": version: 1.3.11 resolution: "@types/node-forge@npm:1.3.11" @@ -4880,7 +4939,16 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:22.x, @types/node@npm:>=13.7.0, @types/node@npm:>=6": +"@types/node@npm:*, @types/node@npm:>=13.7.0, @types/node@npm:>=6, @types/node@npm:^24.0.0": + version: 24.12.2 + resolution: "@types/node@npm:24.12.2" + dependencies: + undici-types: "npm:~7.16.0" + checksum: 10c0/710050c42f89075c4479e4e1e4c2532486b0c41b1e2a8a13ad88641c88b88cdaea87414e19224f30028719737bd70e327edcaa184d50e86b9418941edd7eb02b + languageName: node + linkType: hard + +"@types/node@npm:22.x": version: 22.15.15 resolution: "@types/node@npm:22.15.15" dependencies: @@ -5058,6 +5126,15 @@ __metadata: languageName: node linkType: hard +"@types/stream-buffers@npm:^3.0.3": + version: 3.0.8 + resolution: "@types/stream-buffers@npm:3.0.8" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/049b69c25c2f0a147f86c7048325885cd97ffcdb09eeff9decacd38ab93cef549db8524321b77548daa1ae0c70a3a21e4c4b518ea2c16bb50a037b13121c517a + languageName: node + linkType: hard + "@types/symlink-or-copy@npm:^1.2.0": version: 1.2.0 resolution: "@types/symlink-or-copy@npm:1.2.0" @@ -5960,14 +6037,14 @@ __metadata: linkType: hard "ajv@npm:^6.12.3, ajv@npm:^6.12.4, ajv@npm:^6.12.5, ajv@npm:^6.7.0": - version: 6.14.0 - resolution: "ajv@npm:6.14.0" + version: 6.15.0 + resolution: "ajv@npm:6.15.0" dependencies: fast-deep-equal: "npm:^3.1.1" fast-json-stable-stringify: "npm:^2.0.0" json-schema-traverse: "npm:^0.4.1" uri-js: "npm:^4.2.2" - checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22 + checksum: 10c0/67966499dd272ecde1c2e467084411132891523d057487587879d39ac04207f4351b7b2324c83198013967fbfa632c1612adc960114a30770fbe07a0773b32c2 languageName: node linkType: hard @@ -10210,6 +10287,13 @@ __metadata: languageName: node linkType: hard +"dotenv@npm:^17.4.2": + version: 17.4.2 + resolution: "dotenv@npm:17.4.2" + checksum: 10c0/164f8e77a646c8446867d5b588d26ea6005c8ea7c5eb41cf926f6113d23f2191355f6e0cfd95ea9bab98394a5b0a3f1e51a8399711b666fe55cc7b0bd745f942 + languageName: node + linkType: hard + "dotenv@npm:^4.0.0": version: 4.0.0 resolution: "dotenv@npm:4.0.0" @@ -10400,12 +10484,12 @@ __metadata: linkType: hard "enhanced-resolve@npm:^5.17.1, enhanced-resolve@npm:^5.17.4": - version: 5.20.1 - resolution: "enhanced-resolve@npm:5.20.1" + version: 5.21.0 + resolution: "enhanced-resolve@npm:5.21.0" dependencies: graceful-fs: "npm:^4.2.4" - tapable: "npm:^2.3.0" - checksum: 10c0/c6503ee1b2d725843e047e774445ecb12b779aa52db25d11ebe18d4b3adc148d3d993d2038b3d0c38ad836c9c4b3930fbc55df42f72b44785e2f94e5530eda69 + tapable: "npm:^2.3.3" + checksum: 10c0/8d25b9eb7cbaaf6bac7ca52cefb6aa8a723a3cea754aa3c52f269bdae3b6d5f3219fadbaf4362ed7d53f027e0b83bfbeb4c646640123cf62e6dbe52f28604c77 languageName: node linkType: hard @@ -12062,18 +12146,7 @@ __metadata: languageName: node linkType: hard -"form-data@npm:~2.3.2": - version: 2.3.3 - resolution: "form-data@npm:2.3.3" - dependencies: - asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.6" - mime-types: "npm:^2.1.12" - checksum: 10c0/706ef1e5649286b6a61e5bb87993a9842807fd8f149cd2548ee807ea4fb882247bdf7f6e64ac4720029c0cd5c80343de0e22eee1dc9e9882e12db9cc7bc016a4 - languageName: node - linkType: hard - -"form-data@npm:~4.0.4": +"form-data@npm:^4.0.0, form-data@npm:^4.0.4, form-data@npm:~4.0.4": version: 4.0.5 resolution: "form-data@npm:4.0.5" dependencies: @@ -12086,6 +12159,17 @@ __metadata: languageName: node linkType: hard +"form-data@npm:~2.3.2": + version: 2.3.3 + resolution: "form-data@npm:2.3.3" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.6" + mime-types: "npm:^2.1.12" + checksum: 10c0/706ef1e5649286b6a61e5bb87993a9842807fd8f149cd2548ee807ea4fb882247bdf7f6e64ac4720029c0cd5c80343de0e22eee1dc9e9882e12db9cc7bc016a4 + languageName: node + linkType: hard + "formik@npm:^2.1.5, formik@npm:^2.4.5": version: 2.4.6 resolution: "formik@npm:2.4.6" @@ -12254,6 +12338,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@npm:^2.3.3, fsevents@npm:~2.3.2": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -12274,6 +12368,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" @@ -13118,6 +13221,13 @@ __metadata: languageName: node linkType: hard +"hpagent@npm:^1.2.0": + version: 1.2.0 + resolution: "hpagent@npm:1.2.0" + checksum: 10c0/505ef42e5e067dba701ea21e7df9fa73f6f5080e59d53680829827d34cd7040f1ecf7c3c8391abe9df4eb4682ef4a4321608836b5b70a61b88c1b3a03d77510b + languageName: node + linkType: hard + "html-encoding-sniffer@npm:^4.0.0": version: 4.0.0 resolution: "html-encoding-sniffer@npm:4.0.0" @@ -14485,6 +14595,15 @@ __metadata: languageName: node linkType: hard +"isomorphic-ws@npm:^5.0.0": + version: 5.0.0 + resolution: "isomorphic-ws@npm:5.0.0" + peerDependencies: + ws: "*" + checksum: 10c0/a058ac8b5e6efe9e46252cb0bc67fd325005d7216451d1a51238bc62d7da8486f828ef017df54ddf742e0fffcbe4b1bcc2a66cc115b027ed0180334cd18df252 + languageName: node + linkType: hard + "isstream@npm:~0.1.2": version: 0.1.2 resolution: "isstream@npm:0.1.2" @@ -15153,6 +15272,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^6.2.2": + version: 6.2.2 + resolution: "jose@npm:6.2.2" + checksum: 10c0/201f4776d77eccd339de99fb3ba940fdf03db15e64be7a99b511e53c232e3f3818e3f21b95223d62f99315a2ab76b4251cedd94e067de56893e45273a8d2151b + languageName: node + linkType: hard + "jquery@npm:^3.4.0": version: 3.5.1 resolution: "jquery@npm:3.5.1" @@ -17445,7 +17571,7 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:2.6.1, node-fetch@npm:^2.3.0": +"node-fetch@npm:2.6.1": version: 2.6.1 resolution: "node-fetch@npm:2.6.1" checksum: 10c0/c58586d121782df045681e29608f940be90c7d8c4cada29957c148cfcc5e2d81d74b690cf10ee6879ed055da7ea821450a74ff43f3bde651cf6c8a5f34a77e2a @@ -17462,6 +17588,20 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:^2.3.0, node-fetch@npm:^2.7.0": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: "npm:^5.0.0" + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8 + languageName: node + linkType: hard + "node-forge@npm:^1": version: 1.3.1 resolution: "node-forge@npm:1.3.1" @@ -17647,6 +17787,13 @@ __metadata: languageName: node linkType: hard +"oauth4webapi@npm:^3.8.5": + version: 3.8.5 + resolution: "oauth4webapi@npm:3.8.5" + checksum: 10c0/688142b30f2243813721bfa4ab879aa0056636b19a3d7964d46b11b967199ab8f74f3771225f71ec766821d410add950475cf1afcfe26a9640cd1c0a1de8e423 + languageName: node + linkType: hard + "object-assign@npm:^4.0.1, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" @@ -17869,6 +18016,16 @@ __metadata: languageName: node linkType: hard +"openid-client@npm:^6.1.3": + version: 6.8.3 + resolution: "openid-client@npm:6.8.3" + dependencies: + jose: "npm:^6.2.2" + oauth4webapi: "npm:^3.8.5" + checksum: 10c0/3765b5d3be0f5d7443ccc9f711a3c2176452505436f069037e1b03adb710626a08c7242666d59809585272261402a1ab9795fe89696f714463af145bacc50406 + languageName: node + linkType: hard + "openshift-console@workspace:.": version: 0.0.0-use.local resolution: "openshift-console@workspace:." @@ -17879,6 +18036,7 @@ __metadata: "@graphql-codegen/typescript": "npm:^1.15.1" "@graphql-codegen/typescript-graphql-files-modules": "npm:^1.15.1" "@graphql-codegen/typescript-operations": "npm:^1.15.1" + "@kubernetes/client-node": "npm:^1.4.0" "@openshift/dynamic-plugin-sdk": "npm:^8.0.0" "@openshift/dynamic-plugin-sdk-webpack": "npm:^5.1.1" "@patternfly/patternfly": "npm:~6.4.0" @@ -17899,6 +18057,7 @@ __metadata: "@patternfly/react-topology": "npm:~6.4.0" "@patternfly/react-user-feedback": "npm:~6.2.0" "@patternfly/react-virtualized-extension": "npm:~6.2.0" + "@playwright/test": "npm:^1.59.1" "@pmmmwh/react-refresh-webpack-plugin": "npm:^0.6.2" "@rjsf/core": "npm:^4.2.3" "@swc/core": "npm:^1.15.18" @@ -17911,6 +18070,7 @@ __metadata: "@types/glob": "npm:7.x" "@types/immutable": "npm:3.x" "@types/jest": "npm:^30.0.0" + "@types/js-yaml": "npm:^3.12.7" "@types/json-schema": "npm:^7.0.7" "@types/lodash-es": "npm:^4.17.12" "@types/node": "npm:22.x" @@ -17942,6 +18102,7 @@ __metadata: cypress-jest-adapter: "npm:^0.1.1" cypress-multi-reporters: "npm:^2.0.5" dagre: "npm:^0.8.5" + dotenv: "npm:^17.4.2" esbuild-loader: "npm:^4.4.2" file-loader: "npm:6.2.0" file-saver: "npm:1.3.x" @@ -18688,6 +18849,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.59.1": + version: 1.59.1 + resolution: "playwright-core@npm:1.59.1" + bin: + playwright-core: cli.js + checksum: 10c0/d41a74d9681ce3beb3d5239e9ed577710b4ad099a6ca2476219c6599d51e9cb4b80bd72ed82c528da6a5d929c18ae3b872cf02bb83f78fa1c2cb9199c501abee + languageName: node + linkType: hard + +"playwright@npm:1.59.1": + version: 1.59.1 + resolution: "playwright@npm:1.59.1" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.59.1" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10c0/dfe38396e616e5c4f98825ce90037bb96e477c5a2bd9258a24854f8ce72a8a41427b19098863866f85aa0216e70287dd537c4438d761aca93995e31ae099c533 + languageName: node + linkType: hard + "please-upgrade-node@npm:^3.2.0": version: 3.2.0 resolution: "please-upgrade-node@npm:3.2.0" @@ -20159,6 +20344,13 @@ __metadata: languageName: node linkType: hard +"rfc4648@npm:^1.3.0": + version: 1.5.4 + resolution: "rfc4648@npm:1.5.4" + checksum: 10c0/8683e82ed9c3cb23844720d04eaeee12025146bfdfdf250b1cce80d56e16c6431530ba3033cbb0e7ca3a25223107847f14c6cac11a255ea7d219dc7ba11cd43d + languageName: node + linkType: hard + "rimraf@npm:^2.5.4": version: 2.7.1 resolution: "rimraf@npm:2.7.1" @@ -20934,7 +21126,7 @@ __metadata: languageName: node linkType: hard -"socks-proxy-agent@npm:^8.0.3, socks-proxy-agent@npm:^8.0.5": +"socks-proxy-agent@npm:^8.0.3, socks-proxy-agent@npm:^8.0.4, socks-proxy-agent@npm:^8.0.5": version: 8.0.5 resolution: "socks-proxy-agent@npm:8.0.5" dependencies: @@ -21295,6 +21487,13 @@ __metadata: languageName: node linkType: hard +"stream-buffers@npm:^3.0.2": + version: 3.0.3 + resolution: "stream-buffers@npm:3.0.3" + checksum: 10c0/d052e6344fba340b27dfbe8d6568f600b7f81fdc57b2659e82c8d58a3ef855a4852c56736b1078a511a7f4458db96ee89b11c42c96d116b9073a99deb29a6f05 + languageName: node + linkType: hard + "stream-combiner2@npm:^1.1.1": version: 1.1.1 resolution: "stream-combiner2@npm:1.1.1" @@ -21777,16 +21976,16 @@ __metadata: languageName: node linkType: hard -"tapable@npm:^2.0.0, tapable@npm:^2.2.1, tapable@npm:^2.3.0": - version: 2.3.0 - resolution: "tapable@npm:2.3.0" - checksum: 10c0/cb9d67cc2c6a74dedc812ef3085d9d681edd2c1fa18e4aef57a3c0605fdbe44e6b8ea00bd9ef21bc74dd45314e39d31227aa031ebf2f5e38164df514136f2681 +"tapable@npm:^2.0.0, tapable@npm:^2.2.1, tapable@npm:^2.3.0, tapable@npm:^2.3.3": + version: 2.3.3 + resolution: "tapable@npm:2.3.3" + checksum: 10c0/47992e861053f861154e92fb4a98ac4ab47b6463717e60792dd1e8c755da0c4964cd8bb68c308a9066d6da89000b6310457b4d5d985c30de4ccc29066068cc17 languageName: node linkType: hard -"tar-fs@npm:^3.0.6": - version: 3.0.8 - resolution: "tar-fs@npm:3.0.8" +"tar-fs@npm:^3.0.6, tar-fs@npm:^3.0.9": + version: 3.1.2 + resolution: "tar-fs@npm:3.1.2" dependencies: bare-fs: "npm:^4.0.1" bare-path: "npm:^3.0.0" @@ -21797,7 +21996,7 @@ __metadata: optional: true bare-path: optional: true - checksum: 10c0/b70bb2ad0490ab13b48edd10bd648bb54c52b681981cdcdc3aa4517e98ad94c94659ddca1925872ee658d781b9fcdd2b1c808050647f06b1bca157dd2fcae038 + checksum: 10c0/9dcbbbef9cdfc27f47651fe679f15952a6a8e6b3c9761c4bf3f416ace41cf462fb6292519bd3e041cadfcc0b89043a6bdecb46ff19f770b6864b77dcde7bad46 languageName: node linkType: hard @@ -22243,6 +22442,13 @@ __metadata: languageName: node linkType: hard +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 10c0/047cb209a6b60c742f05c9d3ace8fa510bff609995c129a37ace03476a9b12db4dbf975e74600830ef0796e18882b2381fb5fb1f6b4f96b832c374de3ab91a11 + languageName: node + linkType: hard + "tree-dump@npm:^1.0.1": version: 1.0.2 resolution: "tree-dump@npm:1.0.2" @@ -22703,6 +22909,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.16.0": + version: 7.16.0 + resolution: "undici-types@npm:7.16.0" + checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a + languageName: node + linkType: hard + "undici@npm:^5.4.0": version: 5.28.4 resolution: "undici@npm:5.28.4" @@ -23858,6 +24071,13 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: 10c0/5612d5f3e54760a797052eb4927f0ddc01383550f542ccd33d5238cfd65aeed392a45ad38364970d0a0f4fea32e1f4d231b3d8dac4a3bdd385e5cf802ae097db + languageName: node + linkType: hard + "webidl-conversions@npm:^4.0.2": version: 4.0.2 resolution: "webidl-conversions@npm:4.0.2" @@ -24125,6 +24345,16 @@ __metadata: languageName: node linkType: hard +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: "npm:~0.0.3" + webidl-conversions: "npm:^3.0.0" + checksum: 10c0/1588bed84d10b72d5eec1d0faa0722ba1962f1821e7539c535558fb5398d223b0c50d8acab950b8c488b4ba69043fd833cc2697056b167d8ad46fac3995a55d5 + languageName: node + linkType: hard + "whatwg-url@npm:^7.0.0": version: 7.0.0 resolution: "whatwg-url@npm:7.0.0" @@ -24355,9 +24585,9 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.18.0": - version: 8.18.0 - resolution: "ws@npm:8.18.0" +"ws@npm:^8.18.0, ws@npm:^8.18.2": + version: 8.20.0 + resolution: "ws@npm:8.20.0" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -24366,7 +24596,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10c0/25eb33aff17edcb90721ed6b0eb250976328533ad3cd1a28a274bd263682e7296a6591ff1436d6cbc50fa67463158b062f9d1122013b361cec99a05f84680e06 + checksum: 10c0/956ac5f11738c914089b65878b9223692ace77337ba55379ae68e1ecbeae9b47a0c6eb9403688f609999a58c80d83d99865fe0029b229d308b08c1ef93d4ea14 languageName: node linkType: hard From 1e56e9e215ade1272f5b13047819d7d67b778404 Mon Sep 17 00:00:00 2001 From: stefanonardo Date: Thu, 23 Apr 2026 14:23:29 +0200 Subject: [PATCH 2/2] CONSOLE-5212: Set up Playwright e2e test infrastructure Add the foundation for migrating Cypress e2e tests to Playwright: config with admin and developer projects using separate storageState files, global setup and teardown with cluster authentication and namespace lifecycle, KubernetesClient for typed K8s API calls, BasePage with robustClick and loading detection, CleanupFixture for automatic resource cleanup, and a smoke test that proves the full lifecycle works against both localhost (auth-disabled) and remote clusters. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 + frontend/.eslintignore | 1 + frontend/.eslintrc.js | 12 + frontend/e2e/.env.example | 37 ++ frontend/e2e/clients/kubernetes-client.ts | 474 ++++++++++++++++++++ frontend/e2e/fixtures/cleanup-fixture.ts | 174 +++++++ frontend/e2e/fixtures/index.ts | 80 ++++ frontend/e2e/global.setup.ts | 168 +++++++ frontend/e2e/global.teardown.ts | 62 +++ frontend/e2e/pages/base-page.ts | 97 ++++ frontend/e2e/tests/smoke/smoke-test.spec.ts | 6 + frontend/e2e/tsconfig.json | 15 + frontend/package.json | 20 +- frontend/playwright.config.ts | 106 +++++ frontend/tsconfig.json | 3 +- 15 files changed, 1255 insertions(+), 4 deletions(-) create mode 100644 frontend/e2e/.env.example create mode 100644 frontend/e2e/clients/kubernetes-client.ts create mode 100644 frontend/e2e/fixtures/cleanup-fixture.ts create mode 100644 frontend/e2e/fixtures/index.ts create mode 100644 frontend/e2e/global.setup.ts create mode 100644 frontend/e2e/global.teardown.ts create mode 100644 frontend/e2e/pages/base-page.ts create mode 100644 frontend/e2e/tests/smoke/smoke-test.spec.ts create mode 100644 frontend/e2e/tsconfig.json create mode 100644 frontend/playwright.config.ts diff --git a/.gitignore b/.gitignore index 07b9936295d..7b9a9530880 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,10 @@ cypress-a11y-report.json /frontend/**/yarn-error.log /frontend/**/dist /frontend/@types/console/generated +/frontend/e2e/.auth/ +/frontend/e2e/.test-config.json +/frontend/test-results/ +/frontend/playwright-report/ /frontend/gui_test_screenshots /frontend/package-lock.json /frontend/po-files diff --git a/frontend/.eslintignore b/frontend/.eslintignore index 9941413fc25..0e36cd165fd 100644 --- a/frontend/.eslintignore +++ b/frontend/.eslintignore @@ -12,3 +12,4 @@ Godeps dynamic-demo-plugin .eslintrc.js tsconfig.json +e2e/tsconfig.json diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index e71f4384d5b..f0cf1003c94 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -126,6 +126,18 @@ module.exports = { version: 'detect', }, }, + overrides: [ + { + files: ['e2e/**/*.ts'], + parserOptions: { + project: './e2e/tsconfig.json', + }, + rules: { + 'no-console': 'off', + 'no-empty-pattern': 'off', + }, + }, + ], globals: { process: 'readonly', React: true, diff --git a/frontend/e2e/.env.example b/frontend/e2e/.env.example new file mode 100644 index 00000000000..2d4204d24d4 --- /dev/null +++ b/frontend/e2e/.env.example @@ -0,0 +1,37 @@ +# OpenShift Console Playwright E2E Configuration +# Copy to .env and fill in values for your environment. + +# Console URL to test against (default: http://localhost:9000) +# Separate from BRIDGE_BASE_ADDRESS so you can be oc-login'd to a cluster +# while testing against localhost. +WEB_CONSOLE_URL=http://localhost:9000 + +# K8s API server URL (only needed if no kubeconfig is available) +# When you oc login, the kubeconfig already has the cluster URL. +# CLUSTER_URL=https://api.mycluster.example.com:6443 + +# Username for cluster authentication (default: kubeadmin) +# OPENSHIFT_USERNAME=kubeadmin + +# Required for browser login on remote clusters +BRIDGE_KUBEADMIN_PASSWORD= + +# Optional: htpasswd identity provider for developer user tests +# BRIDGE_HTPASSWD_IDP=my_htpasswd_provider +# BRIDGE_HTPASSWD_USERNAME=testuser +# BRIDGE_HTPASSWD_PASSWORD= + +# Optional: path to kubeconfig (defaults to ~/.kube/config) +# KUBECONFIG= + +# Optional: number of parallel workers (default: auto locally, 1 in CI) +# WORKERS=4 + +# Optional: skip global setup (reuse existing .auth/ files) +# SKIP_GLOBAL_SETUP=true + +# Optional: skip resource cleanup after tests +# SKIP_TEST_CLEANUP=true + +# Optional: debug mode (skip cleanup, keep artifacts, enable video) +# DEBUG=1 diff --git a/frontend/e2e/clients/kubernetes-client.ts b/frontend/e2e/clients/kubernetes-client.ts new file mode 100644 index 00000000000..a9255d43cf4 --- /dev/null +++ b/frontend/e2e/clients/kubernetes-client.ts @@ -0,0 +1,474 @@ +import * as fs from 'fs'; +import * as https from 'https'; +import * as net from 'net'; +import * as path from 'path'; +import { URL } from 'url'; + +import * as k8s from '@kubernetes/client-node'; + +export interface ClusterAuthConfig { + clusterUrl: string; + username: string; + password: string; + token?: string; +} + +function isNotFound(err: unknown): boolean { + if (typeof err === 'object' && err !== null) { + const statusCode = (err as any).statusCode ?? (err as any).response?.statusCode; + if (statusCode === 404) { + return true; + } + const msg = err instanceof Error ? err.message : String(err); + return msg.includes('404') || msg.includes('not found'); + } + return false; +} + +async function pollUntil( + condition: () => Promise, + timeoutMs: number, + intervalMs = 1_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await condition()) { + return true; + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + return false; +} + +export default class KubernetesClient { + private readonly k8sApi: k8s.CoreV1Api; + private readonly appsApi: k8s.AppsV1Api; + private readonly coApi: k8s.CustomObjectsApi; + private readonly kubeConfig: k8s.KubeConfig; + + private static getProxyUrl(): string | undefined { + return ( + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy + ); + } + + private static createProxyAgent(proxyUrl: string): https.Agent { + const proxy = new URL(proxyUrl); + return new https.Agent({ + rejectUnauthorized: false, + createConnection: (options, callback) => { + const proxySocket = net.connect( + { host: proxy.hostname, port: parseInt(proxy.port || '3128', 10) }, + () => { + proxySocket.write( + [ + `CONNECT ${options.host}:${options.port} HTTP/1.1`, + `Host: ${options.host}:${options.port}`, + 'Connection: keep-alive', + '', + '', + ].join('\r\n'), + ); + }, + ); + let responseData = ''; + const onData = (chunk: Buffer) => { + responseData += chunk.toString(); + if (responseData.includes('\r\n\r\n')) { + proxySocket.removeListener('data', onData); + const [statusLine] = responseData.split('\r\n'); + const statusCode = parseInt(statusLine.split(' ')[1], 10); + if (statusCode === 200) { + callback(null, proxySocket); + } else { + proxySocket.destroy(); + callback(new Error(`Proxy CONNECT failed: ${statusCode}`) as any, null as any); + } + } + }; + proxySocket.on('data', onData); + proxySocket.on('error', (err) => { + callback(err as any, null as any); + }); + }, + } as https.AgentOptions); + } + + static async getOAuthToken( + clusterUrl: string, + username: string, + password: string, + ): Promise { + const oauthServerUrl = await KubernetesClient.getOAuthServerUrl(clusterUrl); + return new Promise((resolve, reject) => { + const authHeader = Buffer.from(`${username}:${password}`).toString('base64'); + const tokenUrl = new URL('/oauth/authorize', oauthServerUrl); + tokenUrl.searchParams.set('response_type', 'token'); + tokenUrl.searchParams.set('client_id', 'openshift-challenging-client'); + const proxyUrl = KubernetesClient.getProxyUrl(); + const agent = proxyUrl ? KubernetesClient.createProxyAgent(proxyUrl) : undefined; + const options: https.RequestOptions = { + hostname: tokenUrl.hostname, + port: tokenUrl.port || 443, + path: tokenUrl.pathname + tokenUrl.search, + method: 'GET', + headers: { Authorization: `Basic ${authHeader}`, 'X-CSRF-Token': '1' }, + rejectUnauthorized: false, + agent, + }; + const req = https.request(options, (res) => { + const location = res.headers.location; + if (location && location.includes('access_token=')) { + const match = location.match(/access_token=([^&]+)/); + if (match) { + resolve(match[1]); + return; + } + } + let body = ''; + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + reject( + new Error( + `OAuth authentication failed: HTTP ${res.statusCode}. Response: ${body.substring( + 0, + 200, + )}`, + ), + ); + }); + }); + req.on('error', (err) => reject(new Error(`OAuth request failed: ${err.message}`))); + req.end(); + }); + } + + private static async getOAuthServerUrl(clusterUrl: string): Promise { + return new Promise((resolve) => { + const url = new URL('/.well-known/oauth-authorization-server', clusterUrl); + const proxyUrl = KubernetesClient.getProxyUrl(); + const agent = proxyUrl ? KubernetesClient.createProxyAgent(proxyUrl) : undefined; + const options: https.RequestOptions = { + hostname: url.hostname, + port: url.port || 443, + path: url.pathname, + method: 'GET', + rejectUnauthorized: false, + agent, + }; + const req = https.request(options, (res) => { + let body = ''; + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + try { + resolve(JSON.parse(body).issuer || clusterUrl); + } catch { + resolve(clusterUrl); + } + }); + }); + req.on('error', () => resolve(clusterUrl)); + req.end(); + }); + } + + static async generateKubeconfig( + clusterUrl: string, + username: string, + password: string, + outputPath: string, + ): Promise { + const token = await KubernetesClient.getOAuthToken(clusterUrl, username, password); + const kubeconfigYaml = [ + 'apiVersion: v1', + 'kind: Config', + 'clusters:', + ' - name: cluster', + ' cluster:', + ` server: ${clusterUrl}`, + ' insecure-skip-tls-verify: true', + 'contexts:', + ' - name: context', + ' context:', + ' cluster: cluster', + ' user: user', + 'current-context: context', + 'users:', + ' - name: user', + ' user:', + ` token: ${token}`, + '', + ].join('\n'); + const dir = path.dirname(outputPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(outputPath, kubeconfigYaml, { encoding: 'utf8', mode: 0o600 }); + return outputPath; + } + + constructor(config: ClusterAuthConfig, kubeConfigPath?: string) { + this.kubeConfig = new k8s.KubeConfig(); + const effectivePath = kubeConfigPath || this.tryDiscoverKubeConfig(); + if (effectivePath && fs.existsSync(effectivePath)) { + this.kubeConfig.loadFromFile(effectivePath); + } else { + // Try default kubeconfig (~/.kube/config from oc login), then token fallback + try { + this.kubeConfig.loadFromDefault(); + } catch { + if (config.token && config.clusterUrl) { + this.kubeConfig.loadFromOptions({ + clusters: [{ name: 'cluster', server: config.clusterUrl, skipTLSVerify: true }], + contexts: [{ cluster: 'cluster', name: 'context', user: 'user' }], + currentContext: 'context', + users: [{ name: 'user', token: config.token }], + }); + } else { + throw new Error( + 'No kubeconfig found and no token/clusterUrl provided. Run "oc login" or set KUBECONFIG.', + ); + } + } + } + + const proxyUrl = KubernetesClient.getProxyUrl(); + if (proxyUrl && !process.env.NODE_TLS_REJECT_UNAUTHORIZED) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + } + + this.k8sApi = this.kubeConfig.makeApiClient(k8s.CoreV1Api); + this.coApi = this.kubeConfig.makeApiClient(k8s.CustomObjectsApi); + this.appsApi = this.kubeConfig.makeApiClient(k8s.AppsV1Api); + } + + private tryDiscoverKubeConfig(): string | undefined { + const kubeConfigDir = path.join(process.cwd(), '.kubeconfigs'); + const configPath = path.join(kubeConfigDir, 'test-config'); + if (fs.existsSync(configPath)) { + return configPath; + } + return undefined; + } + + get kc(): k8s.KubeConfig { + return this.kubeConfig; + } + get coreV1Api(): k8s.CoreV1Api { + return this.k8sApi; + } + get customObjectsApi(): k8s.CustomObjectsApi { + return this.coApi; + } + get appsV1Api(): k8s.AppsV1Api { + return this.appsApi; + } + + getCurrentUserToken(): string | undefined { + try { + return this.kubeConfig.getCurrentUser()?.token; + } catch { + return undefined; + } + } + + async verifyAuthentication(): Promise { + await this.k8sApi.listNamespace({ limit: 1 }); + return true; + } + + async createNamespace(name: string, labels?: Record): Promise { + try { + await this.k8sApi.readNamespace({ name }); + return; // already exists + } catch (err) { + if (!isNotFound(err)) { + throw err; + } + } + await this.k8sApi.createNamespace({ + body: { + metadata: { name, labels: { ...labels, 'openshift.io/run-level': '0' } }, + }, + }); + } + + async deleteNamespace(name: string): Promise { + try { + await this.k8sApi.deleteNamespace({ name }); + } catch (err) { + if (!isNotFound(err)) { + throw err; + } + } + } + + async waitForNamespaceReady(name: string, timeoutMs = 30_000): Promise { + return pollUntil( + async () => { + try { + const ns = await this.k8sApi.readNamespace({ name }); + return ns?.status?.phase === 'Active'; + } catch { + return false; + } + }, + timeoutMs, + 1_000, + ); + } + + async waitForNamespaceDeleted(name: string, timeoutMs = 120_000): Promise { + return pollUntil( + async () => { + try { + await this.k8sApi.readNamespace({ name }); + return false; // still exists + } catch (err) { + if (isNotFound(err)) { + return true; + } // gone + throw err; // unexpected error — don't silently swallow + } + }, + timeoutMs, + 2_000, + ); + } + + async setupConsoleUserSettings(username = 'kubeadmin', defaultNamespace?: string): Promise { + const namespace = 'openshift-console-user-settings'; + const configMapName = `user-settings-${username}`; + const patchData: Record = { + 'console.guidedTour': JSON.stringify({ + admin: { completed: true }, + dev: { completed: true }, + }), + }; + if (defaultNamespace) { + patchData['console.lastNamespace'] = defaultNamespace; + } + try { + await this.patchConfigMap(configMapName, namespace, patchData); + } catch { + // ConfigMap may not exist yet — that's fine, tour will be dismissed in browser + } + } + + async patchConfigMap( + name: string, + namespace: string, + patchData: Record, + ): Promise { + const existing = await this.k8sApi.readNamespacedConfigMap({ name, namespace }); + const existingData = (existing as any)?.data || {}; + const mergedData = { ...existingData, ...patchData }; + await this.k8sApi.patchNamespacedConfigMap({ + name, + namespace, + body: { data: mergedData }, + contentType: k8s.PatchStrategy.MergePatch, + } as any); + } + + async deleteConfigMap(name: string, namespace: string): Promise { + try { + await this.k8sApi.deleteNamespacedConfigMap({ name, namespace }); + } catch (err) { + if (!isNotFound(err)) { + throw err; + } + } + } + + async deleteSecret(name: string, namespace: string): Promise { + try { + await this.k8sApi.deleteNamespacedSecret({ name, namespace }); + } catch (err) { + if (!isNotFound(err)) { + throw err; + } + } + } + + async createCustomResource( + group: string, + version: string, + namespace: string, + plural: string, + body: Record, + ): Promise { + const response = await this.coApi.createNamespacedCustomObject({ + body, + group, + namespace, + plural, + version, + }); + return response; + } + + async deleteCustomResource( + group: string, + version: string, + namespace: string, + plural: string, + name: string, + ): Promise { + try { + await this.coApi.deleteNamespacedCustomObject({ group, name, namespace, plural, version }); + } catch (err) { + if (!isNotFound(err)) { + throw err; + } + } + } + + async getCustomResource( + group: string, + version: string, + namespace: string, + plural: string, + name: string, + ): Promise { + const response = await this.coApi.getNamespacedCustomObject({ + group, + name, + namespace, + plural, + version, + }); + return response; + } + + async listCustomResources( + group: string, + version: string, + namespace: string, + plural: string, + ): Promise { + try { + const response = await this.coApi.listNamespacedCustomObject({ + group, + namespace, + plural, + version, + }); + return (response as any)?.items || []; + } catch { + return []; + } + } + + async getPods(namespace: string): Promise { + const response = await this.k8sApi.listNamespacedPod({ namespace }); + return response.items || []; + } +} diff --git a/frontend/e2e/fixtures/cleanup-fixture.ts b/frontend/e2e/fixtures/cleanup-fixture.ts new file mode 100644 index 00000000000..e7b9fb0f463 --- /dev/null +++ b/frontend/e2e/fixtures/cleanup-fixture.ts @@ -0,0 +1,174 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import KubernetesClient from '../clients/kubernetes-client'; + +export interface TrackedResource { + name: string; + namespace?: string; + apiGroup: string; + apiVersion: string; + plural: string; + type: string; +} + +export interface CleanupFixture { + track(resource: TrackedResource): void; + trackNamespace(name: string): void; + trackCustomResource( + name: string, + namespace: string, + apiGroup: string, + apiVersion: string, + plural: string, + type?: string, + ): void; + readonly count: number; + executeCleanup(): Promise; + shouldSkipCleanup(): boolean; +} + +export function createCleanupFixture(testName: string): CleanupFixture { + const resources: TrackedResource[] = []; + const skipCleanup = + process.env.SKIP_TEST_CLEANUP === 'true' || + process.env.DEBUG === '1' || + process.env.DEBUG === 'true'; + + function getClient(): KubernetesClient | null { + try { + const configPath = path.resolve(__dirname, '..', '.test-config.json'); + let kubeConfigPath: string | undefined; + let authToken: string | undefined; + if (fs.existsSync(configPath)) { + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + kubeConfigPath = config.kubeConfigPath; + authToken = config.authToken; + } + return new KubernetesClient( + { + clusterUrl: process.env.CLUSTER_URL || '', + username: process.env.OPENSHIFT_USERNAME || 'kubeadmin', + password: process.env.BRIDGE_KUBEADMIN_PASSWORD || '', + token: authToken, + }, + kubeConfigPath, + ); + } catch (err) { + console.warn( + `[Cleanup] Failed to create K8s client: ${err instanceof Error ? err.message : err}`, + ); + return null; + } + } + + return { + track(resource: TrackedResource) { + resources.push(resource); + }, + + trackNamespace(name: string) { + resources.push({ + name, + apiGroup: '', + apiVersion: 'v1', + plural: 'namespaces', + type: 'Namespace', + }); + }, + + trackCustomResource( + name: string, + namespace: string, + apiGroup: string, + apiVersion: string, + plural: string, + type?: string, + ) { + resources.push({ + name, + namespace, + apiGroup, + apiVersion, + plural, + type: type || plural, + }); + }, + + get count() { + return resources.length; + }, + + shouldSkipCleanup() { + return skipCleanup; + }, + + async executeCleanup() { + if (skipCleanup || resources.length === 0) { + return; + } + + const client = getClient(); + if (!client) { + console.warn(`[Cleanup] No K8s client available for "${testName}"`); + return; + } + + const namespaces = resources.filter((r) => r.type === 'Namespace'); + const others = resources.filter((r) => r.type !== 'Namespace'); + + // Delete non-namespace resources first + for (const resource of others) { + try { + if (resource.apiGroup === '' && resource.namespace) { + switch (resource.type) { + case 'ConfigMap': + await client.deleteConfigMap(resource.name, resource.namespace); + break; + case 'Secret': + await client.deleteSecret(resource.name, resource.namespace); + break; + default: + console.warn( + `[Cleanup] Unhandled core resource type ${resource.type} "${resource.name}" — skipping`, + ); + break; + } + } else if (resource.namespace) { + await client.deleteCustomResource( + resource.apiGroup, + resource.apiVersion, + resource.namespace, + resource.plural, + resource.name, + ); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (!msg.includes('404') && !msg.includes('not found')) { + console.warn(`[Cleanup] Failed to delete ${resource.type} ${resource.name}: ${msg}`); + } + } + } + + // Then delete namespaces + for (const ns of namespaces) { + try { + await client.deleteNamespace(ns.name); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (!msg.includes('404') && !msg.includes('not found')) { + console.warn(`[Cleanup] Failed to delete namespace ${ns.name}: ${msg}`); + } + } + } + + // Wait for namespaces to terminate + for (const ns of namespaces) { + await client.waitForNamespaceDeleted(ns.name, 60_000).catch(() => { + console.warn(`[Cleanup] Namespace ${ns.name} did not terminate within timeout`); + }); + } + }, + }; +} diff --git a/frontend/e2e/fixtures/index.ts b/frontend/e2e/fixtures/index.ts new file mode 100644 index 00000000000..f57b43de4ea --- /dev/null +++ b/frontend/e2e/fixtures/index.ts @@ -0,0 +1,80 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { test as base, expect } from '@playwright/test'; + +import KubernetesClient from '../clients/kubernetes-client'; + +import type { CleanupFixture } from './cleanup-fixture'; +import { createCleanupFixture } from './cleanup-fixture'; + +export interface SharedTestConfig { + testNamespace: string; + authToken?: string; + kubeConfigPath?: string; +} + +type TestFixtures = { + cleanup: CleanupFixture; +}; + +type WorkerFixtures = { + testConfig: SharedTestConfig; + k8sClient: KubernetesClient; +}; + +export const test = base.extend({ + testConfig: [ + async ({}, use) => { + const configPath = path.resolve(__dirname, '..', '.test-config.json'); + let config: SharedTestConfig = { + testNamespace: 'default', + }; + if (fs.existsSync(configPath)) { + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch { + // fall through with defaults + } + } + await use(config); + }, + { scope: 'worker' }, + ], + + k8sClient: [ + async ({ testConfig }, use) => { + const client = new KubernetesClient( + { + clusterUrl: process.env.CLUSTER_URL || '', + username: process.env.OPENSHIFT_USERNAME || 'kubeadmin', + password: process.env.BRIDGE_KUBEADMIN_PASSWORD || '', + token: testConfig.authToken, + }, + testConfig.kubeConfigPath, + ); + await use(client); + }, + { scope: 'worker' }, + ], + + cleanup: async ({}, use, testInfo) => { + const testName = testInfo.titlePath.join(' > '); + const fixture = createCleanupFixture(testName); + try { + await use(fixture); + } finally { + if (!fixture.shouldSkipCleanup() && fixture.count > 0) { + try { + await fixture.executeCleanup(); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[Cleanup] Failed for "${testName}": ${msg}`); + } + } + } + }, +}); + +export { expect }; +export type { CleanupFixture }; diff --git a/frontend/e2e/global.setup.ts b/frontend/e2e/global.setup.ts new file mode 100644 index 00000000000..760a165aa6f --- /dev/null +++ b/frontend/e2e/global.setup.ts @@ -0,0 +1,168 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import type { FullConfig } from '@playwright/test'; +import { chromium } from '@playwright/test'; + +import KubernetesClient from './clients/kubernetes-client'; + +const STORAGE_STATE_DIR = path.resolve(__dirname, '.auth'); +const CONFIG_FILE = path.resolve(__dirname, '.test-config.json'); + +interface LoginOptions { + baseURL: string; + provider: string; + username: string; + password: string; + storageStatePath: string; + config: FullConfig; +} + +async function performBrowserLogin(opts: LoginOptions): Promise { + const browser = await chromium.launch({ + args: ['--ignore-certificate-errors', '--disable-dev-shm-usage', '--no-sandbox'], + }); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + const page = await context.newPage(); + + try { + await page.goto(opts.baseURL, { timeout: 90_000, waitUntil: 'domcontentloaded' }); + + // Check if auth is disabled (local dev) + const authDisabled = await page + .evaluate(() => (window as any).SERVER_FLAGS?.authDisabled) + .catch(() => false); + + if (authDisabled) { + console.log(`✅ Auth disabled — saving storage state for ${opts.provider}`); + await page.context().storageState({ path: opts.storageStatePath }); + fs.chmodSync(opts.storageStatePath, 0o600); + return; + } + + // Wait for login page + await page.waitForSelector('[data-test-id="login"], #inputUsername', { timeout: 30_000 }); + + // Click provider button if visible + const providerButton = page.getByText(opts.provider, { exact: true }); + if ((await providerButton.count()) > 0) { + await providerButton.click(); + } + + // Fill credentials and submit + await page.locator('#inputUsername').fill(opts.username); + await page.locator('#inputPassword').fill(opts.password); + await page.locator('button[type="submit"]').click(); + + // Wait for console to load (user dropdown indicates successful login) + await page.waitForSelector('[data-test="user-dropdown-toggle"]', { timeout: 60_000 }); + console.log(`✅ Logged in as ${opts.username}`); + + // Save storage state + await page.context().storageState({ path: opts.storageStatePath }); + fs.chmodSync(opts.storageStatePath, 0o600); + } finally { + await browser.close(); + } +} + +async function globalSetup(config: FullConfig) { + if (process.env.SKIP_GLOBAL_SETUP === 'true') { + console.log('⏭️ Skipping global setup (SKIP_GLOBAL_SETUP=true)'); + return; + } + + // Remove stale config from previous runs + if (fs.existsSync(CONFIG_FILE)) { + fs.unlinkSync(CONFIG_FILE); + } + + console.log('🚀 Setting up test environment...'); + + const baseURL = + process.env.WEB_CONSOLE_URL || + config.projects.find((p) => p.name !== 'auth-setup')?.use?.baseURL || + 'http://localhost:9000'; + const username = process.env.OPENSHIFT_USERNAME || 'kubeadmin'; + const password = process.env.BRIDGE_KUBEADMIN_PASSWORD || ''; + const testNamespace = `console-e2e-${Date.now()}`; + + // --- Phase 1: Auth --- + let k8sClient: KubernetesClient | null = null; + let clusterAvailable = false; + + try { + k8sClient = new KubernetesClient({ + clusterUrl: process.env.CLUSTER_URL || '', + username, + password, + }); + await k8sClient.verifyAuthentication(); + clusterAvailable = true; + console.log('✅ Cluster authentication verified'); + } catch (err) { + console.log('⚠️ No cluster access — running in local no-auth mode'); + k8sClient = null; + } + + // --- Phase 2: Cluster setup --- + if (clusterAvailable && k8sClient) { + try { + await k8sClient.createNamespace(testNamespace); + await k8sClient.waitForNamespaceReady(testNamespace); + console.log(`✅ Test namespace created: ${testNamespace}`); + } catch (err) { + console.warn(`⚠️ Failed to create test namespace: ${err}`); + } + + try { + await k8sClient.setupConsoleUserSettings(username, testNamespace); + console.log('✅ Console user settings configured (guided tour dismissed)'); + } catch { + // Non-critical — tour will be dismissed in browser if needed + } + + // Save config for worker fixtures + const testConfig = { + testNamespace, + authToken: k8sClient.getCurrentUserToken(), + kubeConfigPath: process.env.KUBECONFIG, + }; + fs.mkdirSync(path.dirname(CONFIG_FILE), { recursive: true, mode: 0o700 }); + fs.writeFileSync(CONFIG_FILE, JSON.stringify(testConfig, null, 2), { + encoding: 'utf8', + mode: 0o600, + }); + } + + // --- Phase 3: Browser login --- + fs.mkdirSync(STORAGE_STATE_DIR, { recursive: true, mode: 0o700 }); + + await performBrowserLogin({ + baseURL, + provider: 'kube:admin', + username, + password, + storageStatePath: path.join(STORAGE_STATE_DIR, 'kubeadmin.json'), + config, + }); + + // Optional: htpasswd user login + const htpasswdUser = process.env.BRIDGE_HTPASSWD_USERNAME; + const htpasswdPass = process.env.BRIDGE_HTPASSWD_PASSWORD; + if (htpasswdUser && htpasswdPass) { + const htpasswdIdp = process.env.BRIDGE_HTPASSWD_IDP || htpasswdUser; + await performBrowserLogin({ + baseURL, + provider: htpasswdIdp, + username: htpasswdUser, + password: htpasswdPass, + storageStatePath: path.join(STORAGE_STATE_DIR, 'developer.json'), + config, + }); + } + + console.log('🏁 Global setup complete'); +} + +export default globalSetup; diff --git a/frontend/e2e/global.teardown.ts b/frontend/e2e/global.teardown.ts new file mode 100644 index 00000000000..a0a414d7bde --- /dev/null +++ b/frontend/e2e/global.teardown.ts @@ -0,0 +1,62 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import KubernetesClient from './clients/kubernetes-client'; + +const CONFIG_FILE = path.resolve(__dirname, '.test-config.json'); + +async function globalTeardown() { + if (process.env.DEBUG === '1' || process.env.DEBUG === 'true') { + console.log('🐛 Debug mode — skipping teardown'); + return; + } + + console.log('🧹 Cleaning up test environment...'); + + // Read test config to get namespace + let testNamespace: string | undefined; + let kubeConfigPath: string | undefined; + let authToken: string | undefined; + + if (fs.existsSync(CONFIG_FILE)) { + try { + const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')); + testNamespace = config.testNamespace; + kubeConfigPath = config.kubeConfigPath; + authToken = config.authToken; + } catch { + // config file corrupted — skip cleanup + } + } + + if (!testNamespace) { + console.log('No test namespace to clean up'); + return; + } + + try { + const client = new KubernetesClient( + { + clusterUrl: process.env.CLUSTER_URL || '', + username: process.env.OPENSHIFT_USERNAME || 'kubeadmin', + password: process.env.BRIDGE_KUBEADMIN_PASSWORD || '', + token: authToken, + }, + kubeConfigPath, + ); + + await client.deleteNamespace(testNamespace); + const deleted = await client.waitForNamespaceDeleted(testNamespace, 120_000); + if (deleted) { + console.log(`✅ Namespace ${testNamespace} deleted`); + } else { + console.warn(`⚠️ Namespace ${testNamespace} still terminating after 120s`); + } + } catch (err) { + console.warn(`⚠️ Failed to clean up namespace ${testNamespace}: ${err}`); + } + + console.log('🏁 Teardown complete'); +} + +export default globalTeardown; diff --git a/frontend/e2e/pages/base-page.ts b/frontend/e2e/pages/base-page.ts new file mode 100644 index 00000000000..1e575eccbc8 --- /dev/null +++ b/frontend/e2e/pages/base-page.ts @@ -0,0 +1,97 @@ +import type { Locator, Page } from '@playwright/test'; + +export default abstract class BasePage { + constructor(public readonly page: Page) {} + + private readonly loadingIndicators = [ + '.pf-v6-c-spinner', + '.pf-v5-c-spinner', + '.pf-c-spinner', + '.co-m-loader', + '[data-test="loading-indicator"]', + '[data-test="loading-box"]', + '.loading-skeleton', + '.skeleton-catalog--grid', + '[class*="skeleton"]', + ]; + + protected async waitForLoadingComplete(timeoutMs = 5_000): Promise { + const loadingSelector = this.loadingIndicators.join(', '); + const loadingElements = this.page.locator(loadingSelector); + try { + const count = await loadingElements.count().catch(() => 0); + if (count > 0) { + await loadingElements.first().waitFor({ state: 'hidden', timeout: timeoutMs }); + } + } catch { + // Loading indicators may have already disappeared — continue + } + } + + protected async goTo(url: string): Promise { + await this.page.goto(url, { timeout: 90_000 }); + await this.waitForLoadingComplete(); + } + + protected locator( + selector: string, + options?: { + has?: Locator; + hasNot?: Locator; + hasNotText?: RegExp | string; + hasText?: RegExp | string; + }, + ): Locator { + return this.page.locator(selector, options); + } + + protected async robustClick( + locator: Locator, + options: { + timeout?: number; + retries?: number; + retryDelay?: number; + force?: boolean; + } = {}, + ): Promise { + const { timeout = 30_000, retries = 3, retryDelay = 1_000, force = false } = options; + let lastError: Error | null = null; + const attemptTimeout = timeout / retries; + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + await this.waitForLoadingComplete(Math.min(attemptTimeout / 4, 3_000)); + await locator.waitFor({ state: 'visible', timeout: attemptTimeout }); + await locator.scrollIntoViewIfNeeded({ timeout: attemptTimeout / 3 }); + + try { + await locator.click({ force, timeout: attemptTimeout }); + return; + } catch (clickError) { + const msg = clickError instanceof Error ? clickError.message : String(clickError); + if (attempt < retries && (msg.includes('intercept') || msg.includes('not visible'))) { + await locator.click({ force: true, timeout: attemptTimeout }); + return; + } + throw clickError; + } + } catch (error) { + lastError = error; + if (attempt < retries && retryDelay > 100) { + await this.page.waitForTimeout(retryDelay); + } + } + } + throw new Error(`robustClick failed after ${retries} attempts: ${lastError?.message}`); + } + + async navigateToTab(locator: Locator, timeoutMs = 60_000): Promise { + await this.robustClick(locator, { timeout: timeoutMs }); + await this.waitForLoadingComplete(); + } + + async clickButtonByText(buttonText: string): Promise { + const button = this.locator('button', { hasText: buttonText }); + await this.robustClick(button); + } +} diff --git a/frontend/e2e/tests/smoke/smoke-test.spec.ts b/frontend/e2e/tests/smoke/smoke-test.spec.ts new file mode 100644 index 00000000000..bcd3ff088ea --- /dev/null +++ b/frontend/e2e/tests/smoke/smoke-test.spec.ts @@ -0,0 +1,6 @@ +import { test, expect } from '../../fixtures'; + +test('console loads after setup', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('body')).not.toBeEmpty(); +}); diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json new file mode 100644 index 00000000000..58af195ae2a --- /dev/null +++ b/frontend/e2e/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2021", + "module": "esnext", + "moduleResolution": "node", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "noUnusedLocals": true, + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/frontend/package.json b/frontend/package.json index 185a694d344..b7b93f0504f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -53,6 +53,18 @@ "test-playwright-headed": "playwright test --headed", "test-playwright-debug": "playwright test --debug", "test-playwright-ui": "playwright test --ui", + "test-playwright-smoke": "playwright test --project=smoke", + "test-playwright-console": "playwright test --project=console", + "test-playwright-dev-console": "playwright test --project=dev-console", + "test-playwright-helm": "playwright test --project=helm", + "test-playwright-knative": "playwright test --project=knative", + "test-playwright-olm": "playwright test --project=olm", + "test-playwright-shipwright": "playwright test --project=shipwright", + "test-playwright-telemetry": "playwright test --project=telemetry", + "test-playwright-topology": "playwright test --project=topology", + "test-playwright-webterminal": "playwright test --project=webterminal", + "test-playwright-admin": "playwright test --project=smoke --project=console --project=dev-console --project=helm --project=knative --project=olm --project=shipwright --project=telemetry --project=topology --project=webterminal", + "test-playwright-developer": "playwright test --project=dev-console-developer --project=shipwright-developer --project=topology-developer", "test-puppeteer-csp": "yarn ts-node ./test-puppeteer-csp.ts", "cypress-merge": "mochawesome-merge ./gui_test_screenshots/cypress_report*.json > ./gui_test_screenshots/cypress.json", "cypress-generate": "marge -o ./gui_test_screenshots/ -f cypress-report -t 'OpenShift Console Cypress Test Results' -p 'OpenShift Cypress Test Results' --showPassed false --assetsDir ./gui_test_screenshots/cypress/assets ./gui_test_screenshots/cypress.json", @@ -71,7 +83,7 @@ "export-pos": "./i18n-scripts/export-pos.sh", "memsource-upload": "./i18n-scripts/memsource-upload.sh", "memsource-download": "./i18n-scripts/memsource-download.sh", - "prepare-husky": "cd .. && husky install frontend/.husky" + "prepare-husky": "[ \"${OPENSHIFT_CI:-}\" = true ] || (cd .. && husky install frontend/.husky)" }, "jest": { "moduleFileExtensions": [ @@ -111,11 +123,13 @@ "testPathIgnorePatterns": [ "/node_modules/", "/public/dist", - "/.*/integration-tests" + "/.*/integration-tests", + "/e2e" ], "modulePathIgnorePatterns": [ "/public/dist", - "/.*/integration-tests" + "/.*/integration-tests", + "/e2e" ], "testRegex": ".*\\.spec\\.(ts|tsx|js|jsx)$", "testEnvironmentOptions": { diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000000..8e074c1fed9 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,106 @@ +import * as path from 'path'; +import * as dotenv from 'dotenv'; + +dotenv.config({ path: path.resolve(__dirname, 'e2e', '.env') }); + +import { defineConfig, devices } from '@playwright/test'; + +const isCI = !!process.env.OPENSHIFT_CI || !!process.env.CI; +const isDebug = process.env.DEBUG === '1' || process.env.DEBUG === 'true'; +const baseURL = process.env.WEB_CONSOLE_URL || 'http://localhost:9000'; + +const adminStorageState = path.resolve(__dirname, 'e2e', '.auth', 'kubeadmin.json'); +const developerStorageState = path.resolve(__dirname, 'e2e', '.auth', 'developer.json'); +const hasDeveloper = !!process.env.BRIDGE_HTPASSWD_USERNAME; + +const packages = [ + 'smoke', + 'console', + 'dev-console', + 'helm', + 'knative', + 'olm', + 'shipwright', + 'telemetry', + 'topology', + 'webterminal', +]; + +// Packages that also have developer-persona tests +const devPackages = ['dev-console', 'shipwright', 'topology']; + +const chromeArgs = [ + '--ignore-certificate-errors', + '--start-maximized', + '--window-size=1920,1080', + '--disable-dev-shm-usage', + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-background-networking', + '--disable-client-side-phishing-detection', + '--disable-default-apps', + '--disable-extensions', + '--disable-popup-blocking', + '--disable-sync', + '--disable-translate', + '--no-first-run', +]; + +export default defineConfig({ + globalSetup: path.resolve(__dirname, 'e2e', 'global.setup.ts'), + globalTeardown: path.resolve(__dirname, 'e2e', 'global.teardown.ts'), + testDir: './e2e/tests', + testMatch: '**/*.spec.ts', + forbidOnly: isCI, + retries: isCI ? 1 : 0, + timeout: 120_000, + reporter: isCI + ? [ + ['dot'], + ['junit', { outputFile: path.resolve(__dirname, 'test-results', 'junit-results.xml') }], + ] + : [['list']], + + expect: { + timeout: 40_000, + }, + + use: { + testIdAttribute: 'data-test', + baseURL, + actionTimeout: 60_000, + navigationTimeout: 90_000, + trace: isCI ? 'on-first-retry' : 'retain-on-failure', + screenshot: 'only-on-failure', + video: isDebug ? 'on' : 'retain-on-failure', + viewport: { width: 1920, height: 1080 }, + ignoreHTTPSErrors: true, + launchOptions: { + args: chromeArgs, + }, + }, + + workers: process.env.WORKERS ? parseInt(process.env.WORKERS, 10) : isCI ? 1 : undefined, + + projects: [ + ...packages.map((pkg) => ({ + name: pkg, + testDir: path.resolve(__dirname, 'e2e', 'tests', pkg), + testIgnore: '**/developer/**', + use: { + ...devices['Desktop Chrome'], + storageState: adminStorageState, + }, + })), + ...(hasDeveloper + ? devPackages.map((pkg) => ({ + name: `${pkg}-developer`, + testDir: path.resolve(__dirname, 'e2e', 'tests', pkg, 'developer'), + use: { + ...devices['Desktop Chrome'], + storageState: developerStorageState, + }, + })) + : []), + ], +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 4e8a132b4e7..4296de751e2 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -43,7 +43,8 @@ "**/node_modules", "public/dist", "packages/console-dynamic-plugin-sdk/scripts", - "**/integration-tests" + "**/integration-tests", + "e2e" ], "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx", "**/*.json"] }