From 722c1820279d41c124cc0c9a69dcf80b267d2577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20August=C3=ADn?= Date: Wed, 13 May 2026 12:43:15 +0200 Subject: [PATCH 1/8] fix(common): increase GitHub login timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dominik AugustΓ­n --- src/playwright/helpers/common.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/playwright/helpers/common.ts b/src/playwright/helpers/common.ts index bee98ff..4913641 100644 --- a/src/playwright/helpers/common.ts +++ b/src/playwright/helpers/common.ts @@ -61,7 +61,7 @@ export class LoginHelper { await this.page.click('[value="Sign in"]'); await this.page.fill("#app_totp", this.getGitHub2FAOTP(userid)); - test.setTimeout(130000); + test.setTimeout(260_000); if ( (await this.uiHelper.isTextVisible( "The two-factor code you entered has already been used", From 303e8bc48d26ec8675a6b76dc9739dcc30b7ef5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20August=C3=ADn?= Date: Wed, 13 May 2026 12:53:55 +0200 Subject: [PATCH 2/8] chore: include built dist for branch reference --- .../keycloak/config/keycloak-values.yaml | 96 +++ dist/deployment/keycloak/constants.d.ts | 29 + dist/deployment/keycloak/constants.d.ts.map | 1 + dist/deployment/keycloak/constants.js | 75 +++ dist/deployment/keycloak/deployment.d.ts | 107 +++ dist/deployment/keycloak/deployment.d.ts.map | 1 + dist/deployment/keycloak/deployment.js | 485 ++++++++++++++ dist/deployment/keycloak/index.d.ts | 3 + dist/deployment/keycloak/index.d.ts.map | 1 + dist/deployment/keycloak/index.js | 1 + dist/deployment/keycloak/types.d.ts | 59 ++ dist/deployment/keycloak/types.d.ts.map | 1 + dist/deployment/keycloak/types.js | 1 + dist/deployment/orchestrator/index.d.ts | 3 + dist/deployment/orchestrator/index.d.ts.map | 1 + dist/deployment/orchestrator/index.js | 7 + .../orchestrator/install-orchestrator.sh | 486 ++++++++++++++ .../rhdh/config/auth/github/app-config.yaml | 17 + .../rhdh/config/auth/github/secrets.yaml | 12 + .../rhdh/config/auth/guest/app-config.yaml | 5 + .../rhdh/config/auth/keycloak/app-config.yaml | 19 + .../config/auth/keycloak/dynamic-plugins.yaml | 3 + .../rhdh/config/auth/keycloak/secrets.yaml | 12 + .../rhdh/config/common/app-config-rhdh.yaml | 6 + .../rhdh/config/common/dynamic-plugins.yaml | 5 + .../rhdh/config/common/rhdh-secrets.yaml | 7 + .../rhdh/config/helm/value_file.yaml | 7 + .../rhdh/config/operator/subscription.yaml | 21 + dist/deployment/rhdh/constants.d.ts | 21 + dist/deployment/rhdh/constants.d.ts.map | 1 + dist/deployment/rhdh/constants.js | 33 + dist/deployment/rhdh/deployment.d.ts | 59 ++ dist/deployment/rhdh/deployment.d.ts.map | 1 + dist/deployment/rhdh/deployment.js | 412 ++++++++++++ dist/deployment/rhdh/deployment.test.d.ts | 2 + dist/deployment/rhdh/deployment.test.d.ts.map | 1 + dist/deployment/rhdh/deployment.test.js | 41 ++ dist/deployment/rhdh/index.d.ts | 2 + dist/deployment/rhdh/index.d.ts.map | 1 + dist/deployment/rhdh/index.js | 1 + dist/deployment/rhdh/types.d.ts | 33 + dist/deployment/rhdh/types.d.ts.map | 1 + dist/deployment/rhdh/types.js | 1 + dist/eslint/base.config.d.ts | 10 + dist/eslint/base.config.d.ts.map | 1 + dist/eslint/base.config.js | 220 +++++++ dist/playwright/base-config.d.ts | 14 + dist/playwright/base-config.d.ts.map | 1 + dist/playwright/base-config.js | 49 ++ dist/playwright/fixtures/test.d.ts | 17 + dist/playwright/fixtures/test.d.ts.map | 1 + dist/playwright/fixtures/test.js | 63 ++ dist/playwright/global-setup.d.ts | 7 + dist/playwright/global-setup.d.ts.map | 1 + dist/playwright/global-setup.js | 87 +++ dist/playwright/helpers/accessibility.d.ts | 13 + .../playwright/helpers/accessibility.d.ts.map | 1 + dist/playwright/helpers/accessibility.js | 24 + dist/playwright/helpers/api-endpoints.d.ts | 13 + .../playwright/helpers/api-endpoints.d.ts.map | 1 + dist/playwright/helpers/api-endpoints.js | 17 + dist/playwright/helpers/api-helper.d.ts | 77 +++ dist/playwright/helpers/api-helper.d.ts.map | 1 + dist/playwright/helpers/api-helper.js | 295 +++++++++ dist/playwright/helpers/auth-api-helper.d.ts | 7 + .../helpers/auth-api-helper.d.ts.map | 1 + dist/playwright/helpers/auth-api-helper.js | 31 + dist/playwright/helpers/common.d.ts | 31 + dist/playwright/helpers/common.d.ts.map | 1 + dist/playwright/helpers/common.js | 362 ++++++++++ dist/playwright/helpers/index.d.ts | 7 + dist/playwright/helpers/index.d.ts.map | 1 + dist/playwright/helpers/index.js | 6 + dist/playwright/helpers/navbar.d.ts | 2 + dist/playwright/helpers/navbar.d.ts.map | 1 + dist/playwright/helpers/navbar.js | 1 + dist/playwright/helpers/rbac-api-helper.d.ts | 43 ++ .../helpers/rbac-api-helper.d.ts.map | 1 + dist/playwright/helpers/rbac-api-helper.js | 80 +++ dist/playwright/helpers/ui-helper.d.ts | 113 ++++ dist/playwright/helpers/ui-helper.d.ts.map | 1 + dist/playwright/helpers/ui-helper.js | 455 +++++++++++++ dist/playwright/page-objects/global-obj.d.ts | 25 + .../page-objects/global-obj.d.ts.map | 1 + dist/playwright/page-objects/global-obj.js | 24 + dist/playwright/page-objects/page-obj.d.ts | 41 ++ .../playwright/page-objects/page-obj.d.ts.map | 1 + dist/playwright/page-objects/page-obj.js | 40 ++ dist/playwright/pages/catalog-import.d.ts | 31 + dist/playwright/pages/catalog-import.d.ts.map | 1 + dist/playwright/pages/catalog-import.js | 65 ++ dist/playwright/pages/catalog.d.ts | 14 + dist/playwright/pages/catalog.d.ts.map | 1 + dist/playwright/pages/catalog.js | 37 ++ dist/playwright/pages/extensions.d.ts | 38 ++ dist/playwright/pages/extensions.d.ts.map | 1 + dist/playwright/pages/extensions.js | 110 ++++ dist/playwright/pages/home-page.d.ts | 10 + dist/playwright/pages/home-page.d.ts.map | 1 + dist/playwright/pages/home-page.js | 46 ++ dist/playwright/pages/index.d.ts | 7 + dist/playwright/pages/index.d.ts.map | 1 + dist/playwright/pages/index.js | 6 + dist/playwright/pages/notifications.d.ts | 24 + dist/playwright/pages/notifications.d.ts.map | 1 + dist/playwright/pages/notifications.js | 112 ++++ dist/playwright/pages/orchestrator.d.ts | 23 + dist/playwright/pages/orchestrator.d.ts.map | 1 + dist/playwright/pages/orchestrator.js | 248 +++++++ dist/playwright/pages/workflows.d.ts | 3 + dist/playwright/pages/workflows.d.ts.map | 1 + dist/playwright/pages/workflows.js | 1 + dist/playwright/run-once.d.ts | 11 + dist/playwright/run-once.d.ts.map | 1 + dist/playwright/run-once.js | 40 ++ dist/playwright/teardown-namespaces.d.ts | 11 + dist/playwright/teardown-namespaces.d.ts.map | 1 + dist/playwright/teardown-namespaces.js | 34 + dist/playwright/teardown-reporter.d.ts | 29 + dist/playwright/teardown-reporter.d.ts.map | 1 + dist/playwright/teardown-reporter.js | 105 +++ dist/utils/bash.d.ts | 9 + dist/utils/bash.d.ts.map | 1 + dist/utils/bash.js | 25 + dist/utils/common.d.ts | 4 + dist/utils/common.d.ts.map | 1 + dist/utils/common.js | 16 + dist/utils/index.d.ts | 6 + dist/utils/index.d.ts.map | 1 + dist/utils/index.js | 5 + dist/utils/kubernetes-client.d.ts | 106 +++ dist/utils/kubernetes-client.d.ts.map | 1 + dist/utils/kubernetes-client.js | 623 ++++++++++++++++++ dist/utils/merge-yamls.d.ts | 53 ++ dist/utils/merge-yamls.d.ts.map | 1 + dist/utils/merge-yamls.js | 107 +++ dist/utils/merge-yamls.test.d.ts | 2 + dist/utils/merge-yamls.test.d.ts.map | 1 + dist/utils/merge-yamls.test.js | 54 ++ dist/utils/plugin-metadata.d.ts | 96 +++ dist/utils/plugin-metadata.d.ts.map | 1 + dist/utils/plugin-metadata.js | 364 ++++++++++ dist/utils/tests/helpers.d.ts | 26 + dist/utils/tests/helpers.d.ts.map | 1 + dist/utils/tests/helpers.js | 84 +++ .../tests/plugin-metadata.fixtures.test.d.ts | 2 + .../plugin-metadata.fixtures.test.d.ts.map | 1 + .../tests/plugin-metadata.fixtures.test.js | 563 ++++++++++++++++ .../tests/plugin-metadata.nightly.test.d.ts | 2 + .../plugin-metadata.nightly.test.d.ts.map | 1 + .../tests/plugin-metadata.nightly.test.js | 206 ++++++ dist/utils/tests/plugin-metadata.pr.test.d.ts | 2 + .../tests/plugin-metadata.pr.test.d.ts.map | 1 + dist/utils/tests/plugin-metadata.pr.test.js | 351 ++++++++++ dist/utils/tests/plugin-metadata.test.d.ts | 2 + .../utils/tests/plugin-metadata.test.d.ts.map | 1 + dist/utils/tests/plugin-metadata.test.js | 156 +++++ dist/utils/vault.d.ts | 16 + dist/utils/vault.d.ts.map | 1 + dist/utils/vault.js | 94 +++ dist/utils/workspace-paths.d.ts | 43 ++ dist/utils/workspace-paths.d.ts.map | 1 + dist/utils/workspace-paths.js | 65 ++ 163 files changed, 8382 insertions(+) create mode 100644 dist/deployment/keycloak/config/keycloak-values.yaml create mode 100644 dist/deployment/keycloak/constants.d.ts create mode 100644 dist/deployment/keycloak/constants.d.ts.map create mode 100644 dist/deployment/keycloak/constants.js create mode 100644 dist/deployment/keycloak/deployment.d.ts create mode 100644 dist/deployment/keycloak/deployment.d.ts.map create mode 100644 dist/deployment/keycloak/deployment.js create mode 100644 dist/deployment/keycloak/index.d.ts create mode 100644 dist/deployment/keycloak/index.d.ts.map create mode 100644 dist/deployment/keycloak/index.js create mode 100644 dist/deployment/keycloak/types.d.ts create mode 100644 dist/deployment/keycloak/types.d.ts.map create mode 100644 dist/deployment/keycloak/types.js create mode 100644 dist/deployment/orchestrator/index.d.ts create mode 100644 dist/deployment/orchestrator/index.d.ts.map create mode 100644 dist/deployment/orchestrator/index.js create mode 100755 dist/deployment/orchestrator/install-orchestrator.sh create mode 100644 dist/deployment/rhdh/config/auth/github/app-config.yaml create mode 100644 dist/deployment/rhdh/config/auth/github/secrets.yaml create mode 100644 dist/deployment/rhdh/config/auth/guest/app-config.yaml create mode 100644 dist/deployment/rhdh/config/auth/keycloak/app-config.yaml create mode 100644 dist/deployment/rhdh/config/auth/keycloak/dynamic-plugins.yaml create mode 100644 dist/deployment/rhdh/config/auth/keycloak/secrets.yaml create mode 100644 dist/deployment/rhdh/config/common/app-config-rhdh.yaml create mode 100644 dist/deployment/rhdh/config/common/dynamic-plugins.yaml create mode 100644 dist/deployment/rhdh/config/common/rhdh-secrets.yaml create mode 100644 dist/deployment/rhdh/config/helm/value_file.yaml create mode 100644 dist/deployment/rhdh/config/operator/subscription.yaml create mode 100644 dist/deployment/rhdh/constants.d.ts create mode 100644 dist/deployment/rhdh/constants.d.ts.map create mode 100644 dist/deployment/rhdh/constants.js create mode 100644 dist/deployment/rhdh/deployment.d.ts create mode 100644 dist/deployment/rhdh/deployment.d.ts.map create mode 100644 dist/deployment/rhdh/deployment.js create mode 100644 dist/deployment/rhdh/deployment.test.d.ts create mode 100644 dist/deployment/rhdh/deployment.test.d.ts.map create mode 100644 dist/deployment/rhdh/deployment.test.js create mode 100644 dist/deployment/rhdh/index.d.ts create mode 100644 dist/deployment/rhdh/index.d.ts.map create mode 100644 dist/deployment/rhdh/index.js create mode 100644 dist/deployment/rhdh/types.d.ts create mode 100644 dist/deployment/rhdh/types.d.ts.map create mode 100644 dist/deployment/rhdh/types.js create mode 100644 dist/eslint/base.config.d.ts create mode 100644 dist/eslint/base.config.d.ts.map create mode 100644 dist/eslint/base.config.js create mode 100644 dist/playwright/base-config.d.ts create mode 100644 dist/playwright/base-config.d.ts.map create mode 100644 dist/playwright/base-config.js create mode 100644 dist/playwright/fixtures/test.d.ts create mode 100644 dist/playwright/fixtures/test.d.ts.map create mode 100644 dist/playwright/fixtures/test.js create mode 100644 dist/playwright/global-setup.d.ts create mode 100644 dist/playwright/global-setup.d.ts.map create mode 100644 dist/playwright/global-setup.js create mode 100644 dist/playwright/helpers/accessibility.d.ts create mode 100644 dist/playwright/helpers/accessibility.d.ts.map create mode 100644 dist/playwright/helpers/accessibility.js create mode 100644 dist/playwright/helpers/api-endpoints.d.ts create mode 100644 dist/playwright/helpers/api-endpoints.d.ts.map create mode 100644 dist/playwright/helpers/api-endpoints.js create mode 100644 dist/playwright/helpers/api-helper.d.ts create mode 100644 dist/playwright/helpers/api-helper.d.ts.map create mode 100644 dist/playwright/helpers/api-helper.js create mode 100644 dist/playwright/helpers/auth-api-helper.d.ts create mode 100644 dist/playwright/helpers/auth-api-helper.d.ts.map create mode 100644 dist/playwright/helpers/auth-api-helper.js create mode 100644 dist/playwright/helpers/common.d.ts create mode 100644 dist/playwright/helpers/common.d.ts.map create mode 100644 dist/playwright/helpers/common.js create mode 100644 dist/playwright/helpers/index.d.ts create mode 100644 dist/playwright/helpers/index.d.ts.map create mode 100644 dist/playwright/helpers/index.js create mode 100644 dist/playwright/helpers/navbar.d.ts create mode 100644 dist/playwright/helpers/navbar.d.ts.map create mode 100644 dist/playwright/helpers/navbar.js create mode 100644 dist/playwright/helpers/rbac-api-helper.d.ts create mode 100644 dist/playwright/helpers/rbac-api-helper.d.ts.map create mode 100644 dist/playwright/helpers/rbac-api-helper.js create mode 100644 dist/playwright/helpers/ui-helper.d.ts create mode 100644 dist/playwright/helpers/ui-helper.d.ts.map create mode 100644 dist/playwright/helpers/ui-helper.js create mode 100644 dist/playwright/page-objects/global-obj.d.ts create mode 100644 dist/playwright/page-objects/global-obj.d.ts.map create mode 100644 dist/playwright/page-objects/global-obj.js create mode 100644 dist/playwright/page-objects/page-obj.d.ts create mode 100644 dist/playwright/page-objects/page-obj.d.ts.map create mode 100644 dist/playwright/page-objects/page-obj.js create mode 100644 dist/playwright/pages/catalog-import.d.ts create mode 100644 dist/playwright/pages/catalog-import.d.ts.map create mode 100644 dist/playwright/pages/catalog-import.js create mode 100644 dist/playwright/pages/catalog.d.ts create mode 100644 dist/playwright/pages/catalog.d.ts.map create mode 100644 dist/playwright/pages/catalog.js create mode 100644 dist/playwright/pages/extensions.d.ts create mode 100644 dist/playwright/pages/extensions.d.ts.map create mode 100644 dist/playwright/pages/extensions.js create mode 100644 dist/playwright/pages/home-page.d.ts create mode 100644 dist/playwright/pages/home-page.d.ts.map create mode 100644 dist/playwright/pages/home-page.js create mode 100644 dist/playwright/pages/index.d.ts create mode 100644 dist/playwright/pages/index.d.ts.map create mode 100644 dist/playwright/pages/index.js create mode 100644 dist/playwright/pages/notifications.d.ts create mode 100644 dist/playwright/pages/notifications.d.ts.map create mode 100644 dist/playwright/pages/notifications.js create mode 100644 dist/playwright/pages/orchestrator.d.ts create mode 100644 dist/playwright/pages/orchestrator.d.ts.map create mode 100644 dist/playwright/pages/orchestrator.js create mode 100644 dist/playwright/pages/workflows.d.ts create mode 100644 dist/playwright/pages/workflows.d.ts.map create mode 100644 dist/playwright/pages/workflows.js create mode 100644 dist/playwright/run-once.d.ts create mode 100644 dist/playwright/run-once.d.ts.map create mode 100644 dist/playwright/run-once.js create mode 100644 dist/playwright/teardown-namespaces.d.ts create mode 100644 dist/playwright/teardown-namespaces.d.ts.map create mode 100644 dist/playwright/teardown-namespaces.js create mode 100644 dist/playwright/teardown-reporter.d.ts create mode 100644 dist/playwright/teardown-reporter.d.ts.map create mode 100644 dist/playwright/teardown-reporter.js create mode 100644 dist/utils/bash.d.ts create mode 100644 dist/utils/bash.d.ts.map create mode 100644 dist/utils/bash.js create mode 100644 dist/utils/common.d.ts create mode 100644 dist/utils/common.d.ts.map create mode 100644 dist/utils/common.js create mode 100644 dist/utils/index.d.ts create mode 100644 dist/utils/index.d.ts.map create mode 100644 dist/utils/index.js create mode 100644 dist/utils/kubernetes-client.d.ts create mode 100644 dist/utils/kubernetes-client.d.ts.map create mode 100644 dist/utils/kubernetes-client.js create mode 100644 dist/utils/merge-yamls.d.ts create mode 100644 dist/utils/merge-yamls.d.ts.map create mode 100644 dist/utils/merge-yamls.js create mode 100644 dist/utils/merge-yamls.test.d.ts create mode 100644 dist/utils/merge-yamls.test.d.ts.map create mode 100644 dist/utils/merge-yamls.test.js create mode 100644 dist/utils/plugin-metadata.d.ts create mode 100644 dist/utils/plugin-metadata.d.ts.map create mode 100644 dist/utils/plugin-metadata.js create mode 100644 dist/utils/tests/helpers.d.ts create mode 100644 dist/utils/tests/helpers.d.ts.map create mode 100644 dist/utils/tests/helpers.js create mode 100644 dist/utils/tests/plugin-metadata.fixtures.test.d.ts create mode 100644 dist/utils/tests/plugin-metadata.fixtures.test.d.ts.map create mode 100644 dist/utils/tests/plugin-metadata.fixtures.test.js create mode 100644 dist/utils/tests/plugin-metadata.nightly.test.d.ts create mode 100644 dist/utils/tests/plugin-metadata.nightly.test.d.ts.map create mode 100644 dist/utils/tests/plugin-metadata.nightly.test.js create mode 100644 dist/utils/tests/plugin-metadata.pr.test.d.ts create mode 100644 dist/utils/tests/plugin-metadata.pr.test.d.ts.map create mode 100644 dist/utils/tests/plugin-metadata.pr.test.js create mode 100644 dist/utils/tests/plugin-metadata.test.d.ts create mode 100644 dist/utils/tests/plugin-metadata.test.d.ts.map create mode 100644 dist/utils/tests/plugin-metadata.test.js create mode 100644 dist/utils/vault.d.ts create mode 100644 dist/utils/vault.d.ts.map create mode 100644 dist/utils/vault.js create mode 100644 dist/utils/workspace-paths.d.ts create mode 100644 dist/utils/workspace-paths.d.ts.map create mode 100644 dist/utils/workspace-paths.js diff --git a/dist/deployment/keycloak/config/keycloak-values.yaml b/dist/deployment/keycloak/config/keycloak-values.yaml new file mode 100644 index 0000000..36229a7 --- /dev/null +++ b/dist/deployment/keycloak/config/keycloak-values.yaml @@ -0,0 +1,96 @@ +global: + security: + allowInsecureImages: true + +replicaCount: 1 + +# Use Bitnami legacy repository (Bitnami images moved to bitnamilegacy as of Aug 2025) +# Note: Legacy images are not updated/maintained. Consider migrating to official Keycloak image for long-term. +image: + registry: docker.io + repository: bitnamilegacy/keycloak + tag: "26.3.3-debian-12-r0" + pullPolicy: IfNotPresent + +auth: + adminUser: admin + adminPassword: admin123 + +service: + type: ClusterIP + port: 8080 + +# OpenShift Route configuration +route: + enabled: true + host: "" # Will be auto-generated by OpenShift + tls: + enabled: false + +ingress: + enabled: false + +postgresql: + enabled: true + image: + registry: docker.io + repository: bitnamilegacy/postgresql + tag: "17.6.0-debian-12-r4" + pullPolicy: IfNotPresent + auth: + postgresPassword: postgres123 + username: keycloak + password: keycloak123 + database: keycloak + primary: + resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 100m + memory: 256Mi + persistence: + enabled: true + size: 1Gi + +resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 100m + memory: 256Mi + +extraEnvVars: + - name: KEYCLOAK_ADMIN + value: admin + - name: KEYCLOAK_ADMIN_PASSWORD + value: admin123 + - name: KC_HOSTNAME_STRICT + value: "false" + - name: KC_HOSTNAME_STRICT_HTTPS + value: "false" + - name: KC_HTTP_ENABLED + value: "true" + - name: KC_PROXY + value: "edge" + - name: JAVA_OPTS_APPEND + value: "-Djava.net.preferIPv4Stack=true -Xms256m -Xmx512m" + +# Increase probe timeouts for slower startup on resource-constrained clusters +livenessProbe: + enabled: true + initialDelaySeconds: 120 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + successThreshold: 1 + +readinessProbe: + enabled: true + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + successThreshold: 1 diff --git a/dist/deployment/keycloak/constants.d.ts b/dist/deployment/keycloak/constants.d.ts new file mode 100644 index 0000000..3a27dbe --- /dev/null +++ b/dist/deployment/keycloak/constants.d.ts @@ -0,0 +1,29 @@ +import type { KeycloakClientConfig } from "./types.js"; +export declare const DEFAULT_KEYCLOAK_CONFIG: { + namespace: string; + releaseName: string; + adminUser: string; + adminPassword: string; + realm: string; +}; +export declare const DEFAULT_CONFIG_PATHS: { + valuesFile: string; +}; +export declare const BITNAMI_CHART_REPO = "https://charts.bitnami.com/bitnami"; +export declare const BITNAMI_CHART_NAME = "bitnami/keycloak"; +export declare const DEFAULT_RHDH_CLIENT: KeycloakClientConfig; +export declare const DEFAULT_GROUPS: { + name: string; +}[]; +export declare const DEFAULT_USERS: { + username: string; + email: string; + firstName: string; + lastName: string; + enabled: boolean; + emailVerified: boolean; + password: string; + groups: string[]; +}[]; +export declare const SERVICE_ACCOUNT_ROLES: string[]; +//# sourceMappingURL=constants.d.ts.map \ No newline at end of file diff --git a/dist/deployment/keycloak/constants.d.ts.map b/dist/deployment/keycloak/constants.d.ts.map new file mode 100644 index 0000000..8fb628d --- /dev/null +++ b/dist/deployment/keycloak/constants.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../src/deployment/keycloak/constants.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAKvD,eAAO,MAAM,uBAAuB;;;;;;CAMnC,CAAC;AAEF,eAAO,MAAM,oBAAoB;;CAKhC,CAAC;AAEF,eAAO,MAAM,kBAAkB,uCAAuC,CAAC;AACvE,eAAO,MAAM,kBAAkB,qBAAqB,CAAC;AAErD,eAAO,MAAM,mBAAmB,EAAE,oBA0BjC,CAAC;AAEF,eAAO,MAAM,cAAc;;GAI1B,CAAC;AAEF,eAAO,MAAM,aAAa;;;;;;;;;GAqBzB,CAAC;AAGF,eAAO,MAAM,qBAAqB,UAIjC,CAAC"} \ No newline at end of file diff --git a/dist/deployment/keycloak/constants.js b/dist/deployment/keycloak/constants.js new file mode 100644 index 0000000..5621a75 --- /dev/null +++ b/dist/deployment/keycloak/constants.js @@ -0,0 +1,75 @@ +import path from "path"; +// Navigate from dist/deployment/keycloak/ to package root +const PACKAGE_ROOT = path.resolve(import.meta.dirname, "../../.."); +export const DEFAULT_KEYCLOAK_CONFIG = { + namespace: "rhdh-keycloak", + releaseName: "keycloak", + adminUser: "admin", + adminPassword: "admin123", + realm: "rhdh", +}; +export const DEFAULT_CONFIG_PATHS = { + valuesFile: path.join(PACKAGE_ROOT, "dist/deployment/keycloak/config/keycloak-values.yaml"), +}; +export const BITNAMI_CHART_REPO = "https://charts.bitnami.com/bitnami"; +export const BITNAMI_CHART_NAME = "bitnami/keycloak"; +export const DEFAULT_RHDH_CLIENT = { + clientId: "rhdh-client", + clientSecret: "rhdh-client-secret", + name: "RHDH Client", + redirectUris: ["*"], + webOrigins: ["*"], + standardFlowEnabled: true, + implicitFlowEnabled: true, + directAccessGrantsEnabled: true, + serviceAccountsEnabled: true, + authorizationServicesEnabled: true, + publicClient: false, + defaultClientScopes: [ + "service_account", + "web-origins", + "roles", + "profile", + "basic", + "email", + ], + optionalClientScopes: [ + "address", + "phone", + "offline_access", + "microprofile-jwt", + ], +}; +export const DEFAULT_GROUPS = [ + { name: "developers" }, + { name: "admins" }, + { name: "viewers" }, +]; +export const DEFAULT_USERS = [ + { + username: "test1", + email: "test1@example.com", + firstName: "Test", + lastName: "User1", + enabled: true, + emailVerified: true, + password: "test1@123", + groups: ["developers"], + }, + { + username: "test2", + email: "test2@example.com", + firstName: "Test", + lastName: "User2", + enabled: true, + emailVerified: true, + password: "test2@123", + groups: ["developers"], + }, +]; +// Service account roles required for RHDH integration +export const SERVICE_ACCOUNT_ROLES = [ + "view-authorization", + "manage-authorization", + "view-users", +]; diff --git a/dist/deployment/keycloak/deployment.d.ts b/dist/deployment/keycloak/deployment.d.ts new file mode 100644 index 0000000..a8f0b76 --- /dev/null +++ b/dist/deployment/keycloak/deployment.d.ts @@ -0,0 +1,107 @@ +import { KubernetesClientHelper } from "../../utils/kubernetes-client.js"; +import type { KeycloakDeploymentOptions, KeycloakDeploymentConfig, KeycloakClientConfig, KeycloakUserConfig, KeycloakGroupConfig, KeycloakRealmConfig, KeycloakConnectionConfig } from "./types.js"; +export declare class KeycloakHelper { + k8sClient: KubernetesClientHelper; + deploymentConfig: KeycloakDeploymentConfig; + keycloakUrl: string; + realm: string; + clientId: string; + clientSecret: string; + private _adminClient; + constructor(options?: KeycloakDeploymentOptions); + /** + * Deploy Keycloak using Helm and configure it for RHDH. + */ + deploy(): Promise; + /** + * Check if Keycloak is already running + */ + isRunning(): Promise; + /** + * Configure Keycloak with realm, client, groups, and users for RHDH + */ + configureForRHDH(options?: { + realm?: string; + client?: Partial; + groups?: KeycloakGroupConfig[]; + users?: KeycloakUserConfig[]; + }): Promise; + /** + * Connect to an existing Keycloak instance + */ + connect(config: KeycloakConnectionConfig): Promise; + /** + * Create a new realm + */ + createRealm(config: KeycloakRealmConfig): Promise; + /** + * Create a new client in a realm + */ + createClient(realm: string, config: KeycloakClientConfig): Promise; + /** + * Create a group in a realm + */ + createGroup(realm: string, config: KeycloakGroupConfig): Promise; + /** + * Create a user in a realm with optional group membership + */ + createUser(realm: string, config: KeycloakUserConfig): Promise; + /** + * Create users and groups in a realm. + */ + createUsersAndGroups(realm: string, options: { + users?: KeycloakUserConfig[]; + groups?: KeycloakGroupConfig[]; + }): Promise; + /** + * Get all users in a realm + */ + getUsers(realm: string): Promise; + /** + * Get all groups in a realm + */ + getGroups(realm: string): Promise; + /** + * Get groups for a user in a realm (user resolved by username). + */ + getGroupsOfUser(realm: string, username: string): Promise; + /** + * Delete a user from a realm + */ + deleteUser(realm: string, username: string): Promise; + /** + * Delete a group from a realm + */ + deleteGroup(realm: string, groupName: string): Promise; + /** + * Delete users and groups from a realm. + */ + deleteUsersAndGroups(realm: string, options: { + users?: Array; + groups?: Array; + }): Promise; + /** + * Delete a realm + */ + deleteRealm(realm: string): Promise; + /** + * Teardown Keycloak deployment + */ + teardown(): Promise; + /** + * Wait for Keycloak to be ready + */ + waitUntilReady(timeout?: number): Promise; + private _buildDeploymentConfig; + private _deployWithHelm; + private _createRoute; + getRouteLocation(): Promise; + private _waitForKeycloak; + private _initializeAdminClient; + private _ensureAdminClient; + private _assignServiceAccountRoles; + private _addUserToGroup; + private _isConflictError; + private _log; +} +//# sourceMappingURL=deployment.d.ts.map \ No newline at end of file diff --git a/dist/deployment/keycloak/deployment.d.ts.map b/dist/deployment/keycloak/deployment.d.ts.map new file mode 100644 index 0000000..1330d19 --- /dev/null +++ b/dist/deployment/keycloak/deployment.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"deployment.d.ts","sourceRoot":"","sources":["../../../src/deployment/keycloak/deployment.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,sBAAsB,EAAE,MAAM,kCAAkC,CAAC;AAY1E,OAAO,KAAK,EACV,yBAAyB,EACzB,wBAAwB,EACxB,oBAAoB,EACpB,kBAAkB,EAClB,mBAAmB,EACnB,mBAAmB,EACnB,wBAAwB,EACzB,MAAM,YAAY,CAAC;AAEpB,qBAAa,cAAc;IAClB,SAAS,yBAAgC;IACzC,gBAAgB,EAAE,wBAAwB,CAAC;IAC3C,WAAW,EAAE,MAAM,CAAM;IACzB,KAAK,EAAE,MAAM,CAAM;IACnB,QAAQ,EAAE,MAAM,CAAM;IACtB,YAAY,EAAE,MAAM,CAAM;IACjC,OAAO,CAAC,YAAY,CAAoC;gBAE5C,OAAO,GAAE,yBAA8B;IAInD;;OAEG;IACG,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAa7B;;OAEG;IACG,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;IAUnC;;OAEG;IACG,gBAAgB,CAAC,OAAO,CAAC,EAAE;QAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,OAAO,CAAC,oBAAoB,CAAC,CAAC;QACvC,MAAM,CAAC,EAAE,mBAAmB,EAAE,CAAC;QAC/B,KAAK,CAAC,EAAE,kBAAkB,EAAE,CAAC;KAC9B,GAAG,OAAO,CAAC,IAAI,CAAC;IAsCjB;;OAEG;IACG,OAAO,CAAC,MAAM,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC;IAuB9D;;OAEG;IACG,WAAW,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAmB7D;;OAEG;IACG,YAAY,CAChB,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,oBAAoB,GAC3B,OAAO,CAAC,IAAI,CAAC;IAqChB;;OAEG;IACG,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAkB5E;;OAEG;IACG,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC;IA8C1E;;OAEG;IACG,oBAAoB,CACxB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE;QACP,KAAK,CAAC,EAAE,kBAAkB,EAAE,CAAC;QAC7B,MAAM,CAAC,EAAE,mBAAmB,EAAE,CAAC;KAChC,GACA,OAAO,CAAC,IAAI,CAAC;IAahB;;OAEG;IACG,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAe5D;;OAEG;IACG,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAQ9D;;OAEG;IACG,eAAe,CACnB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAejC;;OAEG;IACG,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBhE;;OAEG;IACG,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBlE;;OAEG;IACG,oBAAoB,CACxB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE;QACP,KAAK,CAAC,EAAE,KAAK,CAAC,kBAAkB,GAAG,MAAM,CAAC,CAAC;QAC3C,MAAM,CAAC,EAAE,KAAK,CAAC,mBAAmB,GAAG,MAAM,CAAC,CAAC;KAC9C,GACA,OAAO,CAAC,IAAI,CAAC;IAkBhB;;OAEG;IACG,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAW/C;;OAEG;IACG,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAK/B;;OAEG;IACG,cAAc,CAAC,OAAO,GAAE,MAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAY1D,OAAO,CAAC,sBAAsB;YAahB,eAAe;YAWf,YAAY;IAwBpB,gBAAgB,IAAI,OAAO,CAAC,MAAM,CAAC;YAO3B,gBAAgB;YAoBhB,sBAAsB;YActB,kBAAkB;YAQlB,0BAA0B;YAmD1B,eAAe;IAqB7B,OAAO,CAAC,gBAAgB;IAKxB,OAAO,CAAC,IAAI;CAGb"} \ No newline at end of file diff --git a/dist/deployment/keycloak/deployment.js b/dist/deployment/keycloak/deployment.js new file mode 100644 index 0000000..70b6bb2 --- /dev/null +++ b/dist/deployment/keycloak/deployment.js @@ -0,0 +1,485 @@ +import KeycloakAdminClient from "@keycloak/keycloak-admin-client"; +import { KubernetesClientHelper } from "../../utils/kubernetes-client.js"; +import { $, runQuietUnlessFailure } from "../../utils/bash.js"; +import { DEFAULT_KEYCLOAK_CONFIG, BITNAMI_CHART_REPO, BITNAMI_CHART_NAME, DEFAULT_CONFIG_PATHS, DEFAULT_RHDH_CLIENT, SERVICE_ACCOUNT_ROLES, DEFAULT_USERS, DEFAULT_GROUPS, } from "./constants.js"; +export class KeycloakHelper { + k8sClient = new KubernetesClientHelper(); + deploymentConfig; + keycloakUrl = ""; + realm = ""; + clientId = ""; + clientSecret = ""; + _adminClient = null; + constructor(options = {}) { + this.deploymentConfig = this._buildDeploymentConfig(options); + } + /** + * Deploy Keycloak using Helm and configure it for RHDH. + */ + async deploy() { + this._log("Starting Keycloak deployment..."); + await this.k8sClient.createNamespaceIfNotExists(this.deploymentConfig.namespace); + await this._deployWithHelm(); + await this._createRoute(); + await this._waitForKeycloak(); + await this._initializeAdminClient(); + } + /** + * Check if Keycloak is already running + */ + async isRunning() { + try { + this.keycloakUrl = await this.getRouteLocation(); + const response = await fetch(`${this.keycloakUrl}/realms/master`); + return response.ok; + } + catch { + return false; + } + } + /** + * Configure Keycloak with realm, client, groups, and users for RHDH + */ + async configureForRHDH(options) { + this._log("Configuring Keycloak for RHDH..."); + await this._ensureAdminClient(); + const realmName = options?.realm ?? DEFAULT_KEYCLOAK_CONFIG.realm; + // Create realm + await this.createRealm({ realm: realmName, enabled: true }); + // Create client + const clientConfig = { + ...DEFAULT_RHDH_CLIENT, + ...options?.client, + }; + await this.createClient(realmName, clientConfig); + // Store realm and client info for external access + this.realm = realmName; + this.clientId = clientConfig.clientId; + this.clientSecret = clientConfig.clientSecret; + // Assign service account roles + await this._assignServiceAccountRoles(realmName, clientConfig.clientId); + // Create groups + const groups = options?.groups ?? DEFAULT_GROUPS; + for (const group of groups) { + await this.createGroup(realmName, group); + } + // Create users + const users = options?.users ?? DEFAULT_USERS; + for (const user of users) { + await this.createUser(realmName, user); + } + } + /** + * Connect to an existing Keycloak instance + */ + async connect(config) { + this.keycloakUrl = config.baseUrl; + this._adminClient = new KeycloakAdminClient({ + baseUrl: config.baseUrl, + realmName: config.realm ?? "master", + }); + if (config.username && config.password) { + await this._adminClient.auth({ + username: config.username, + password: config.password, + grantType: "password", + clientId: config.clientId ?? "admin-cli", + }); + } + else if (config.clientId && config.clientSecret) { + await this._adminClient.auth({ + grantType: "client_credentials", + clientId: config.clientId, + clientSecret: config.clientSecret, + }); + } + } + /** + * Create a new realm + */ + async createRealm(config) { + await this._ensureAdminClient(); + try { + await this._adminClient.realms.create({ + realm: config.realm, + displayName: config.displayName ?? config.realm, + enabled: config.enabled ?? true, + }); + this._log(`Created realm: ${config.realm}`); + } + catch (error) { + if (this._isConflictError(error)) { + this._log(`Realm ${config.realm} already exists`); + } + else { + throw error; + } + } + } + /** + * Create a new client in a realm + */ + async createClient(realm, config) { + await this._ensureAdminClient(); + try { + this._adminClient.setConfig({ realmName: realm }); + await this._adminClient.clients.create({ + clientId: config.clientId, + secret: config.clientSecret, + name: config.name ?? config.clientId, + description: config.description ?? "", + redirectUris: config.redirectUris ?? ["*"], + webOrigins: config.webOrigins ?? ["*"], + standardFlowEnabled: config.standardFlowEnabled ?? true, + implicitFlowEnabled: config.implicitFlowEnabled ?? true, + directAccessGrantsEnabled: config.directAccessGrantsEnabled ?? true, + serviceAccountsEnabled: config.serviceAccountsEnabled ?? true, + authorizationServicesEnabled: config.authorizationServicesEnabled ?? true, + publicClient: config.publicClient ?? false, + enabled: true, + protocol: "openid-connect", + fullScopeAllowed: true, + attributes: config.attributes, + defaultClientScopes: config.defaultClientScopes, + optionalClientScopes: config.optionalClientScopes, + }); + this._log(`Created client: ${config.clientId}`); + } + catch (error) { + if (this._isConflictError(error)) { + this._log(`Client ${config.clientId} already exists`); + } + else { + throw error; + } + } + } + /** + * Create a group in a realm + */ + async createGroup(realm, config) { + await this._ensureAdminClient(); + try { + this._adminClient.setConfig({ realmName: realm }); + await this._adminClient.groups.create({ + name: config.name, + }); + this._log(`Created group: ${config.name}`); + } + catch (error) { + if (this._isConflictError(error)) { + this._log(`Group ${config.name} already exists`); + } + else { + throw error; + } + } + } + /** + * Create a user in a realm with optional group membership + */ + async createUser(realm, config) { + await this._ensureAdminClient(); + try { + this._adminClient.setConfig({ realmName: realm }); + // Create user + const createResponse = await this._adminClient.users.create({ + username: config.username, + email: config.email, + firstName: config.firstName, + lastName: config.lastName, + enabled: config.enabled ?? true, + emailVerified: config.emailVerified ?? true, + }); + this._log(`Created user: ${config.username}`); + const userId = createResponse.id; + // Set password if provided + if (config.password) { + await this._adminClient.users.resetPassword({ + id: userId, + credential: { + type: "password", + value: config.password, + temporary: config.temporary ?? false, + }, + }); + } + // Add to groups if specified + if (config.groups?.length) { + for (const groupName of config.groups) { + await this._addUserToGroup(realm, userId, groupName); + } + } + } + catch (error) { + if (this._isConflictError(error)) { + this._log(`User ${config.username} already exists`); + } + else { + throw error; + } + } + } + /** + * Create users and groups in a realm. + */ + async createUsersAndGroups(realm, options) { + await this._ensureAdminClient(); + const { groups = [], users = [] } = options; + for (const group of groups) { + await this.createGroup(realm, group); + } + for (const user of users) { + await this.createUser(realm, user); + } + } + /** + * Get all users in a realm + */ + async getUsers(realm) { + await this._ensureAdminClient(); + this._adminClient.setConfig({ realmName: realm }); + const users = await this._adminClient.users.find(); + return users.map((u) => ({ + username: u.username, + email: u.email, + firstName: u.firstName, + lastName: u.lastName, + enabled: u.enabled, + emailVerified: u.emailVerified, + })); + } + /** + * Get all groups in a realm + */ + async getGroups(realm) { + await this._ensureAdminClient(); + this._adminClient.setConfig({ realmName: realm }); + const groups = await this._adminClient.groups.find(); + return groups.map((g) => ({ name: g.name })); + } + /** + * Get groups for a user in a realm (user resolved by username). + */ + async getGroupsOfUser(realm, username) { + await this._ensureAdminClient(); + this._adminClient.setConfig({ realmName: realm }); + const users = await this._adminClient.users.find({ username }); + if (users.length === 0) { + return []; + } + const user = users[0]; + const groups = await this._adminClient.users.listGroups({ + id: user.id, + }); + return groups.map((g) => ({ name: g.name })); + } + /** + * Delete a user from a realm + */ + async deleteUser(realm, username) { + if (DEFAULT_USERS.some((u) => u.username === username)) { + throw new Error(`Deleting default Keycloak user "${username}" is not permitted.`); + } + await this._ensureAdminClient(); + this._adminClient.setConfig({ realmName: realm }); + const users = await this._adminClient.users.find({ username }); + if (users.length > 0) { + await this._adminClient.users.del({ id: users[0].id }); + this._log(`Deleted user: ${username}`); + } + } + /** + * Delete a group from a realm + */ + async deleteGroup(realm, groupName) { + if (DEFAULT_GROUPS.some((g) => g.name === groupName)) { + throw new Error(`Deleting default Keycloak group "${groupName}" is not permitted.`); + } + await this._ensureAdminClient(); + this._adminClient.setConfig({ realmName: realm }); + const groups = await this._adminClient.groups.find({ search: groupName }); + const group = groups.find((g) => g.name === groupName); + if (group) { + await this._adminClient.groups.del({ id: group.id }); + this._log(`Deleted group: ${groupName}`); + } + } + /** + * Delete users and groups from a realm. + */ + async deleteUsersAndGroups(realm, options) { + await this._ensureAdminClient(); + const { groups = [], users = [] } = options; + const usernames = users.map((u) => typeof u === "string" ? u : u.username); + const groupNames = groups.map((g) => (typeof g === "string" ? g : g.name)); + for (const username of usernames) { + await this.deleteUser(realm, username); + } + for (const groupName of groupNames) { + await this.deleteGroup(realm, groupName); + } + } + /** + * Delete a realm + */ + async deleteRealm(realm) { + await this._ensureAdminClient(); + try { + await this._adminClient.realms.del({ realm }); + this._log(`Deleted realm: ${realm}`); + } + catch (error) { + this._log(`Failed to delete realm ${realm}: ${error}`); + } + } + /** + * Teardown Keycloak deployment + */ + async teardown() { + await this.k8sClient.deleteNamespace(this.deploymentConfig.namespace); + this._log(`Keycloak deployment torn down`); + } + /** + * Wait for Keycloak to be ready + */ + async waitUntilReady(timeout = 500) { + this._log(`Waiting for Keycloak to be ready...`); + const labelSelector = `app.kubernetes.io/instance=${this.deploymentConfig.releaseName}`; + await this.k8sClient.waitForPodsWithFailureDetection(this.deploymentConfig.namespace, labelSelector, timeout); + } + // Private methods + _buildDeploymentConfig(options) { + return { + namespace: options.namespace ?? DEFAULT_KEYCLOAK_CONFIG.namespace, + releaseName: options.releaseName ?? DEFAULT_KEYCLOAK_CONFIG.releaseName, + valuesFile: options.valuesFile ?? DEFAULT_CONFIG_PATHS.valuesFile, + adminUser: options.adminUser ?? DEFAULT_KEYCLOAK_CONFIG.adminUser, + adminPassword: options.adminPassword ?? DEFAULT_KEYCLOAK_CONFIG.adminPassword, + }; + } + async _deployWithHelm() { + await $ `helm repo add bitnami ${BITNAMI_CHART_REPO} || true`; + await runQuietUnlessFailure `helm repo update`; + await runQuietUnlessFailure `helm upgrade --install ${this.deploymentConfig.releaseName} ${BITNAMI_CHART_NAME} \ + --namespace ${this.deploymentConfig.namespace} \ + --values ${this.deploymentConfig.valuesFile}`; + await this.waitUntilReady(); + } + async _createRoute() { + // Use plain HTTP route (no TLS) for test environments to avoid self-signed certificate issues + const routeManifest = ` +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: ${this.deploymentConfig.releaseName} + namespace: ${this.deploymentConfig.namespace} + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: ${this.deploymentConfig.releaseName} +spec: + to: + kind: Service + name: ${this.deploymentConfig.releaseName} + weight: 100 + port: + targetPort: http + wildcardPolicy: None +`; + await $ `echo ${routeManifest} | kubectl apply -f -`; + } + async getRouteLocation() { + return await this.k8sClient.getRouteLocation(this.deploymentConfig.namespace, this.deploymentConfig.releaseName); + } + async _waitForKeycloak() { + this._log("Waiting for Keycloak API to be ready..."); + const timeout = 500; + const startTime = Date.now(); + while (true) { + if (await this.isRunning()) { + break; + } + if ((Date.now() - startTime) / 1000 >= timeout) { + throw new Error(`Keycloak API not ready after ${timeout} seconds`); + } + await new Promise((resolve) => setTimeout(resolve, 5000)); + this._log(" Waiting for Keycloak API to be ready..."); + } + } + async _initializeAdminClient() { + this._adminClient = new KeycloakAdminClient({ + baseUrl: this.keycloakUrl, + realmName: "master", + }); + await this._adminClient.auth({ + username: this.deploymentConfig.adminUser, + password: this.deploymentConfig.adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + } + async _ensureAdminClient() { + if (!this._adminClient) { + throw new Error("Admin client not initialized. Call deploy() or connect() first."); + } + } + async _assignServiceAccountRoles(realm, clientId) { + await this._ensureAdminClient(); + this._adminClient.setConfig({ realmName: realm }); + // Get service account user + const clients = await this._adminClient.clients.find({ clientId }); + if (clients.length === 0) { + throw new Error(`Client ${clientId} not found`); + } + const client = clients[0]; + const serviceAccountUser = await this._adminClient.clients.getServiceAccountUser({ + id: client.id, + }); + // Get realm-management client + const realmMgmtClients = await this._adminClient.clients.find({ + clientId: "realm-management", + }); + if (realmMgmtClients.length === 0) { + throw new Error("realm-management client not found"); + } + const realmMgmtClient = realmMgmtClients[0]; + // Get roles + const allRoles = await this._adminClient.clients.listRoles({ + id: realmMgmtClient.id, + }); + const rolesToAssign = allRoles.filter((r) => SERVICE_ACCOUNT_ROLES.includes(r.name)); + if (rolesToAssign.length > 0) { + await this._adminClient.users.addClientRoleMappings({ + id: serviceAccountUser.id, + clientUniqueId: realmMgmtClient.id, + roles: rolesToAssign.map((r) => ({ + id: r.id, + name: r.name, + })), + }); + this._log(`Assigned service account roles: ${rolesToAssign.map((r) => r.name).join(", ")}`); + } + } + async _addUserToGroup(realm, userId, groupName) { + this._adminClient.setConfig({ realmName: realm }); + const groups = await this._adminClient.groups.find({ search: groupName }); + const group = groups.find((g) => g.name === groupName); + if (group) { + await this._adminClient.users.addToGroup({ + id: userId, + groupId: group.id, + }); + this._log(` Added user to group: ${groupName}`); + } + else { + this._log(` Warning: Group ${groupName} not found`); + } + } + _isConflictError(error) { + const err = error; + return err.response?.status === 409 || err.status === 409; + } + _log(...args) { + console.log("[Keycloak]", ...args); + } +} diff --git a/dist/deployment/keycloak/index.d.ts b/dist/deployment/keycloak/index.d.ts new file mode 100644 index 0000000..f3018ab --- /dev/null +++ b/dist/deployment/keycloak/index.d.ts @@ -0,0 +1,3 @@ +export { KeycloakHelper } from "./deployment.js"; +export type { KeycloakUserConfig, KeycloakGroupConfig } from "./types.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/deployment/keycloak/index.d.ts.map b/dist/deployment/keycloak/index.d.ts.map new file mode 100644 index 0000000..42c2c71 --- /dev/null +++ b/dist/deployment/keycloak/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/deployment/keycloak/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,YAAY,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC"} \ No newline at end of file diff --git a/dist/deployment/keycloak/index.js b/dist/deployment/keycloak/index.js new file mode 100644 index 0000000..93ede1f --- /dev/null +++ b/dist/deployment/keycloak/index.js @@ -0,0 +1 @@ +export { KeycloakHelper } from "./deployment.js"; diff --git a/dist/deployment/keycloak/types.d.ts b/dist/deployment/keycloak/types.d.ts new file mode 100644 index 0000000..59d5946 --- /dev/null +++ b/dist/deployment/keycloak/types.d.ts @@ -0,0 +1,59 @@ +export type KeycloakDeploymentOptions = { + namespace?: string; + releaseName?: string; + valuesFile?: string; + adminUser?: string; + adminPassword?: string; +}; +export type KeycloakDeploymentConfig = { + namespace: string; + releaseName: string; + valuesFile: string; + adminUser: string; + adminPassword: string; +}; +export type KeycloakClientConfig = { + clientId: string; + clientSecret: string; + name?: string; + description?: string; + redirectUris?: string[]; + webOrigins?: string[]; + standardFlowEnabled?: boolean; + implicitFlowEnabled?: boolean; + directAccessGrantsEnabled?: boolean; + serviceAccountsEnabled?: boolean; + authorizationServicesEnabled?: boolean; + publicClient?: boolean; + attributes?: Record; + defaultClientScopes?: string[]; + optionalClientScopes?: string[]; +}; +export type KeycloakUserConfig = { + username: string; + email?: string; + firstName?: string; + lastName?: string; + enabled?: boolean; + emailVerified?: boolean; + password?: string; + temporary?: boolean; + groups?: string[]; +}; +export type KeycloakGroupConfig = { + name: string; +}; +export type KeycloakRealmConfig = { + realm: string; + displayName?: string; + enabled?: boolean; +}; +export type KeycloakConnectionConfig = { + baseUrl: string; + realm?: string; + clientId?: string; + clientSecret?: string; + username?: string; + password?: string; +}; +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/dist/deployment/keycloak/types.d.ts.map b/dist/deployment/keycloak/types.d.ts.map new file mode 100644 index 0000000..a9f5b57 --- /dev/null +++ b/dist/deployment/keycloak/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/deployment/keycloak/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,yBAAyB,GAAG;IACtC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,yBAAyB,CAAC,EAAE,OAAO,CAAC;IACpC,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,4BAA4B,CAAC,EAAE,OAAO,CAAC;IACvC,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC"} \ No newline at end of file diff --git a/dist/deployment/keycloak/types.js b/dist/deployment/keycloak/types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/deployment/keycloak/types.js @@ -0,0 +1 @@ +export {}; diff --git a/dist/deployment/orchestrator/index.d.ts b/dist/deployment/orchestrator/index.d.ts new file mode 100644 index 0000000..4a9e4c1 --- /dev/null +++ b/dist/deployment/orchestrator/index.d.ts @@ -0,0 +1,3 @@ +export declare function installOrchestrator(namespace?: string): Promise; +export default installOrchestrator; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/deployment/orchestrator/index.d.ts.map b/dist/deployment/orchestrator/index.d.ts.map new file mode 100644 index 0000000..ceafb13 --- /dev/null +++ b/dist/deployment/orchestrator/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/deployment/orchestrator/index.ts"],"names":[],"mappings":"AAKA,wBAAsB,mBAAmB,CAAC,SAAS,SAAiB,iBAEnE;AAED,eAAe,mBAAmB,CAAC"} \ No newline at end of file diff --git a/dist/deployment/orchestrator/index.js b/dist/deployment/orchestrator/index.js new file mode 100644 index 0000000..c35150a --- /dev/null +++ b/dist/deployment/orchestrator/index.js @@ -0,0 +1,7 @@ +import { resolve } from "path"; +import { $ } from "../../utils/index.js"; +const scriptPath = resolve(import.meta.dirname, "install-orchestrator.sh"); +export async function installOrchestrator(namespace = "orchestrator") { + await $ `bash ${scriptPath} ${namespace}`; +} +export default installOrchestrator; diff --git a/dist/deployment/orchestrator/install-orchestrator.sh b/dist/deployment/orchestrator/install-orchestrator.sh new file mode 100755 index 0000000..ebf2b57 --- /dev/null +++ b/dist/deployment/orchestrator/install-orchestrator.sh @@ -0,0 +1,486 @@ +#!/bin/bash +# +# Standalone script to install the orchestrator (Serverless Logic / SonataFlow) +# on OpenShift. +# +# Usage: ./install-orchestrator.sh [namespace] +# Default namespace: orchestrator +# + +set -e + +export NAME_SPACE="${1:-${NAME_SPACE:-orchestrator}}" + +LOWER_CASE_CLASS='[:lower:]' +UPPER_CASE_CLASS='[:upper:]' + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +if [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]]; then + : "${LOG_NO_COLOR:=false}" +else + : "${LOG_NO_COLOR:=true}" +fi +: "${LOG_LEVEL:=INFO}" + +log::timestamp() { + echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + return 0 +} +log::level_value() { + local input="$1" + local level + level="$(echo "$input" | tr "$LOWER_CASE_CLASS" "$UPPER_CASE_CLASS")" + case "${level}" in DEBUG) echo 0 ;; INFO) echo 1 ;; WARN|WARNING) echo 2 ;; ERROR|ERR) echo 3 ;; *) echo 1 ;; esac + return 0; +} +log::should_log() { + local input requested_level config_level + input="$1" + requested_level="$(echo "$input" | tr "$LOWER_CASE_CLASS" "$UPPER_CASE_CLASS")" + config_level="$(echo "${LOG_LEVEL}" | tr "$LOWER_CASE_CLASS" "$UPPER_CASE_CLASS")" + + [[ "$(log::level_value "${requested_level}")" -ge "$(log::level_value "${config_level}")" ]] + return $? +} +log::reset_code() { + [[ "${LOG_NO_COLOR}" == "true" ]] && printf '' || printf '\033[0m' + return 0; +} +log::color_for_level() { + [[ "${LOG_NO_COLOR}" == "true" ]] && { printf ''; return 0; } + local level input + input="$1" + level="$(echo "$input" | tr "$LOWER_CASE_CLASS" "$UPPER_CASE_CLASS")" + case "${level}" in + DEBUG) printf '\033[36m' ;; INFO) printf '\033[34m' ;; WARN|WARNING) printf '\033[33m' ;; + ERROR|ERR) printf '\033[31m' ;; SUCCESS) printf '\033[32m' ;; SECTION) printf '\033[35m\033[1m' ;; + *) printf '\033[37m' ;; + esac +} +log::icon_for_level() { + local level input + input="$1" + level="$(echo "$input" | tr "$LOWER_CASE_CLASS" "$UPPER_CASE_CLASS")" + case "${level}" in DEBUG) printf '🐞' ;; INFO) printf 'β„Ή' ;; WARN|WARNING) printf '⚠' ;; ERROR|ERR) printf '❌' ;; SUCCESS) printf 'βœ“' ;; *) printf '-' ;; esac + return 0 +} +log::emit_line() { + local level="$1" icon="$2" line="$3" color reset timestamp + log::should_log "${level}" || return 0 + timestamp="$(log::timestamp)" + color="$(log::color_for_level "${level}")" + reset="$(log::reset_code)" + printf '%s[%s] %s %s%s\n' "${color}" "${timestamp}" "${icon}" "${line}" "${reset}" >&2 +} +log::emit() { + local level="$1"; shift + local icon message; icon="$(log::icon_for_level "${level}")"; message="${*:-}" + [[ -z "${message}" ]] && return 0 + while IFS= read -r line; do log::emit_line "${level}" "${icon}" "${line}"; done <<< "${message}" +} +log::debug() { + log::emit "DEBUG" "$@" + return 0 +} +log::info() { + log::emit "INFO" "$@" + return 0 +} +log::warn() { + log::emit "WARN" "$@" + return 0 +} +log::error() { + log::emit "ERROR" "$@" + return 0 +} +log::success() { + log::emit "SUCCESS" "$@" + return 0 +} + +# --------------------------------------------------------------------------- +# Operator subscription and status +# --------------------------------------------------------------------------- +install_subscription() { + local name=$1 namespace=$2 channel=$3 package=$4 source_name=$5 source_namespace=$6 starting_csv=${7:-} + local yaml + yaml="apiVersion: operators.coreos.com/v1alpha1 +kind: Subscription +metadata: + name: $name + namespace: $namespace +spec: + channel: $channel + installPlanApproval: Automatic + name: $package + source: $source_name + sourceNamespace: $source_namespace" + if [[ -n "$starting_csv" ]]; then + yaml+=" + startingCSV: $starting_csv" + fi + echo "$yaml" | oc apply -f - + return 0 +} + +# Wait for an operator CSV to reach a status phase. +# Uses OLM label selector (operators.coreos.com/.) which is +# deterministic, unlike spec.displayName which varies across channels/versions. +wait_for_operator() { + local timeout=${1:-300} namespace=$2 package=$3 expected_status=${4:-Succeeded} + local label="operators.coreos.com/${package}.${namespace}" + log::info "Waiting for operator '${package}' in '${namespace}' (label=${label}, timeout ${timeout}s, expected: ${expected_status})" + timeout "${timeout}" bash -c " + while true; do + CURRENT_PHASE=\$(oc get csv -n '${namespace}' -l '${label}' -o jsonpath='{.items[0].status.phase}' 2>/dev/null) + echo \"[wait_for_operator] Phase: \${CURRENT_PHASE}\" >&2 + [[ \"\${CURRENT_PHASE}\" == \"${expected_status}\" ]] && echo \"[wait_for_operator] Operator reached ${expected_status}\" >&2 && break + sleep 10 + done + " || { log::error "Operator '${package}' did not reach ${expected_status} in time."; return 1; } +} + +install_serverless_logic_ocp_operator() { + install_subscription logic-operator openshift-operators stable logic-operator redhat-operators openshift-marketplace logic-operator.v1.37.2 + return 0 +} +waitfor_serverless_logic_ocp_operator() { + wait_for_operator 500 openshift-operators logic-operator Succeeded + return 0 +} + +install_serverless_ocp_operator() { + install_subscription serverless-operator openshift-operators stable serverless-operator redhat-operators openshift-marketplace + return 0 +} +waitfor_serverless_ocp_operator() { + wait_for_operator 300 openshift-operators serverless-operator Succeeded + return 0 +} + +# --------------------------------------------------------------------------- +# Namespace +# --------------------------------------------------------------------------- +force_delete_namespace() { + local project=$1 timeout_seconds=${2:-120} elapsed=0 sleep_interval=2 + log::warn "Force deleting namespace ${project}" + oc get namespace "$project" -o json | jq '.spec = {"finalizers":[]}' | oc replace --raw "/api/v1/namespaces/$project/finalize" -f - + while oc get namespace "$project" &>/dev/null; do + [[ $elapsed -ge $timeout_seconds ]] && { log::warn "Timeout deleting ${project}"; return 1; } + sleep $sleep_interval + elapsed=$((elapsed + sleep_interval)) + done + log::success "Namespace '${project}' deleted." +} + +delete_namespace() { + local project=$1 + if oc get namespace "$project" &>/dev/null; then + log::warn "Deleting namespace ${project}..." + oc delete namespace "$project" --grace-period=0 --force || true + if oc get namespace "$project" -o jsonpath='{.status.phase}' 2>/dev/null | grep -q Terminating; then + force_delete_namespace "$project" + fi + fi + return 0 +} + +configure_namespace() { + local project=$1 + if oc get namespace "$project" &>/dev/null; then + log::info "Namespace ${project} already exists, reusing it." + else + log::info "Creating namespace: ${project}" + oc create namespace "${project}" || { log::error "Failed to create namespace ${project}"; exit 1; } + fi + oc config set-context --current --namespace="${project}" || { log::error "Failed to set context"; exit 1; } + log::info "Namespace ${project} is ready." + return 0 +} + +# --------------------------------------------------------------------------- +# Deployment wait +# --------------------------------------------------------------------------- +wait_for_deployment() { + local namespace=$1 resource_name=$2 timeout_minutes=${3:-5} check_interval=${4:-10} + [[ -z "$namespace" || -z "$resource_name" ]] && { log::error "wait_for_deployment: namespace and resource_name required"; return 1; } + local max_attempts=$((timeout_minutes * 60 / check_interval)) + log::info "Waiting for '$resource_name' in '$namespace' (timeout ${timeout_minutes}m)..." + for ((i = 1; i <= max_attempts; i++)); do + local pod_name + pod_name=$(oc get pods -n "$namespace" 2>/dev/null | grep "$resource_name" | awk '{print $1}' | head -n 1) + if [[ -n "$pod_name" ]]; then + local is_ready + is_ready=$(oc get pod "$pod_name" -n "$namespace" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null) + if [[ "$is_ready" == "True" ]] && oc get pod "$pod_name" -n "$namespace" 2>/dev/null | grep -q Running; then + log::success "Pod '$pod_name' is ready" + return 0 + fi + fi + sleep "$check_interval" + done + log::error "Timeout waiting for $resource_name" + return 1 +} + +# --------------------------------------------------------------------------- +# PostgreSQL (simple deployment for orchestrator) +# --------------------------------------------------------------------------- +create_simple_postgres_deployment() { + local namespace=$1 postgres_name="backstage-psql" + if oc get deployment "$postgres_name" -n "$namespace" &>/dev/null; then + log::info "PostgreSQL '$postgres_name' already exists" + return 0 + fi + log::info "Creating PostgreSQL '$postgres_name' in '$namespace'" + oc create secret generic "${postgres_name}-secret" -n "$namespace" \ + --from-literal=POSTGRESQL_USER=postgres \ + --from-literal=POSTGRESQL_PASSWORD=postgres \ + --from-literal=POSTGRESQL_DATABASE=postgres \ + --from-literal=POSTGRES_USER=postgres \ + --from-literal=POSTGRES_PASSWORD=postgres \ + --from-literal=POSTGRES_DB=postgres \ + --dry-run=client -o yaml | oc apply -f - -n "$namespace" || true + + oc apply -f - -n "$namespace" << EOF +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: ${postgres_name}-pvc + namespace: ${namespace} +spec: + accessModes: [ReadWriteOnce] + resources: { requests: { storage: 1Gi } } +EOF + + oc apply -f - -n "$namespace" << EOF +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: ${postgres_name} + namespace: ${namespace} +spec: + serviceName: ${postgres_name} + replicas: 1 + selector: { matchLabels: { app: ${postgres_name} } } + template: + metadata: { labels: { app: ${postgres_name} } } + spec: + containers: + - name: postgres + image: registry.redhat.io/rhel9/postgresql-15:latest + env: + - name: POSTGRESQL_USER + valueFrom: { secretKeyRef: { name: ${postgres_name}-secret, key: POSTGRESQL_USER } } + - name: POSTGRESQL_PASSWORD + valueFrom: { secretKeyRef: { name: ${postgres_name}-secret, key: POSTGRESQL_PASSWORD } } + - name: POSTGRESQL_DATABASE + valueFrom: { secretKeyRef: { name: ${postgres_name}-secret, key: POSTGRESQL_DATABASE } } + ports: [ { containerPort: 5432, name: postgres } ] + volumeMounts: [ { name: postgres-data, mountPath: /var/lib/pgsql/data } ] + livenessProbe: + exec: { command: [ /usr/libexec/check-container, --live ] } + initialDelaySeconds: 120 + periodSeconds: 10 + readinessProbe: + exec: { command: [ /usr/libexec/check-container ] } + initialDelaySeconds: 5 + periodSeconds: 10 + volumes: [ { name: postgres-data, persistentVolumeClaim: { claimName: ${postgres_name}-pvc } } ] +EOF + + oc apply -f - -n "$namespace" << EOF +apiVersion: v1 +kind: Service +metadata: + name: ${postgres_name} + namespace: ${namespace} +spec: + selector: { app: ${postgres_name} } + ports: [ { name: postgres, port: 5432, targetPort: 5432 } ] + type: ClusterIP +EOF + + log::info "Waiting for PostgreSQL StatefulSet..." + oc wait statefulset "$postgres_name" -n "$namespace" --for=jsonpath='{.status.readyReplicas}'=1 --timeout=300s || true + sleep 5 + oc exec -n "$namespace" statefulset/"$postgres_name" -- psql -U postgres -c "CREATE DATABASE backstage_plugin_orchestrator;" 2>/dev/null || log::warn "Orchestrator DB may already exist" + log::success "PostgreSQL deployment created." +} + +# --------------------------------------------------------------------------- +# SonataFlow platform +# --------------------------------------------------------------------------- +create_sonataflow_platform() { + local namespace=$1 postgres_secret_name=$2 postgres_service_name=$3 + if ! oc get crd sonataflowplatforms.sonataflow.org &>/dev/null && ! oc get crd sonataflowplatform.sonataflow.org &>/dev/null; then + log::error "SonataFlowPlatform CRD not found. Install Serverless Logic Operator first." + return 1 + fi + if oc get sonataflowplatform sonataflow-platform -n "$namespace" &>/dev/null || oc get sfp sonataflow-platform -n "$namespace" &>/dev/null; then + log::info "SonataFlowPlatform already exists" + return 0 + fi + log::info "Creating SonataFlowPlatform in '$namespace'" + oc apply -f - -n "$namespace" << EOF +apiVersion: sonataflow.org/v1alpha08 +kind: SonataFlowPlatform +metadata: + name: sonataflow-platform + namespace: ${namespace} +spec: + services: + dataIndex: + persistence: + postgresql: + secretRef: { name: ${postgres_secret_name}, userKey: POSTGRES_USER, passwordKey: POSTGRES_PASSWORD } + serviceRef: { name: ${postgres_service_name}, namespace: ${namespace}, port: 5432, databaseName: backstage_plugin_orchestrator } + jobService: + persistence: + postgresql: + secretRef: { name: ${postgres_secret_name}, userKey: POSTGRES_USER, passwordKey: POSTGRES_PASSWORD } + serviceRef: { name: ${postgres_service_name}, namespace: ${namespace}, port: 5432, databaseName: backstage_plugin_orchestrator } +EOF + local attempt=0 max_attempts=60 + while [[ $attempt -lt $max_attempts ]]; do + if oc get deployment sonataflow-platform-data-index-service -n "$namespace" &>/dev/null && \ + oc get deployment sonataflow-platform-jobs-service -n "$namespace" &>/dev/null; then + log::success "SonataFlowPlatform services created" + wait_for_deployment "$namespace" sonataflow-platform-data-index-service 20 || true + wait_for_deployment "$namespace" sonataflow-platform-jobs-service 20 || true + log::success "SonataFlowPlatform ready." + return 0 + fi + attempt=$((attempt + 1)) + [[ $((attempt % 10)) -eq 0 ]] && log::info "Waiting for SonataFlowPlatform... ($attempt/$max_attempts)" + sleep 5 + done + log::warn "SonataFlowPlatform services did not appear in time." +} + +# --------------------------------------------------------------------------- +# Orchestrator connection info +# --------------------------------------------------------------------------- +print_orchestrator_connection_info() { + local namespace=$1 + local data_index_service="sonataflow-platform-data-index-service" + local service_url="http://${data_index_service}.${namespace}.svc.cluster.local" + log::info "==========================================" + log::info "Orchestrator Plugin Connection Information" + log::info "==========================================" + log::info "Namespace: ${namespace}" + log::info "Internal URL for Orchestrator Backend Plugin: ${service_url}" + log::info "dynamic-plugins.yaml: pluginConfig.orchestrator.dataIndexService.url: ${service_url}" + if oc get svc "${data_index_service}" -n "${namespace}" &>/dev/null; then + local port; port=$(oc get svc "${data_index_service}" -n "${namespace}" -o jsonpath='{.spec.ports[0].port}' 2>/dev/null || echo "8080") + log::info "Service: ${data_index_service}, port: ${port}" + else + log::warn "Service '${data_index_service}' not found yet." + fi + log::info "==========================================" + return 0 +} + +# --------------------------------------------------------------------------- +# Wait for SonataFlow CRDs +# --------------------------------------------------------------------------- +wait_for_sonataflow_crds() { + log::info "Waiting for SonataFlow CRDs..." + local attempt=0 max_attempts=60 + while [[ $attempt -lt $max_attempts ]]; do + if oc get crd sonataflows.sonataflow.org &>/dev/null; then + log::success "SonataFlow CRD is available." + return 0 + fi + attempt=$((attempt + 1)) + [[ $((attempt % 6)) -eq 0 ]] && log::info "Waiting for sonataflows.sonataflow.org... ($attempt/$max_attempts)" + sleep 5 + done + log::error "Timed out waiting for SonataFlow CRD." + return 1 +} + +# --------------------------------------------------------------------------- +# Deploy orchestrator workflows (operator path: git clone + helm greeting) +# Uses local yaml/ if present, otherwise clones repo. +# --------------------------------------------------------------------------- +deploy_orchestrator_workflows_operator() { + local namespace=$1 + + # PostgreSQL + if ! oc get statefulset backstage-psql -n "$namespace" &>/dev/null && ! oc get deployment backstage-psql -n "$namespace" &>/dev/null; then + log::info "Creating simple PostgreSQL deployment..." + create_simple_postgres_deployment "$namespace" + else + log::info "PostgreSQL found, waiting for ready..." + if oc get statefulset backstage-psql -n "$namespace" &>/dev/null; then + oc wait statefulset backstage-psql -n "$namespace" --for=jsonpath='{.status.readyReplicas}'=1 --timeout=300s || true + else + wait_for_deployment "$namespace" backstage-psql 15 || true + fi + fi + + local psql_secret_name psql_svc_name + psql_secret_name=$(oc get secrets -n "$namespace" -o name 2>/dev/null | grep "backstage-psql" | grep "secret" | head -1 | sed 's|secret\/||') + psql_svc_name='backstage-psql' + + log::info "PostgreSQL secret: $psql_secret_name, service: $psql_svc_name" + + if ! oc get sonataflowplatform sonataflow-platform -n "$namespace" &>/dev/null && ! oc get sfp sonataflow-platform -n "$namespace" &>/dev/null; then + create_sonataflow_platform "$namespace" "$psql_secret_name" "$psql_svc_name" + else + log::info "SonataFlowPlatform already exists" + wait_for_deployment "$namespace" sonataflow-platform-data-index-service 20 || true + wait_for_deployment "$namespace" sonataflow-platform-jobs-service 20 || true + fi + + if ! oc get crd sonataflows.sonataflow.org &>/dev/null; then + log::error "SonataFlow CRD not found." + return 1 + fi +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + log::info "Starting orchestrator deployment for namespace: ${NAME_SPACE}" + + if ! oc whoami &>/dev/null && ! kubectl cluster-info &>/dev/null; then + log::error "Not logged into OpenShift/Kubernetes cluster" + return 1 + fi + + log::info "Checking Serverless operators..." + if ! oc get subscription serverless-operator -n openshift-operators &>/dev/null; then + log::info "Installing OpenShift Serverless Operator..." + install_serverless_ocp_operator + else + log::info "OpenShift Serverless Operator already installed" + fi + + if oc get subscription logic-operator -n openshift-operators &>/dev/null || \ + oc get subscription logic-operator-rhel8 -n openshift-operators &>/dev/null; then + log::info "OpenShift Serverless Logic Operator already installed" + else + log::info "Installing OpenShift Serverless Logic Operator..." + install_serverless_logic_ocp_operator + fi + + log::info "Waiting for operators to be ready..." + waitfor_serverless_ocp_operator + waitfor_serverless_logic_ocp_operator + wait_for_sonataflow_crds + + configure_namespace "${NAME_SPACE}" + log::info "Deploying orchestrator workflows..." + deploy_orchestrator_workflows_operator "${NAME_SPACE}" + print_orchestrator_connection_info "${NAME_SPACE}" + + log::success "Orchestrator deployment completed successfully!" +} + +main "$@" diff --git a/dist/deployment/rhdh/config/auth/github/app-config.yaml b/dist/deployment/rhdh/config/auth/github/app-config.yaml new file mode 100644 index 0000000..552584c --- /dev/null +++ b/dist/deployment/rhdh/config/auth/github/app-config.yaml @@ -0,0 +1,17 @@ +auth: + environment: production + session: + secret: superSecretSecret + providers: + github: + production: + clientSecret: ${GITHUB_OAUTH_APP_SECRET} + clientId: ${GITHUB_OAUTH_APP_ID} + callbackUrl: ${RHDH_BASE_URL}/api/auth/github/handler/frame +signInPage: github +catalog: + locations: + - type: url + target: https://github.com/janus-qe/test-user-entity/blob/main/user.yaml + rules: + - allow: [User] diff --git a/dist/deployment/rhdh/config/auth/github/secrets.yaml b/dist/deployment/rhdh/config/auth/github/secrets.yaml new file mode 100644 index 0000000..30cc619 --- /dev/null +++ b/dist/deployment/rhdh/config/auth/github/secrets.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: rhdh-secrets +type: Opaque +stringData: + GITHUB_OAUTH_APP_ID: $VAULT_GITHUB_OAUTH_OVERLAYS_APP_ID + GITHUB_OAUTH_APP_SECRET: $VAULT_GITHUB_OAUTH_OVERLAYS_APP_SECRET + GH_USER_ID: $VAULT_GH_USER_ID + GH_USER_PASS: $VAULT_GH_USER_PASS + GH_2FA_SECRET: $VAULT_GH_2FA_SECRET + GH_RHDH_QE_USER_TOKEN: $VAULT_GITHUB_USER_TOKEN diff --git a/dist/deployment/rhdh/config/auth/guest/app-config.yaml b/dist/deployment/rhdh/config/auth/guest/app-config.yaml new file mode 100644 index 0000000..a867aee --- /dev/null +++ b/dist/deployment/rhdh/config/auth/guest/app-config.yaml @@ -0,0 +1,5 @@ +auth: + environment: development + providers: + guest: + dangerouslyAllowOutsideDevelopment: true diff --git a/dist/deployment/rhdh/config/auth/keycloak/app-config.yaml b/dist/deployment/rhdh/config/auth/keycloak/app-config.yaml new file mode 100644 index 0000000..71b2705 --- /dev/null +++ b/dist/deployment/rhdh/config/auth/keycloak/app-config.yaml @@ -0,0 +1,19 @@ +auth: + environment: production + session: + secret: superSecretSecret + providers: + oidc: + production: + metadataUrl: "${KEYCLOAK_METADATA_URL}" + clientId: "${KEYCLOAK_CLIENT_ID}" + clientSecret: "${KEYCLOAK_CLIENT_SECRET}" + prompt: auto + callbackUrl: "${RHDH_BASE_URL}/api/auth/oidc/handler/frame" + signIn: + resolvers: + - resolver: emailLocalPartMatchingUserEntityName +signInPage: oidc +catalog: + rules: + - allow: [User, Group] diff --git a/dist/deployment/rhdh/config/auth/keycloak/dynamic-plugins.yaml b/dist/deployment/rhdh/config/auth/keycloak/dynamic-plugins.yaml new file mode 100644 index 0000000..51bc2ad --- /dev/null +++ b/dist/deployment/rhdh/config/auth/keycloak/dynamic-plugins.yaml @@ -0,0 +1,3 @@ +plugins: + - package: ./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic + disabled: false diff --git a/dist/deployment/rhdh/config/auth/keycloak/secrets.yaml b/dist/deployment/rhdh/config/auth/keycloak/secrets.yaml new file mode 100644 index 0000000..7d2bc18 --- /dev/null +++ b/dist/deployment/rhdh/config/auth/keycloak/secrets.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: rhdh-secrets +type: Opaque +stringData: + KEYCLOAK_BASE_URL: $KEYCLOAK_BASE_URL + KEYCLOAK_METADATA_URL: $KEYCLOAK_METADATA_URL + KEYCLOAK_CLIENT_ID: $KEYCLOAK_CLIENT_ID + KEYCLOAK_CLIENT_SECRET: $KEYCLOAK_CLIENT_SECRET + KEYCLOAK_REALM: $KEYCLOAK_REALM + KEYCLOAK_LOGIN_REALM: $KEYCLOAK_LOGIN_REALM diff --git a/dist/deployment/rhdh/config/common/app-config-rhdh.yaml b/dist/deployment/rhdh/config/common/app-config-rhdh.yaml new file mode 100644 index 0000000..0271f15 --- /dev/null +++ b/dist/deployment/rhdh/config/common/app-config-rhdh.yaml @@ -0,0 +1,6 @@ +app: + baseUrl: "${RHDH_BASE_URL}" +backend: + baseUrl: "${RHDH_BASE_URL}" + cors: + origin: "${RHDH_BASE_URL}" diff --git a/dist/deployment/rhdh/config/common/dynamic-plugins.yaml b/dist/deployment/rhdh/config/common/dynamic-plugins.yaml new file mode 100644 index 0000000..392431d --- /dev/null +++ b/dist/deployment/rhdh/config/common/dynamic-plugins.yaml @@ -0,0 +1,5 @@ +includes: + - dynamic-plugins.default.yaml +plugins: + - package: ./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-quickstart + disabled: true diff --git a/dist/deployment/rhdh/config/common/rhdh-secrets.yaml b/dist/deployment/rhdh/config/common/rhdh-secrets.yaml new file mode 100644 index 0000000..274f623 --- /dev/null +++ b/dist/deployment/rhdh/config/common/rhdh-secrets.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Secret +metadata: + name: rhdh-secrets +type: Opaque +stringData: + RHDH_BASE_URL: $RHDH_BASE_URL diff --git a/dist/deployment/rhdh/config/helm/value_file.yaml b/dist/deployment/rhdh/config/helm/value_file.yaml new file mode 100644 index 0000000..3fb9a14 --- /dev/null +++ b/dist/deployment/rhdh/config/helm/value_file.yaml @@ -0,0 +1,7 @@ +upstream: + backstage: + extraAppConfig: + - configMapRef: app-config-rhdh + filename: app-config-rhdh.yaml + extraEnvVarsSecrets: + - rhdh-secrets diff --git a/dist/deployment/rhdh/config/operator/subscription.yaml b/dist/deployment/rhdh/config/operator/subscription.yaml new file mode 100644 index 0000000..554be2d --- /dev/null +++ b/dist/deployment/rhdh/config/operator/subscription.yaml @@ -0,0 +1,21 @@ +apiVersion: rhdh.redhat.com/v1alpha3 +kind: Backstage +metadata: + name: developer-hub +spec: + application: + appConfig: + configMaps: + - name: app-config-rhdh + mountPath: /opt/app-root/src + extraFiles: + mountPath: /opt/app-root/src + replicas: 1 + route: + enabled: true + dynamicPluginsConfigMapName: dynamic-plugins + extraEnvs: + secrets: + - name: rhdh-secrets + database: + enableLocalDb: true diff --git a/dist/deployment/rhdh/constants.d.ts b/dist/deployment/rhdh/constants.d.ts new file mode 100644 index 0000000..871966f --- /dev/null +++ b/dist/deployment/rhdh/constants.d.ts @@ -0,0 +1,21 @@ +import type { AuthProvider } from "./types.js"; +import { MergeOptions } from "../../utils/merge-yamls.js"; +export declare const DEFAULT_CONFIG_PATHS: { + appConfig: string; + secrets: string; + dynamicPlugins: string; + helm: { + valueFile: string; + }; + operator: { + subscription: string; + }; +}; +export declare const AUTH_CONFIG_PATHS: Record; +export declare const CHART_URL = "oci://quay.io/rhdh/chart"; +//# sourceMappingURL=constants.d.ts.map \ No newline at end of file diff --git a/dist/deployment/rhdh/constants.d.ts.map b/dist/deployment/rhdh/constants.d.ts.map new file mode 100644 index 0000000..e94a26f --- /dev/null +++ b/dist/deployment/rhdh/constants.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../src/deployment/rhdh/constants.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAK1D,eAAO,MAAM,oBAAoB;;;;;;;;;;CAyBhC,CAAC;AAEF,eAAO,MAAM,iBAAiB,EAAE,MAAM,CACpC,YAAY,EACZ;IACE,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,CAAC,EAAE,YAAY,CAAC;CAC9B,CAoCF,CAAC;AAEF,eAAO,MAAM,SAAS,6BAA6B,CAAC"} \ No newline at end of file diff --git a/dist/deployment/rhdh/constants.js b/dist/deployment/rhdh/constants.js new file mode 100644 index 0000000..06b515c --- /dev/null +++ b/dist/deployment/rhdh/constants.js @@ -0,0 +1,33 @@ +import path from "path"; +// Navigate from dist/deployment/rhdh/ to package root +const PACKAGE_ROOT = path.resolve(import.meta.dirname, "../../.."); +export const DEFAULT_CONFIG_PATHS = { + appConfig: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/common/app-config-rhdh.yaml"), + secrets: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/common/rhdh-secrets.yaml"), + dynamicPlugins: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/common/dynamic-plugins.yaml"), + helm: { + valueFile: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/helm/value_file.yaml"), + }, + operator: { + subscription: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/operator/subscription.yaml"), + }, +}; +export const AUTH_CONFIG_PATHS = { + guest: { + appConfig: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/auth/guest/app-config.yaml"), + secrets: "", + dynamicPlugins: "", + }, + keycloak: { + appConfig: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/auth/keycloak/app-config.yaml"), + secrets: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/auth/keycloak/secrets.yaml"), + dynamicPlugins: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/auth/keycloak/dynamic-plugins.yaml"), + }, + github: { + appConfig: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/auth/github/app-config.yaml"), + secrets: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/auth/github/secrets.yaml"), + dynamicPlugins: "", + mergeStrategy: { arrayMergeStrategy: { byKey: "target" } }, + }, +}; +export const CHART_URL = "oci://quay.io/rhdh/chart"; diff --git a/dist/deployment/rhdh/deployment.d.ts b/dist/deployment/rhdh/deployment.d.ts new file mode 100644 index 0000000..7bc4300 --- /dev/null +++ b/dist/deployment/rhdh/deployment.d.ts @@ -0,0 +1,59 @@ +import { KubernetesClientHelper } from "../../utils/kubernetes-client.js"; +import type { DeploymentOptions, DeploymentConfig } from "./types.js"; +export declare class RHDHDeployment { + k8sClient: KubernetesClientHelper; + rhdhUrl: string; + deploymentConfig: DeploymentConfig; + constructor(namespace: string); + deploy(options?: { + timeout?: number | null; + }): Promise; + private _applyAppConfig; + private _applySecrets; + /** Shared merge strategy for dynamic plugin arrays. */ + private static readonly pluginMergeOpts; + /** + * Merges package defaults + auth config (+ optional user config) into a + * single dynamic plugins configuration. + */ + private _mergeBaseConfigs; + /** + * Merges a generated plugin config with the base (defaults + auth) config. + */ + private _mergeGeneratedWithBase; + /** + * Builds the merged dynamic plugins configuration. + * + * 1. Assembles raw config: user-provided OR auto-generated from metadata + * 2. Processes for deployment: injects metadata (PR) + resolves all packages to OCI + * + * The processing step is shared β€” processPluginsForDeployment handles + * both PR and nightly via isNightlyJob() and GIT_PR_NUMBER detection. + */ + private _buildDynamicPluginsConfig; + private _applyDynamicPlugins; + private _deployWithHelm; + private _deployWithOperator; + rolloutRestart(): Promise; + /** + * Performs a clean restart by scaling down to 0 first, waiting for pods to terminate, + * then scaling back up. This prevents MigrationLocked errors by ensuring no pods + * hold database locks when new pods start. + */ + scaleDownAndRestart(): Promise; + waitUntilReady(timeout?: number): Promise; + teardown(): Promise; + private _deploymentExists; + private _resolveChartVersion; + /** + * Resolve the semantic version from the "next" tag by looking up the + * downstream image (rhdh-hub-rhel9) and finding tags with the same digest. + */ + private _resolveVersionFromNextTag; + private _buildDeploymentConfig; + configure(deploymentOptions?: DeploymentOptions): Promise; + private _buildBaseUrl; + private _log; + private _logBoxen; +} +//# sourceMappingURL=deployment.d.ts.map \ No newline at end of file diff --git a/dist/deployment/rhdh/deployment.d.ts.map b/dist/deployment/rhdh/deployment.d.ts.map new file mode 100644 index 0000000..160f016 --- /dev/null +++ b/dist/deployment/rhdh/deployment.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"deployment.d.ts","sourceRoot":"","sources":["../../../src/deployment/rhdh/deployment.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,kCAAkC,CAAC;AAwB1E,OAAO,KAAK,EACV,iBAAiB,EACjB,gBAAgB,EAGjB,MAAM,YAAY,CAAC;AAEpB,qBAAa,cAAc;IAClB,SAAS,yBAAgC;IACzC,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,EAAE,gBAAgB,CAAC;gBAE9B,SAAS,EAAE,MAAM;IAKvB,MAAM,CAAC,OAAO,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;YA0CpD,eAAe;YAmBf,aAAa;IAqB3B,uDAAuD;IACvD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,eAAe,CAE5B;IAEX;;;OAGG;YACW,iBAAiB;IAY/B;;OAEG;YACW,uBAAuB;IAgBrC;;;;;;;;OAQG;YACW,0BAA0B;YAsC1B,oBAAoB;YAWpB,eAAe;YA6Df,mBAAmB;IAoE3B,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAWrC;;;;OAIG;IACG,mBAAmB,IAAI,OAAO,CAAC,IAAI,CAAC;IAOpC,cAAc,CAAC,OAAO,GAAE,MAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IA8BpD,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;YAIjB,iBAAiB;YASjB,oBAAoB;IAsClC;;;OAGG;YACW,0BAA0B;IAwCxC,OAAO,CAAC,sBAAsB;IAoCxB,SAAS,CAAC,iBAAiB,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAUrE,OAAO,CAAC,aAAa;IAUrB,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,SAAS;CAMlB"} \ No newline at end of file diff --git a/dist/deployment/rhdh/deployment.js b/dist/deployment/rhdh/deployment.js new file mode 100644 index 0000000..15f4c91 --- /dev/null +++ b/dist/deployment/rhdh/deployment.js @@ -0,0 +1,412 @@ +import { KubernetesClientHelper } from "../../utils/kubernetes-client.js"; +import { WorkspacePaths } from "../../utils/workspace-paths.js"; +import { $ } from "../../utils/bash.js"; +import yaml from "js-yaml"; +import os from "os"; +import path from "path"; +import { test, request, expect } from "@playwright/test"; +import { mergeYamlFilesIfExists, deepMerge } from "../../utils/merge-yamls.js"; +import { generatePluginsFromMetadata, processPluginsForDeployment, getNormalizedPluginMergeKey, disablePluginWrappers, } from "../../utils/plugin-metadata.js"; +import { envsubst } from "../../utils/common.js"; +import { runOnce } from "../../playwright/run-once.js"; +import cloneDeepWith from "lodash.clonedeepwith"; +import fs from "fs-extra"; +import { DEFAULT_CONFIG_PATHS, AUTH_CONFIG_PATHS, CHART_URL, } from "./constants.js"; +export class RHDHDeployment { + k8sClient = new KubernetesClientHelper(); + rhdhUrl; + deploymentConfig; + constructor(namespace) { + this.deploymentConfig = this._buildDeploymentConfig({ namespace }); + this.rhdhUrl = this._buildBaseUrl(); + } + async deploy(options) { + // Default 600s, custom number to override, null to skip and let consumer control the timeout + const timeout = options?.timeout === undefined ? 600_000 : options.timeout; + if (timeout !== null) { + test.setTimeout(timeout); + } + const executed = await runOnce(`deploy-${this.deploymentConfig.namespace}`, async () => { + this._log("Starting RHDH deployment..."); + this._log("RHDH Base URL: " + this.rhdhUrl); + console.table(this.deploymentConfig); + await this.k8sClient.createNamespaceIfNotExists(this.deploymentConfig.namespace); + await this._applyAppConfig(); + await this._applySecrets(); + if (this.deploymentConfig.method === "helm") { + const isUpgrade = await this._deploymentExists(); + await this._deployWithHelm(this.deploymentConfig.valueFile); + if (isUpgrade) { + await this.scaleDownAndRestart(); // Restart as helm does not monitor config changes + } + } + else { + await this._applyDynamicPlugins(); + await this._deployWithOperator(this.deploymentConfig.subscription); + } + await this.waitUntilReady(); + }); + if (!executed) { + this._log(`Deployment already completed for namespace "${this.deploymentConfig.namespace}", skipping`); + } + } + async _applyAppConfig() { + const authConfig = AUTH_CONFIG_PATHS[this.deploymentConfig.auth]; + const appConfigYaml = await mergeYamlFilesIfExists([ + DEFAULT_CONFIG_PATHS.appConfig, + authConfig.appConfig, + this.deploymentConfig.appConfig, + ], authConfig.mergeStrategy); + this._logBoxen("App Config", appConfigYaml); + await this.k8sClient.applyConfigMapFromObject("app-config-rhdh", appConfigYaml, this.deploymentConfig.namespace); + } + async _applySecrets() { + const authConfig = AUTH_CONFIG_PATHS[this.deploymentConfig.auth]; + const secretsYaml = await mergeYamlFilesIfExists([ + DEFAULT_CONFIG_PATHS.secrets, + authConfig.secrets, + this.deploymentConfig.secrets, + ]); + // Use cloneDeepWith to substitute env vars in-place, avoiding JSON.parse issues + // with control characters in secrets (e.g., private keys with newlines) + const substituted = cloneDeepWith(secretsYaml, (value) => { + if (typeof value === "string") + return envsubst(value); + }); + await this.k8sClient.applySecretFromObject("rhdh-secrets", substituted, this.deploymentConfig.namespace); + } + /** Shared merge strategy for dynamic plugin arrays. */ + static pluginMergeOpts = { + arrayMergeStrategy: { byKey: "package" }, + }; + /** + * Merges package defaults + auth config (+ optional user config) into a + * single dynamic plugins configuration. + */ + async _mergeBaseConfigs(userConfigPath) { + const authConfig = AUTH_CONFIG_PATHS[this.deploymentConfig.auth]; + const paths = [ + DEFAULT_CONFIG_PATHS.dynamicPlugins, + authConfig.dynamicPlugins, + ...(userConfigPath ? [userConfigPath] : []), + ]; + return await mergeYamlFilesIfExists(paths, RHDHDeployment.pluginMergeOpts); + } + /** + * Merges a generated plugin config with the base (defaults + auth) config. + */ + async _mergeGeneratedWithBase(generatedConfig) { + const baseConfig = await this._mergeBaseConfigs(); + // Use normalizeKey so OCI and local path for the same logical plugin + // (e.g., keycloak from metadata OCI + auth local path with -dynamic suffix) + // are deduplicated; generated (metadata) wins so OCI URL is kept. + return deepMerge(baseConfig, generatedConfig, { + arrayMergeStrategy: { + byKey: "package", + normalizeKey: (item) => getNormalizedPluginMergeKey(item), + }, + }); + } + /** + * Builds the merged dynamic plugins configuration. + * + * 1. Assembles raw config: user-provided OR auto-generated from metadata + * 2. Processes for deployment: injects metadata (PR) + resolves all packages to OCI + * + * The processing step is shared β€” processPluginsForDeployment handles + * both PR and nightly via isNightlyJob() and GIT_PR_NUMBER detection. + */ + async _buildDynamicPluginsConfig() { + const userConfigPath = this.deploymentConfig.dynamicPlugins; + const userConfigExists = userConfigPath && fs.existsSync(userConfigPath); + const wrapperPlugins = disablePluginWrappers(this.deploymentConfig.disableWrappers); + let config; + if (userConfigExists) { + this._log(`Using user config: ${userConfigPath}`); + config = await this._mergeBaseConfigs(userConfigPath); + } + else { + this._log(`No user config at '${userConfigPath}', auto-generating from metadata...`); + const generated = await generatePluginsFromMetadata(WorkspacePaths.metadataDir); + config = await this._mergeGeneratedWithBase(generated); + } + // Process for deployment: inject metadata (PR only) + resolve all packages to OCI + let result = await processPluginsForDeployment(config, WorkspacePaths.metadataDir); + // Disable wrapper plugins (PR builds only) + if (process.env.GIT_PR_NUMBER) { + result = deepMerge(result, wrapperPlugins, { + arrayMergeStrategy: "concat", + }); + } + return result; + } + async _applyDynamicPlugins() { + const dynamicPluginsYaml = await this._buildDynamicPluginsConfig(); + this._logBoxen("Dynamic Plugins", dynamicPluginsYaml); + await this.k8sClient.applyConfigMapFromObject("dynamic-plugins", dynamicPluginsYaml, this.deploymentConfig.namespace); + } + async _deployWithHelm(valueFile) { + const chartVersion = await this._resolveChartVersion(this.deploymentConfig.version); + this._log(`Helm chart version resolved to: ${chartVersion}`); + const valueFileObject = (await mergeYamlFilesIfExists([ + DEFAULT_CONFIG_PATHS.helm.valueFile, + valueFile, + ])); + this._logBoxen("Value File", valueFileObject); + // Merge dynamic plugins into the values file (including auth-specific plugins) + if (!valueFileObject.global) { + valueFileObject.global = {}; + } + valueFileObject.global.dynamic = await this._buildDynamicPluginsConfig(); + // Set catalog index image if CATALOG_INDEX_IMAGE env var is provided. + // The catalog index provides dynamic-plugins.default.yaml with default plugin + // configurations and versions for the RHDH release. + const catalogIndexImage = process.env.CATALOG_INDEX_IMAGE; + if (catalogIndexImage) { + const [imageRef, tag] = catalogIndexImage.split(":"); + const firstSlash = imageRef.indexOf("/"); + valueFileObject.global.catalogIndex = { + image: { + registry: imageRef.substring(0, firstSlash), + repository: imageRef.substring(firstSlash + 1), + tag: tag || "latest", + }, + }; + this._log(`Catalog index image: ${catalogIndexImage}`); + } + this._logBoxen("Dynamic Plugins", valueFileObject.global.dynamic); + // Escape {{inherit}} for Helm's Go template engine. + // The RHDH chart uses `tpl` on dynamic plugin values, so {{inherit}} would be + // interpreted as a Go template action. Escaping to {{ "{{inherit}}" }} produces + // the literal string {{inherit}} after template rendering. + const valuesYaml = yaml + .dump(valueFileObject) + .replace(/\{\{inherit\}\}/g, '{{ "{{inherit}}" }}'); + const valueFilePath = path.join(os.tmpdir(), `${this.deploymentConfig.namespace}-value-file.yaml`); + fs.writeFileSync(valueFilePath, valuesYaml); + await $ ` + helm upgrade redhat-developer-hub -i "${process.env.CHART_URL || CHART_URL}" --version "${chartVersion}" \ + -f "${valueFilePath}" \ + --set global.clusterRouterBase="${process.env.K8S_CLUSTER_ROUTER_BASE}" \ + --namespace="${this.deploymentConfig.namespace}" + `; + this._log(`Helm deployment completed successfully`); + } + async _deployWithOperator(subscription) { + const subscriptionObject = (await mergeYamlFilesIfExists([ + DEFAULT_CONFIG_PATHS.operator.subscription, + subscription, + ])); + // Set catalog index image if CATALOG_INDEX_IMAGE env var is provided. + const catalogIndexImage = process.env.CATALOG_INDEX_IMAGE; + if (catalogIndexImage) { + const spec = (subscriptionObject.spec ??= {}); + const app = (spec.application ??= {}); + const extraEnvs = (app.extraEnvs ??= + {}); + const envs = (extraEnvs.envs ??= + []); + envs.push({ + name: "CATALOG_INDEX_IMAGE", + value: catalogIndexImage, + containers: ["install-dynamic-plugins"], + }); + this._log(`Catalog index image: ${catalogIndexImage}`); + } + this._logBoxen("Subscription", subscriptionObject); + const subscriptionFilePath = path.join(os.tmpdir(), `${this.deploymentConfig.namespace}-subscription.yaml`); + fs.writeFileSync(subscriptionFilePath, yaml.dump(subscriptionObject)); + const version = this.deploymentConfig.version; + const isSemanticVersion = /^\d+(\.\d+)?$/.test(version); + // Use main branch for non-semantic versions (e.g., "next", "latest") + const branch = isSemanticVersion ? `release-${version}` : "main"; + // Build version argument based on version type + let versionArg; + if (isSemanticVersion) { + versionArg = `-v ${version}`; + } + else if (version === "next") { + versionArg = "--next"; + } + else { + throw new Error(`Invalid RHDH version "${version}". Use semantic version (e.g., "1.5") or "next".`); + } + this._log(`Using operator branch: ${branch}, version arg: ${versionArg}`); + await $ ` + set -e; + curl -sf https://raw.githubusercontent.com/redhat-developer/rhdh-operator/refs/heads/${branch}/.rhdh/scripts/install-rhdh-catalog-source.sh | bash -s -- ${versionArg} --install-operator rhdh + + timeout 300 bash -c ' + while ! oc get crd/backstages.rhdh.redhat.com -n "${this.deploymentConfig.namespace}" >/dev/null 2>&1; do + echo "Waiting for Backstage CRD to be created..." + sleep 20 + done + echo "Backstage CRD is created." + ' || echo "Error: Timed out waiting for Backstage CRD creation." + + oc apply -f "${subscriptionFilePath}" -n "${this.deploymentConfig.namespace}" + `; + this._log("Operator deployment executed successfully."); + } + async rolloutRestart() { + this._log(`Restarting RHDH deployment in namespace ${this.deploymentConfig.namespace}...`); + await $ `oc rollout restart deployment -l 'app.kubernetes.io/instance in (redhat-developer-hub,developer-hub)' -n ${this.deploymentConfig.namespace}`; + this._log(`RHDH deployment restarted successfully in namespace ${this.deploymentConfig.namespace}`); + await this.waitUntilReady(); + } + /** + * Performs a clean restart by scaling down to 0 first, waiting for pods to terminate, + * then scaling back up. This prevents MigrationLocked errors by ensuring no pods + * hold database locks when new pods start. + */ + async scaleDownAndRestart() { + const namespace = this.deploymentConfig.namespace; + await $ `oc scale deployment -l 'app.kubernetes.io/instance in (redhat-developer-hub,developer-hub)' --replicas=0 -n ${namespace}`; + await $ `oc wait --for=delete pod -l 'app.kubernetes.io/instance in (redhat-developer-hub,developer-hub),app.kubernetes.io/name!=postgresql' -n ${namespace} --timeout=120s || true`; + await $ `oc scale deployment -l 'app.kubernetes.io/instance in (redhat-developer-hub,developer-hub)' --replicas=1 -n ${namespace}`; + } + async waitUntilReady(timeout = 500) { + const namespace = this.deploymentConfig.namespace; + const labelSelector = "app.kubernetes.io/instance in (redhat-developer-hub,developer-hub)"; + const startTime = Date.now(); + try { + await this.k8sClient.waitForPodsWithFailureDetection(namespace, labelSelector, timeout); + } + catch (error) { + throw new Error(`RHDH deployment failed in ${namespace}: ${error instanceof Error ? error.message : error}`, { cause: error }); + } + // Use remaining timeout for route readiness check + const remaining = timeout * 1000 - (Date.now() - startTime); + await expect(async () => { + const context = await request.newContext({ ignoreHTTPSErrors: true }); + const response = await context.get(this.rhdhUrl); + await context.dispose(); + expect(response.ok()).toBeTruthy(); + }).toPass({ timeout: Math.max(remaining, 30_000), intervals: [5_000] }); + this._log(`RHDH is ready in ${namespace}`); + } + async teardown() { + await this.k8sClient.deleteNamespace(this.deploymentConfig.namespace); + } + async _deploymentExists() { + try { + await $ `oc get deployment redhat-developer-hub -n ${this.deploymentConfig.namespace} --no-headers 2>/dev/null`; + return true; + } + catch { + return false; + } + } + async _resolveChartVersion(version) { + let resolvedVersion = version; + // Handle "next" tag by looking up the corresponding version from downstream image + if (version === "next") { + resolvedVersion = await this._resolveVersionFromNextTag(); + this._log(`Resolved "next" tag to version: ${resolvedVersion}`); + } + // Semantic versions (e.g., 1.2, 1.10) + if (/^(\d+(\.\d+)?)$/.test(resolvedVersion)) { + const response = await fetch("https://quay.io/api/v1/repository/rhdh/chart/tag/?onlyActiveTags=true&limit=600"); + if (!response.ok) + throw new Error(`Failed to fetch chart versions: ${response.statusText}`); + const data = (await response.json()); + const matching = data.tags + .map((t) => t.name) + .filter((name) => name.startsWith(`${resolvedVersion}-`)) + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + const latest = matching.at(-1); + if (!latest) + throw new Error(`No chart version found for ${resolvedVersion}`); + return latest; + } + // CI build versions (e.g., 1.2.3-CI) + if (resolvedVersion.endsWith("CI")) + return resolvedVersion; + throw new Error(`Invalid Helm chart version format: "${version}"`); + } + /** + * Resolve the semantic version from the "next" tag by looking up the + * downstream image (rhdh-hub-rhel9) and finding tags with the same digest. + */ + async _resolveVersionFromNextTag() { + // Fetch all active tags in a single API call + const response = await fetch("https://quay.io/api/v1/repository/rhdh/rhdh-hub-rhel9/tag/?onlyActiveTags=true&limit=75"); + if (!response.ok) { + throw new Error(`Failed to fetch image tags: ${response.statusText}`); + } + // Use Record to avoid snake_case linting issues with Quay API response + const data = (await response.json()); + // Find the "next" tag and get its digest + const nextTag = data.tags.find((t) => t["name"] === "next"); + if (!nextTag) { + throw new Error('No "next" tag found in rhdh-hub-rhel9 repository'); + } + const digest = nextTag["manifest_digest"]; + this._log(`"next" tag digest: ${digest}`); + // Find semantic version tag (e.g., "1.10") with the same digest + const semanticVersionTag = data.tags.find((t) => t["manifest_digest"] === digest && + /^\d+\.\d+$/.test(t["name"])); + if (!semanticVersionTag) { + throw new Error(`Could not find semantic version tag for "next" (digest: ${digest})`); + } + return semanticVersionTag["name"]; + } + _buildDeploymentConfig(input) { + // Default to "next" if RHDH_VERSION not set + const version = input.version ?? process.env.RHDH_VERSION ?? "next"; + // Default to "helm" if INSTALLATION_METHOD not set + const method = input.method ?? + process.env.INSTALLATION_METHOD ?? + "helm"; + const base = { + version, + namespace: input.namespace ?? this.deploymentConfig.namespace, + auth: input.auth ?? "keycloak", + appConfig: input.appConfig ?? WorkspacePaths.appConfig, + secrets: input.secrets ?? WorkspacePaths.secrets, + dynamicPlugins: input.dynamicPlugins ?? WorkspacePaths.dynamicPlugins, + disableWrappers: input.disableWrappers ?? [], + }; + if (method === "helm") { + return { + ...base, + method, + valueFile: input.valueFile ?? WorkspacePaths.valueFile, + }; + } + else if (method === "operator") { + return { + ...base, + method, + subscription: input.subscription ?? WorkspacePaths.subscription, + }; + } + else { + throw new Error(`Invalid RHDH installation method: ${method}`); + } + } + async configure(deploymentOptions) { + if (deploymentOptions) { + this.deploymentConfig = this._buildDeploymentConfig(deploymentOptions); + this.rhdhUrl = this._buildBaseUrl(); + } + await this.k8sClient.createNamespaceIfNotExists(this.deploymentConfig.namespace); + } + _buildBaseUrl() { + const prefix = this.deploymentConfig.method === "helm" + ? "redhat-developer-hub" + : "backstage-developer-hub"; + const baseUrl = `https://${prefix}-${this.deploymentConfig.namespace}.${process.env.K8S_CLUSTER_ROUTER_BASE}`; + process.env.RHDH_BASE_URL = baseUrl; + return baseUrl; + } + _log(...args) { + console.log("[RHDHDeployment]", ...args); + } + _logBoxen(title, data) { + const content = yaml.dump(data, { lineWidth: -1 }); + console.log(`\nβ”Œβ”€ ${title} ${"─".repeat(60)}`); + console.log(content); + console.log(`β””${"─".repeat(60 + title.length + 3)}\n`); + } +} diff --git a/dist/deployment/rhdh/deployment.test.d.ts b/dist/deployment/rhdh/deployment.test.d.ts new file mode 100644 index 0000000..6eba03c --- /dev/null +++ b/dist/deployment/rhdh/deployment.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=deployment.test.d.ts.map \ No newline at end of file diff --git a/dist/deployment/rhdh/deployment.test.d.ts.map b/dist/deployment/rhdh/deployment.test.d.ts.map new file mode 100644 index 0000000..6f5ccdd --- /dev/null +++ b/dist/deployment/rhdh/deployment.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"deployment.test.d.ts","sourceRoot":"","sources":["../../../src/deployment/rhdh/deployment.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/deployment/rhdh/deployment.test.js b/dist/deployment/rhdh/deployment.test.js new file mode 100644 index 0000000..fbb200f --- /dev/null +++ b/dist/deployment/rhdh/deployment.test.js @@ -0,0 +1,41 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { deepMerge } from "../../utils/merge-yamls.js"; +import { getNormalizedPluginMergeKey } from "../../utils/plugin-metadata.js"; +/** + * Tests the merge behavior used when user dynamic-plugins config does not exist: + * auth config (e.g. keycloak) is merged with metadata config using normalized plugin key. + * Result must have exactly one entry per logical plugin; metadata (source) wins so OCI URL is kept. + */ +describe("dynamic-plugins merge (no user config path)", () => { + it("yields one keycloak plugin with OCI package when auth has local path and metadata has OCI", () => { + const authPlugins = { + plugins: [ + { + package: "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic", + disabled: false, + pluginConfig: {}, + }, + ], + includes: ["dynamic-plugins.default.yaml"], + }; + const metadataConfig = { + plugins: [ + { + package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-catalog-backend-module-keycloak:pr_1980__3.16.0!backstage-community-plugin-catalog-backend-module-keycloak", + disabled: false, + pluginConfig: { catalog: { providers: { keycloakOrg: {} } } }, + }, + ], + }; + const merged = deepMerge(authPlugins, metadataConfig, { + arrayMergeStrategy: { + byKey: "package", + normalizeKey: (item) => getNormalizedPluginMergeKey(item), + }, + }); + const plugins = merged.plugins; + assert.strictEqual(plugins.length, 1, "merged config must have exactly one keycloak plugin"); + assert.ok(plugins[0].package?.startsWith("oci://"), "metadata (OCI) must win over auth local path"); + }); +}); diff --git a/dist/deployment/rhdh/index.d.ts b/dist/deployment/rhdh/index.d.ts new file mode 100644 index 0000000..06647a6 --- /dev/null +++ b/dist/deployment/rhdh/index.d.ts @@ -0,0 +1,2 @@ +export { RHDHDeployment } from "./deployment.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/deployment/rhdh/index.d.ts.map b/dist/deployment/rhdh/index.d.ts.map new file mode 100644 index 0000000..50ffec0 --- /dev/null +++ b/dist/deployment/rhdh/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/deployment/rhdh/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC"} \ No newline at end of file diff --git a/dist/deployment/rhdh/index.js b/dist/deployment/rhdh/index.js new file mode 100644 index 0000000..8e83699 --- /dev/null +++ b/dist/deployment/rhdh/index.js @@ -0,0 +1 @@ +export { RHDHDeployment } from "./deployment.js"; diff --git a/dist/deployment/rhdh/types.d.ts b/dist/deployment/rhdh/types.d.ts new file mode 100644 index 0000000..15af005 --- /dev/null +++ b/dist/deployment/rhdh/types.d.ts @@ -0,0 +1,33 @@ +export type DeploymentMethod = "helm" | "operator"; +export type AuthProvider = "guest" | "keycloak" | "github"; +export type DeploymentOptions = { + version?: string; + namespace?: string; + auth?: AuthProvider; + appConfig?: string; + secrets?: string; + dynamicPlugins?: string; + method?: DeploymentMethod; + valueFile?: string; + subscription?: string; + disableWrappers?: string[]; +}; +export type HelmDeploymentConfig = { + method: "helm"; + valueFile: string; +}; +export type OperatorDeploymentConfig = { + method: "operator"; + subscription: string; +}; +export type DeploymentConfigBase = { + version: string; + namespace: string; + auth: AuthProvider; + appConfig: string; + secrets: string; + dynamicPlugins: string; + disableWrappers: string[]; +}; +export type DeploymentConfig = DeploymentConfigBase & (HelmDeploymentConfig | OperatorDeploymentConfig); +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/dist/deployment/rhdh/types.d.ts.map b/dist/deployment/rhdh/types.d.ts.map new file mode 100644 index 0000000..f809571 --- /dev/null +++ b/dist/deployment/rhdh/types.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/deployment/rhdh/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,UAAU,CAAC;AACnD,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,CAAC;AAE3D,MAAM,MAAM,iBAAiB,GAAG;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,gBAAgB,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG;IACrC,MAAM,EAAE,UAAU,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,YAAY,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,MAAM,EAAE,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG,oBAAoB,GACjD,CAAC,oBAAoB,GAAG,wBAAwB,CAAC,CAAC"} \ No newline at end of file diff --git a/dist/deployment/rhdh/types.js b/dist/deployment/rhdh/types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/deployment/rhdh/types.js @@ -0,0 +1 @@ +export {}; diff --git a/dist/eslint/base.config.d.ts b/dist/eslint/base.config.d.ts new file mode 100644 index 0000000..9b21583 --- /dev/null +++ b/dist/eslint/base.config.d.ts @@ -0,0 +1,10 @@ +import type { Linter } from "eslint"; +/** + * Creates a base ESLint configuration for RHDH E2E tests. + * This configuration includes TypeScript, Playwright, and file naming conventions. + * + * @param tsconfigRootDir - The root directory for tsconfig.json resolution + * @returns ESLint flat config array + */ +export declare function createEslintConfig(tsconfigRootDir: string): Linter.Config[]; +//# sourceMappingURL=base.config.d.ts.map \ No newline at end of file diff --git a/dist/eslint/base.config.d.ts.map b/dist/eslint/base.config.d.ts.map new file mode 100644 index 0000000..442fb61 --- /dev/null +++ b/dist/eslint/base.config.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"base.config.d.ts","sourceRoot":"","sources":["../../src/eslint/base.config.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAErC;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,eAAe,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,CAmN3E"} \ No newline at end of file diff --git a/dist/eslint/base.config.js b/dist/eslint/base.config.js new file mode 100644 index 0000000..2f2aca0 --- /dev/null +++ b/dist/eslint/base.config.js @@ -0,0 +1,220 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import checkFile from "eslint-plugin-check-file"; +import playwright from "eslint-plugin-playwright"; +/** + * Creates a base ESLint configuration for RHDH E2E tests. + * This configuration includes TypeScript, Playwright, and file naming conventions. + * + * @param tsconfigRootDir - The root directory for tsconfig.json resolution + * @returns ESLint flat config array + */ +export function createEslintConfig(tsconfigRootDir) { + return [ + // Global ignores - must be first for ESLint flat config + { + ignores: [ + "node_modules/**", + "playwright-report/**", + "test-results/**", + "blob-report/**", + "*.config.js", + "dist/**", + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.ts"], + languageOptions: { + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir, + }, + }, + rules: { + // TypeScript naming conventions + "@typescript-eslint/naming-convention": [ + "error", + { + selector: "variable", + format: ["camelCase", "PascalCase"], + leadingUnderscore: "allow", + }, + { + selector: "variable", + modifiers: ["const"], + format: ["camelCase", "PascalCase", "UPPER_CASE"], + }, + { + selector: "function", + format: ["camelCase", "PascalCase"], + }, + { + selector: "parameter", + format: ["camelCase", "PascalCase"], + leadingUnderscore: "allow", + }, + { + selector: "typeLike", + format: ["PascalCase"], + }, + { + selector: "enumMember", + format: ["PascalCase"], + }, + { + selector: "memberLike", + modifiers: ["private"], + format: ["camelCase"], + leadingUnderscore: "allow", + }, + { + selector: "memberLike", + modifiers: ["public"], + format: ["camelCase"], + }, + // Allow HTTP headers in object literals which require specific formats + { + selector: "objectLiteralProperty", + format: null, + filter: { + regex: "^(Accept|Authorization|Content-Type|X-GitHub-Api-Version|X-[A-Za-z-]+)$", + match: true, + }, + }, + ], + // Promise handling + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/await-thenable": "error", + "@typescript-eslint/no-misused-promises": "error", + // Allow any type in tests (for mocking, test data) + "@typescript-eslint/no-explicit-any": "warn", + // Prefer modern syntax + "@typescript-eslint/prefer-optional-chain": "error", + // Allow unused vars starting with underscore + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + // Allow empty functions (for test stubs) + "@typescript-eslint/no-empty-function": "off", + }, + }, + { + files: ["**/*.{js,ts}"], + plugins: { + "check-file": checkFile, + }, + rules: { + "check-file/filename-naming-convention": [ + "error", + { + "**/*.{js,ts}": "KEBAB_CASE", + }, + { + ignoreMiddleExtensions: true, + }, + ], + "check-file/folder-naming-convention": [ + "error", + { + "**": "KEBAB_CASE", + }, + ], + }, + }, + // Playwright test files + { + ...playwright.configs["flat/recommended"], + files: [ + "**/*.spec.ts", + "**/*.test.ts", + "**/tests/**/*.ts", + "**/e2e/**/*.ts", + ], + rules: { + ...playwright.configs["flat/recommended"].rules, + // Playwright best practices + "playwright/expect-expect": "warn", + "playwright/max-nested-describe": ["warn", { max: 2 }], + "playwright/missing-playwright-await": "error", + "playwright/no-conditional-in-test": "warn", + "playwright/no-element-handle": "warn", + "playwright/no-eval": "error", + "playwright/no-focused-test": "error", + "playwright/no-force-option": "warn", + "playwright/no-page-pause": "warn", + "playwright/no-skipped-test": [ + "warn", + { + allowConditional: true, + }, + ], + "playwright/no-useless-await": "warn", + "playwright/no-useless-not": "warn", + "playwright/no-wait-for-selector": "warn", + "playwright/no-wait-for-timeout": "warn", + "playwright/prefer-web-first-assertions": "error", + "playwright/require-top-level-describe": "off", + "playwright/valid-describe-callback": "off", + "playwright/valid-expect": "error", + "playwright/valid-title": "warn", + // Custom restrictions + "no-restricted-syntax": [ + "error", + { + selector: "CallExpression[callee.property.name='fixme'][callee.object.property.name='describe'][callee.object.object.name='test']", + message: "test.describe.fixme() is not valid. Use test.fixme() on individual tests instead.", + }, + ], + // Disallow console.log in tests (use test.info() instead) + "no-console": [ + "warn", + { + allow: ["warn", "error"], + }, + ], + }, + }, + // Page Object Models - require class suffix + { + files: ["**/page-objects/**/*.ts", "**/page/**/*.ts", "**/pages/**/*.ts"], + rules: { + "@typescript-eslint/naming-convention": [ + "error", + { + selector: "class", + format: ["PascalCase"], + suffix: ["Page", "Component", "PO"], + }, + ], + }, + }, + // Fixtures + { + files: ["**/fixtures/**/*.ts"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + }, + }, + // Config files + { + files: ["playwright.config.ts", "**/*.config.ts"], + rules: { + "@typescript-eslint/naming-convention": "off", + "check-file/filename-naming-convention": "off", + }, + }, + // Node test runner (*.test.ts) - describe/it return promises the runner handles + { + files: ["**/*.test.ts"], + rules: { + "@typescript-eslint/no-floating-promises": "off", + }, + }, + ]; +} diff --git a/dist/playwright/base-config.d.ts b/dist/playwright/base-config.d.ts new file mode 100644 index 0000000..09bfc91 --- /dev/null +++ b/dist/playwright/base-config.d.ts @@ -0,0 +1,14 @@ +import { PlaywrightTestConfig } from "@playwright/test"; +/** + * Base Playwright configuration that can be extended by workspace-specific configs. + * Provides sensible defaults for RHDH plugin e2e testing. + */ +export declare const baseConfig: PlaywrightTestConfig; +/** + * Defines a workspace-specific config by merging with base config. + * Only allows overriding the projects configuration. + * @param overrides - Object containing projects to override + * @returns Merged Playwright configuration + */ +export declare function defineConfig(overrides?: Pick): PlaywrightTestConfig; +//# sourceMappingURL=base-config.d.ts.map \ No newline at end of file diff --git a/dist/playwright/base-config.d.ts.map b/dist/playwright/base-config.d.ts.map new file mode 100644 index 0000000..d40f1be --- /dev/null +++ b/dist/playwright/base-config.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"base-config.d.ts","sourceRoot":"","sources":["../../src/playwright/base-config.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,oBAAoB,EACrB,MAAM,kBAAkB,CAAC;AAG1B;;;GAGG;AACH,eAAO,MAAM,UAAU,EAAE,oBA8BxB,CAAC;AAEF;;;;;GAKG;AAEH,wBAAgB,YAAY,CAC1B,SAAS,GAAE,IAAI,CAAC,oBAAoB,EAAE,UAAU,CAAM,GACrD,oBAAoB,CAKtB"} \ No newline at end of file diff --git a/dist/playwright/base-config.js b/dist/playwright/base-config.js new file mode 100644 index 0000000..32c9bbe --- /dev/null +++ b/dist/playwright/base-config.js @@ -0,0 +1,49 @@ +import { defineConfig as baseDefineConfig, } from "@playwright/test"; +import { resolve } from "path"; +/** + * Base Playwright configuration that can be extended by workspace-specific configs. + * Provides sensible defaults for RHDH plugin e2e testing. + */ +export const baseConfig = { + testDir: "./tests", + forbidOnly: !!process.env.CI, + retries: Number(process.env.PLAYWRIGHT_RETRIES ?? 0), + workers: process.env.PLAYWRIGHT_WORKERS || "50%", + outputDir: "node_modules/.cache/e2e-test-results", + timeout: 90_000, + reporter: [ + ["list"], + ["html", { outputFolder: "playwright-report", open: "on-failure" }], + ["json", { outputFile: "playwright-report/results.json" }], + ["junit", { outputFile: "playwright-report/junit-results.xml" }], + [resolve(import.meta.dirname, "../playwright/teardown-reporter.js")], + ], + use: { + ignoreHTTPSErrors: true, + trace: "retain-on-failure", + screenshot: "only-on-failure", + viewport: { width: 1920, height: 1080 }, + video: { + mode: "retain-on-failure", + size: { width: 1280, height: 720 }, + }, + actionTimeout: 10_000, + navigationTimeout: 50_000, + }, + expect: { + timeout: 10_000, + }, + globalSetup: resolve(import.meta.dirname, "../playwright/global-setup.js"), +}; +/** + * Defines a workspace-specific config by merging with base config. + * Only allows overriding the projects configuration. + * @param overrides - Object containing projects to override + * @returns Merged Playwright configuration + */ +export function defineConfig(overrides = {}) { + return baseDefineConfig({ + ...baseConfig, + projects: overrides.projects, + }); +} diff --git a/dist/playwright/fixtures/test.d.ts b/dist/playwright/fixtures/test.d.ts new file mode 100644 index 0000000..357b089 --- /dev/null +++ b/dist/playwright/fixtures/test.d.ts @@ -0,0 +1,17 @@ +import { RHDHDeployment } from "../../deployment/rhdh/index.js"; +import { LoginHelper, UIhelper } from "../helpers/index.js"; +import { runOnce } from "../run-once.js"; +type RHDHDeploymentTestFixtures = { + rhdh: RHDHDeployment; + uiHelper: UIhelper; + loginHelper: LoginHelper; + autoAnnotations: void; +}; +type RHDHDeploymentWorkerFixtures = { + rhdhDeploymentWorker: RHDHDeployment; +}; +export declare const test: import("playwright/test").TestType & { + runOnce: typeof runOnce; +}; +export * from "@playwright/test"; +//# sourceMappingURL=test.d.ts.map \ No newline at end of file diff --git a/dist/playwright/fixtures/test.d.ts.map b/dist/playwright/fixtures/test.d.ts.map new file mode 100644 index 0000000..9f459f1 --- /dev/null +++ b/dist/playwright/fixtures/test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"test.d.ts","sourceRoot":"","sources":["../../../src/playwright/fixtures/test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAEhE,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAKzC,KAAK,0BAA0B,GAAG;IAChC,IAAI,EAAE,cAAc,CAAC;IACrB,QAAQ,EAAE,QAAQ,CAAC;IACnB,WAAW,EAAE,WAAW,CAAC;IACzB,eAAe,EAAE,IAAI,CAAC;CACvB,CAAC;AAEF,KAAK,4BAA4B,GAAG;IAClC,oBAAoB,EAAE,cAAc,CAAC;CACtC,CAAC;AAgEF,eAAO,MAAM,IAAI;;CAEf,CAAC;AAEH,cAAc,kBAAkB,CAAC"} \ No newline at end of file diff --git a/dist/playwright/fixtures/test.js b/dist/playwright/fixtures/test.js new file mode 100644 index 0000000..8eab45c --- /dev/null +++ b/dist/playwright/fixtures/test.js @@ -0,0 +1,63 @@ +import { RHDHDeployment } from "../../deployment/rhdh/index.js"; +import { test as base } from "@playwright/test"; +import { LoginHelper, UIhelper } from "../helpers/index.js"; +import { runOnce } from "../run-once.js"; +import { $ } from "../../utils/bash.js"; +import { WorkspacePaths } from "../../utils/workspace-paths.js"; +import path from "path"; +const baseTest = base.extend({ + rhdhDeploymentWorker: [ + // eslint-disable-next-line no-empty-pattern + async ({}, use, workerInfo) => { + // Set CWD to the workspace's e2e-tests directory so that relative + // config paths resolve correctly even when Playwright runs from the repo root. + // Each worker is a separate process, so this doesn't affect other workers. + const e2eRoot = path.resolve(workerInfo.project.testDir, ".."); + process.chdir(e2eRoot); + $.cwd = e2eRoot; + const rhdhDeployment = new RHDHDeployment(workerInfo.project.name); + await rhdhDeployment.configure(); + await use(rhdhDeployment); + }, + { scope: "worker", auto: true }, + ], + rhdh: [ + async ({ rhdhDeploymentWorker }, use) => { + await use(rhdhDeploymentWorker); + }, + { auto: true, scope: "test" }, + ], + uiHelper: [ + async ({ page }, use) => { + await use(new UIhelper(page)); + }, + { scope: "test" }, + ], + loginHelper: [ + async ({ page }, use) => { + await use(new LoginHelper(page)); + }, + { scope: "test" }, + ], + baseURL: [ + async ({ rhdhDeploymentWorker }, use) => { + await use(rhdhDeploymentWorker.rhdhUrl); + }, + { scope: "test" }, + ], + autoAnnotations: [ + // eslint-disable-next-line no-empty-pattern + async ({}, use, testInfo) => { + testInfo.annotations.push({ + type: "workspace", + description: path.basename(WorkspacePaths.workspaceRoot), + }, { type: "project", description: testInfo.project.name }); + await use(); + }, + { auto: true, scope: "test" }, + ], +}); +export const test = Object.assign(baseTest, { + runOnce, +}); +export * from "@playwright/test"; diff --git a/dist/playwright/global-setup.d.ts b/dist/playwright/global-setup.d.ts new file mode 100644 index 0000000..9db6b81 --- /dev/null +++ b/dist/playwright/global-setup.d.ts @@ -0,0 +1,7 @@ +/** + * Global setup for Playwright tests. + * This file runs once before all tests. + */ +import { type FullConfig } from "@playwright/test"; +export default function globalSetup(config: FullConfig): Promise; +//# sourceMappingURL=global-setup.d.ts.map \ No newline at end of file diff --git a/dist/playwright/global-setup.d.ts.map b/dist/playwright/global-setup.d.ts.map new file mode 100644 index 0000000..90d2931 --- /dev/null +++ b/dist/playwright/global-setup.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"global-setup.d.ts","sourceRoot":"","sources":["../../src/playwright/global-setup.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AA6EnD,wBAA8B,WAAW,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAQ3E"} \ No newline at end of file diff --git a/dist/playwright/global-setup.js b/dist/playwright/global-setup.js new file mode 100644 index 0000000..802c89b --- /dev/null +++ b/dist/playwright/global-setup.js @@ -0,0 +1,87 @@ +/** + * Global setup for Playwright tests. + * This file runs once before all tests. + */ +import dotenv from "dotenv"; +import { resolve } from "path"; +import { KubernetesClientHelper } from "../utils/kubernetes-client.js"; +import { $ } from "../utils/bash.js"; +import { KeycloakHelper } from "../deployment/keycloak/index.js"; +import { DEFAULT_KEYCLOAK_CONFIG, DEFAULT_RHDH_CLIENT, DEFAULT_USERS, } from "../deployment/keycloak/constants.js"; +import { loadLocalVaultSecrets } from "../utils/vault.js"; +const REQUIRED_BINARIES = ["oc", "kubectl", "helm"]; +async function checkRequiredBinaries() { + const missingBinaries = []; + for (const binary of REQUIRED_BINARIES) { + try { + await $ `command -v ${binary} > /dev/null 2>&1`; + } + catch { + missingBinaries.push(binary); + } + } + if (missingBinaries.length > 0) { + throw new Error(`ERROR: Missing required binaries: ${missingBinaries.join(", ")}. Please install them before running tests.`); + } +} +async function setClusterRouterBaseEnv() { + const k8sClient = new KubernetesClientHelper(); + process.env.K8S_CLUSTER_ROUTER_BASE = + await k8sClient.getClusterIngressDomain(); + console.log(`Cluster router base: ${process.env.K8S_CLUSTER_ROUTER_BASE}`); +} +async function deployKeycloak() { + if (process.env.SKIP_KEYCLOAK_DEPLOYMENT === "true") { + console.log("Skipping Keycloak deployment"); + return; + } + console.log("Set SKIP_KEYCLOAK_DEPLOYMENT=true if test doesn't require keycloak/oidc as auth provider"); + const keycloak = new KeycloakHelper({ namespace: "rhdh-keycloak" }); + // Check if Keycloak is already running + if (await keycloak.isRunning()) { + console.log("Keycloak is already running, skipping deployment"); + } + else { + await keycloak.deploy(); + await keycloak.configureForRHDH(); + } + // Set environment variables for RHDH integration + const realm = DEFAULT_KEYCLOAK_CONFIG.realm; + process.env.KEYCLOAK_CLIENT_SECRET = DEFAULT_RHDH_CLIENT.clientSecret; + process.env.KEYCLOAK_CLIENT_ID = DEFAULT_RHDH_CLIENT.clientId; + process.env.KEYCLOAK_REALM = realm; + process.env.KEYCLOAK_LOGIN_REALM = realm; + process.env.KEYCLOAK_METADATA_URL = `${keycloak.keycloakUrl}/realms/${realm}`; + process.env.KEYCLOAK_BASE_URL = keycloak.keycloakUrl; + console.table({ + keycloakURL: keycloak.keycloakUrl, + adminUser: keycloak.deploymentConfig.adminUser, + adminPassword: keycloak.deploymentConfig.adminPassword, + testUsername: DEFAULT_USERS[0].username, + testPassword: DEFAULT_USERS[0].password, + }); +} +export default async function globalSetup(config) { + console.log("Running global setup..."); + await checkRequiredBinaries(); + await loadLocalVaultSecrets(); + loadDotenvFromProjects(config); + await setClusterRouterBaseEnv(); + await deployKeycloak(); + console.log("Global setup completed successfully"); +} +/** + * Loads .env files from each project's e2e-tests directory. + * Uses `override: true` so local .env values take priority over Vault secrets. + */ +function loadDotenvFromProjects(config) { + const seen = new Set(); + for (const project of config.projects) { + // testDir points to e2e-tests/tests, go up one level to e2e-tests/ + const e2eRoot = resolve(project.testDir, ".."); + if (seen.has(e2eRoot)) + continue; + seen.add(e2eRoot); + dotenv.config({ path: resolve(e2eRoot, ".env"), override: true }); + } +} diff --git a/dist/playwright/helpers/accessibility.d.ts b/dist/playwright/helpers/accessibility.d.ts new file mode 100644 index 0000000..d28360a --- /dev/null +++ b/dist/playwright/helpers/accessibility.d.ts @@ -0,0 +1,13 @@ +import type { Page } from "@playwright/test"; +export interface AccessibilityTestOptions { + /** Custom name for the attached results file. Defaults to "accessibility-scan-results.violations.json" */ + attachName?: string; + /** Whether to assert that there are no violations. Defaults to true */ + assertNoViolations?: boolean; + /** WCAG tags to test against. Defaults to ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] */ + wcagTags?: string[]; + /** Rules to disable during the scan. Defaults to ["color-contrast"] */ + disabledRules?: string[]; +} +export declare function runAccessibilityTests(page: Page, options?: AccessibilityTestOptions): Promise; +//# sourceMappingURL=accessibility.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/accessibility.d.ts.map b/dist/playwright/helpers/accessibility.d.ts.map new file mode 100644 index 0000000..f604ba3 --- /dev/null +++ b/dist/playwright/helpers/accessibility.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"accessibility.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/accessibility.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAG7C,MAAM,WAAW,wBAAwB;IACvC,0GAA0G;IAC1G,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,0FAA0F;IAC1F,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,uEAAuE;IACvE,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AASD,wBAAsB,qBAAqB,CACzC,IAAI,EAAE,IAAI,EACV,OAAO,GAAE,wBAA6B,0CAuBvC"} \ No newline at end of file diff --git a/dist/playwright/helpers/accessibility.js b/dist/playwright/helpers/accessibility.js new file mode 100644 index 0000000..6a6d097 --- /dev/null +++ b/dist/playwright/helpers/accessibility.js @@ -0,0 +1,24 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; +const DEFAULT_OPTIONS = { + attachName: "accessibility-scan-results.violations.json", + assertNoViolations: true, + wcagTags: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"], + disabledRules: ["color-contrast"], +}; +export async function runAccessibilityTests(page, options = {}) { + const config = { ...DEFAULT_OPTIONS, ...options }; + const testInfo = test.info(); + const accessibilityScanResults = await new AxeBuilder({ page }) + .withTags(config.wcagTags) + .disableRules(config.disabledRules) + .analyze(); + await testInfo.attach(config.attachName, { + body: JSON.stringify(accessibilityScanResults.violations, null, 2), + contentType: "application/json", + }); + if (config.assertNoViolations) { + expect(accessibilityScanResults.violations, `Found ${accessibilityScanResults.violations.length} accessibility violation(s)`).toHaveLength(0); + } + return accessibilityScanResults; +} diff --git a/dist/playwright/helpers/api-endpoints.d.ts b/dist/playwright/helpers/api-endpoints.d.ts new file mode 100644 index 0000000..35d2f8b --- /dev/null +++ b/dist/playwright/helpers/api-endpoints.d.ts @@ -0,0 +1,13 @@ +export declare const GITHUB_API_ENDPOINTS: { + pull: (owner: string, repo: string, state: "open" | "closed" | "all") => string; + issues: (state: string) => string; + workflowRuns: string; + getOrg: (owner: string) => string; + createRepo: (owner: string) => string; + getRepo: (owner: string, repo: string) => string; + deleteRepo: (owner: string, repo: string) => string; + mergePR: (owner: string, repoName: string, pullNumber: number) => string; + pullFiles: (owner: string, repoName: string, pr: number) => string; + contents: (owner: string, repoName: string) => string; +}; +//# sourceMappingURL=api-endpoints.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/api-endpoints.d.ts.map b/dist/playwright/helpers/api-endpoints.d.ts.map new file mode 100644 index 0000000..b79aedd --- /dev/null +++ b/dist/playwright/helpers/api-endpoints.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"api-endpoints.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/api-endpoints.ts"],"names":[],"mappings":"AASA,eAAO,MAAM,oBAAoB;kBACjB,MAAM,QAAQ,MAAM,SAAS,MAAM,GAAG,QAAQ,GAAG,KAAK;oBAGpD,MAAM;;oBARE,MAAM;wBAeV,MAAM;qBAjBD,MAAM,QAAQ,MAAM;wBAApB,MAAM,QAAQ,MAAM;qBAuB5B,MAAM,YAAY,MAAM,cAAc,MAAM;uBAG1C,MAAM,YAAY,MAAM,MAAM,MAAM;sBAGrC,MAAM,YAAY,MAAM;CAE3C,CAAC"} \ No newline at end of file diff --git a/dist/playwright/helpers/api-endpoints.js b/dist/playwright/helpers/api-endpoints.js new file mode 100644 index 0000000..f665e5c --- /dev/null +++ b/dist/playwright/helpers/api-endpoints.js @@ -0,0 +1,17 @@ +const baseApiUrl = "https://api.github.com"; +const perPage = 100; +const getRepoUrl = (owner, repo) => `${baseApiUrl}/repos/${owner}/${repo}`; +const getOrgUrl = (owner) => `${baseApiUrl}/orgs/${owner}`; +const backstageShowcaseAPI = getRepoUrl("janus-idp", "backstage-showcase"); +export const GITHUB_API_ENDPOINTS = { + pull: (owner, repo, state) => `${getRepoUrl(owner, repo)}/pulls?per_page=${perPage}&state=${state}`, + issues: (state) => `${backstageShowcaseAPI}/issues?per_page=${perPage}&sort=updated&state=${state}`, + workflowRuns: `${backstageShowcaseAPI}/actions/runs?per_page=${perPage}`, + getOrg: getOrgUrl, + createRepo: (owner) => `${getOrgUrl(owner)}/repos`, + getRepo: getRepoUrl, + deleteRepo: getRepoUrl, + mergePR: (owner, repoName, pullNumber) => `${getRepoUrl(owner, repoName)}/pulls/${pullNumber}/merge`, + pullFiles: (owner, repoName, pr) => `${getRepoUrl(owner, repoName)}/pulls/${pr}/files`, + contents: (owner, repoName) => `${getRepoUrl(owner, repoName)}/contents`, +}; diff --git a/dist/playwright/helpers/api-helper.d.ts b/dist/playwright/helpers/api-helper.d.ts new file mode 100644 index 0000000..2c48028 --- /dev/null +++ b/dist/playwright/helpers/api-helper.d.ts @@ -0,0 +1,77 @@ +import type { APIResponse } from "@playwright/test"; +export declare class APIHelper { + private static githubAPIVersion; + private staticToken; + private baseUrl; + useStaticToken: boolean; + static githubRequest(method: string, url: string, body?: string | object): Promise; + static getGithubPaginatedRequest(url: string, pageNo?: number, response?: unknown[]): Promise; + static createGitHubRepo(owner: string, repoName: string): Promise; + static createGitHubRepoWithFile(owner: string, repoName: string, filename: string, fileContent: string): Promise; + static createFileInRepo(owner: string, repoName: string, filePath: string, content: string, commitMessage: string, branch?: string): Promise; + static initCommit(owner: string, repo: string, branch?: string): Promise; + static deleteGitHubRepo(owner: string, repoName: string): Promise; + static mergeGitHubPR(owner: string, repoName: string, pullNumber: number): Promise; + static getGitHubPRs(owner: string, repoName: string, state: "open" | "closed" | "all", paginated?: boolean): Promise; + static getfileContentFromPR(owner: string, repoName: string, pr: number, filename: string): Promise; + getGuestToken(): Promise; + getGuestAuthHeader(): Promise<{ + [key: string]: string; + }>; + setStaticToken(token: string): Promise; + setBaseUrl(url: string): Promise; + static apiRequestWithStaticToken(method: string, url: string, staticToken: string, body?: string | object): Promise; + getAllCatalogUsersFromAPI(): Promise; + getAllCatalogLocationsFromAPI(): Promise; + getAllCatalogGroupsFromAPI(): Promise; + getGroupEntityFromAPI(group: string): Promise; + getCatalogUserFromAPI(user: string): Promise; + deleteUserEntityFromAPI(user: string): Promise<(() => string) | undefined>; + getCatalogGroupFromAPI(group: string): Promise; + deleteGroupEntityFromAPI(group: string): Promise<() => string>; + scheduleEntityRefreshFromAPI(entity: string, kind: string, token: string): Promise; + /** + * Fetches the UID of an entity by its name from the Backstage catalog. + * + * @param name - The name of the entity (e.g., 'hello-world-2'). + * @returns The UID string if found, otherwise undefined. + */ + static getEntityUidByName(name: string): Promise; + /** + * Deletes a location from the Backstage catalog by its UID. + * + * @param uid - The UID of the location to delete. + * @returns The status code of the delete operation. + */ + static deleteLocationByUid(uid: string): Promise; + /** + * Fetches the UID of a Template entity by its name and namespace from the Backstage catalog. + * + * @param name - The name of the template entity (e.g., 'hello-world-2'). + * @param namespace - The namespace of the template entity (default: 'default'). + * @returns The UID string if found, otherwise undefined. + */ + static getTemplateEntityUidByName(name: string, namespace?: string): Promise; + /** + * Deletes an entity location from the Backstage catalog by its ID. + * + * @param id - The ID of the entity to delete. + * @returns The status code of the delete operation. + */ + static deleteEntityLocationById(id: string): Promise; + /** + * Registers a new location in the Backstage catalog. + * + * @param target - The target URL of the location to register. + * @returns The status code of the registration operation. + */ + static registerLocation(target: string): Promise; + /** + * Fetches the ID of a location from the Backstage catalog by its target URL. + * + * @param target - The target URL of the location to search for. + * @returns The ID string if found, otherwise undefined. + */ + static getLocationIdByTarget(target: string): Promise; +} +//# sourceMappingURL=api-helper.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/api-helper.d.ts.map b/dist/playwright/helpers/api-helper.d.ts.map new file mode 100644 index 0000000..3c011db --- /dev/null +++ b/dist/playwright/helpers/api-helper.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"api-helper.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/api-helper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAKpD,qBAAa,SAAS;IACpB,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAgB;IAC/C,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,OAAO,CAAc;IAC7B,cAAc,UAAS;WAEV,aAAa,CACxB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACX,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GACrB,OAAO,CAAC,WAAW,CAAC;WAuBV,yBAAyB,CACpC,GAAG,EAAE,MAAM,EACX,MAAM,GAAE,MAAU,EAClB,QAAQ,GAAE,OAAO,EAAO,GACvB,OAAO,CAAC,OAAO,EAAE,CAAC;WAmBR,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;WAYhD,wBAAwB,CACnC,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM;WAgCR,gBAAgB,CAC3B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,aAAa,EAAE,MAAM,EACrB,MAAM,SAAS;WAeJ,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,SAAS;WAgBvD,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;WAOhD,aAAa,CACxB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM;WAQP,YAAY,CACvB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GAAG,QAAQ,GAAG,KAAK,EAChC,SAAS,UAAQ;WAUN,oBAAoB,CAC/B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,MAAM,CAAC;IAcZ,aAAa,IAAI,OAAO,CAAC,MAAM,CAAC;IAQhC,kBAAkB,IAAI,OAAO,CAAC;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAQxD,cAAc,CAAC,KAAK,EAAE,MAAM;IAK5B,UAAU,CAAC,GAAG,EAAE,MAAM;WAIf,yBAAyB,CACpC,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,MAAM,EACnB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GACrB,OAAO,CAAC,WAAW,CAAC;IAejB,yBAAyB;IAWzB,6BAA6B;IAW7B,0BAA0B;IAW1B,qBAAqB,CAAC,KAAK,EAAE,MAAM;IAWnC,qBAAqB,CAAC,IAAI,EAAE,MAAM;IAWlC,uBAAuB,CAAC,IAAI,EAAE,MAAM;IAepC,sBAAsB,CAAC,KAAK,EAAE,MAAM;IAWpC,wBAAwB,CAAC,KAAK,EAAE,MAAM;IAYtC,4BAA4B,CAChC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM;IAaf;;;;;OAKG;WACU,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAY1E;;;;;OAKG;WACU,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAQ9D;;;;;;OAMG;WACU,0BAA0B,CACrC,IAAI,EAAE,MAAM,EACZ,SAAS,GAAE,MAAkB,GAC5B,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAe9B;;;;;OAKG;WACU,wBAAwB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAQlE;;;;;OAKG;WACU,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAgB9D;;;;;OAKG;WACU,qBAAqB,CAChC,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;CAe/B"} \ No newline at end of file diff --git a/dist/playwright/helpers/api-helper.js b/dist/playwright/helpers/api-helper.js new file mode 100644 index 0000000..1a8714d --- /dev/null +++ b/dist/playwright/helpers/api-helper.js @@ -0,0 +1,295 @@ +import { request, expect } from "@playwright/test"; +import { GITHUB_API_ENDPOINTS } from "./api-endpoints.js"; +export class APIHelper { + static githubAPIVersion = "2022-11-28"; + staticToken = ""; + baseUrl = ""; + useStaticToken = false; + static async githubRequest(method, url, body) { + const context = await request.newContext(); + const options = { + method: method, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${process.env.VAULT_GITHUB_USER_TOKEN}`, + "X-GitHub-Api-Version": this.githubAPIVersion, + }, + }; + if (body) { + options.data = body; + } + const response = await context.fetch(url, options); + return response; + } + static async getGithubPaginatedRequest(url, pageNo = 1, response = []) { + const fullUrl = `${url}&page=${pageNo}`; + const result = await this.githubRequest("GET", fullUrl); + const body = await result.json(); + if (!Array.isArray(body)) { + throw new Error(`Expected array but got ${typeof body}: ${JSON.stringify(body)}`); + } + if (body.length === 0) { + return response; + } + response = [...response, ...body]; + return await this.getGithubPaginatedRequest(url, pageNo + 1, response); + } + static async createGitHubRepo(owner, repoName) { + const response = await APIHelper.githubRequest("POST", GITHUB_API_ENDPOINTS.createRepo(owner), { + name: repoName, + private: false, + }); + expect(response.status() === 201 || response.ok()).toBeTruthy(); + } + static async createGitHubRepoWithFile(owner, repoName, filename, fileContent) { + // Create the repository + await APIHelper.createGitHubRepo(owner, repoName); + // Wait until repository is created + await expect + .poll(async () => { + const res = await APIHelper.githubRequest("GET", GITHUB_API_ENDPOINTS.getRepo(owner, repoName)); + return res.status(); + }, { + timeout: 30_000, + intervals: [5000], + }) + .toBe(200); + // Add the specified file + await APIHelper.createFileInRepo(owner, repoName, filename, fileContent, `Add ${filename} file`); + } + static async createFileInRepo(owner, repoName, filePath, content, commitMessage, branch = "main") { + const encodedContent = Buffer.from(content).toString("base64"); + const response = await APIHelper.githubRequest("PUT", `${GITHUB_API_ENDPOINTS.contents(owner, repoName)}/${filePath}`, { + message: commitMessage, + content: encodedContent, + branch: branch, + }); + expect(response.status() === 201 || response.ok()).toBeTruthy(); + } + static async initCommit(owner, repo, branch = "main") { + const content = Buffer.from("This is the initial commit for the repository.").toString("base64"); + const response = await APIHelper.githubRequest("PUT", `${GITHUB_API_ENDPOINTS.contents(owner, repo)}/initial-commit.md`, { + message: "Initial commit", + content: content, + branch: branch, + }); + expect(response.status() === 201 || response.ok()).toBeTruthy(); + } + static async deleteGitHubRepo(owner, repoName) { + await APIHelper.githubRequest("DELETE", GITHUB_API_ENDPOINTS.deleteRepo(owner, repoName)); + } + static async mergeGitHubPR(owner, repoName, pullNumber) { + await APIHelper.githubRequest("PUT", GITHUB_API_ENDPOINTS.mergePR(owner, repoName, pullNumber)); + } + static async getGitHubPRs(owner, repoName, state, paginated = false) { + const url = GITHUB_API_ENDPOINTS.pull(owner, repoName, state); + if (paginated) { + return await APIHelper.getGithubPaginatedRequest(url); + } + const response = await APIHelper.githubRequest("GET", url); + return response.json(); + } + static async getfileContentFromPR(owner, repoName, pr, filename) { + const response = await APIHelper.githubRequest("GET", GITHUB_API_ENDPOINTS.pullFiles(owner, repoName, pr)); + const fileRawUrl = (await response.json()).find((file) => file.filename === filename).raw_url; + const rawFileContent = await (await APIHelper.githubRequest("GET", fileRawUrl)).text(); + return rawFileContent; + } + async getGuestToken() { + const context = await request.newContext(); + const response = await context.post("/api/auth/guest/refresh"); + expect(response.status()).toBe(200); + const data = await response.json(); + return data.backstageIdentity.token; + } + async getGuestAuthHeader() { + const token = await this.getGuestToken(); + const headers = { + Authorization: `Bearer ${token}`, + }; + return headers; + } + async setStaticToken(token) { + this.useStaticToken = true; + this.staticToken = "Bearer " + token; + } + async setBaseUrl(url) { + this.baseUrl = url; + } + static async apiRequestWithStaticToken(method, url, staticToken, body) { + const context = await request.newContext(); + const options = { + method: method, + headers: { + Accept: "application/json", + Authorization: `${staticToken}`, + }, + ...(body && { data: body }), + }; + const response = await context.fetch(url, options); + return response; + } + async getAllCatalogUsersFromAPI() { + const url = `${this.baseUrl}/api/catalog/entities/by-query?orderField=metadata.name%2Casc&filter=kind%3Duser`; + const token = this.useStaticToken ? this.staticToken : ""; + const response = await APIHelper.apiRequestWithStaticToken("GET", url, token); + return response.json(); + } + async getAllCatalogLocationsFromAPI() { + const url = `${this.baseUrl}/api/catalog/entities/by-query?orderField=metadata.name%2Casc&filter=kind%3Dlocation`; + const token = this.useStaticToken ? this.staticToken : ""; + const response = await APIHelper.apiRequestWithStaticToken("GET", url, token); + return response.json(); + } + async getAllCatalogGroupsFromAPI() { + const url = `${this.baseUrl}/api/catalog/entities/by-query?orderField=metadata.name%2Casc&filter=kind%3Dgroup`; + const token = this.useStaticToken ? this.staticToken : ""; + const response = await APIHelper.apiRequestWithStaticToken("GET", url, token); + return response.json(); + } + async getGroupEntityFromAPI(group) { + const url = `${this.baseUrl}/api/catalog/entities/by-name/group/default/${group}`; + const token = this.useStaticToken ? this.staticToken : ""; + const response = await APIHelper.apiRequestWithStaticToken("GET", url, token); + return response.json(); + } + async getCatalogUserFromAPI(user) { + const url = `${this.baseUrl}/api/catalog/entities/by-name/user/default/${user}`; + const token = this.useStaticToken ? this.staticToken : ""; + const response = await APIHelper.apiRequestWithStaticToken("GET", url, token); + return response.json(); + } + async deleteUserEntityFromAPI(user) { + const r = await this.getCatalogUserFromAPI(user); + if (!r.metadata?.uid) { + return; + } + const url = `${this.baseUrl}/api/catalog/entities/by-uid/${r.metadata.uid}`; + const token = this.useStaticToken ? this.staticToken : ""; + const response = await APIHelper.apiRequestWithStaticToken("DELETE", url, token); + return response.statusText; + } + async getCatalogGroupFromAPI(group) { + const url = `${this.baseUrl}/api/catalog/entities/by-name/group/default/${group}`; + const token = this.useStaticToken ? this.staticToken : ""; + const response = await APIHelper.apiRequestWithStaticToken("GET", url, token); + return response.json(); + } + async deleteGroupEntityFromAPI(group) { + const r = await this.getCatalogGroupFromAPI(group); + const url = `${this.baseUrl}/api/catalog/entities/by-uid/${r.metadata.uid}`; + const token = this.useStaticToken ? this.staticToken : ""; + const response = await APIHelper.apiRequestWithStaticToken("DELETE", url, token); + return response.statusText; + } + async scheduleEntityRefreshFromAPI(entity, kind, token) { + const url = `${this.baseUrl}/api/catalog/refresh`; + const reqBody = { entityRef: `${kind}:default/${entity}` }; + const responseRefresh = await APIHelper.apiRequestWithStaticToken("POST", url, token, reqBody); + return responseRefresh.status(); + } + /** + * Fetches the UID of an entity by its name from the Backstage catalog. + * + * @param name - The name of the entity (e.g., 'hello-world-2'). + * @returns The UID string if found, otherwise undefined. + */ + static async getEntityUidByName(name) { + const baseUrl = process.env.RHDH_BASE_URL; + const url = `${baseUrl}/api/catalog/entities/by-name/template/default/${name}`; + const context = await request.newContext(); + const response = await context.get(url); + if (response.status() !== 200) { + return undefined; + } + const data = await response.json(); + return data?.metadata?.uid; + } + /** + * Deletes a location from the Backstage catalog by its UID. + * + * @param uid - The UID of the location to delete. + * @returns The status code of the delete operation. + */ + static async deleteLocationByUid(uid) { + const baseUrl = process.env.RHDH_BASE_URL; + const url = `${baseUrl}/api/catalog/locations/${uid}`; + const context = await request.newContext(); + const response = await context.delete(url); + return response.status(); + } + /** + * Fetches the UID of a Template entity by its name and namespace from the Backstage catalog. + * + * @param name - The name of the template entity (e.g., 'hello-world-2'). + * @param namespace - The namespace of the template entity (default: 'default'). + * @returns The UID string if found, otherwise undefined. + */ + static async getTemplateEntityUidByName(name, namespace = "default") { + const baseUrl = process.env.RHDH_BASE_URL; + const url = `${baseUrl}/api/catalog/locations/by-entity/template/${namespace}/${name}`; + const context = await request.newContext(); + const response = await context.get(url); + if (response.status() === 200) { + const data = await response.json(); + return data?.metadata?.uid; + } + if (response.status() === 404) { + return undefined; + } + return undefined; + } + /** + * Deletes an entity location from the Backstage catalog by its ID. + * + * @param id - The ID of the entity to delete. + * @returns The status code of the delete operation. + */ + static async deleteEntityLocationById(id) { + const baseUrl = process.env.RHDH_BASE_URL; + const url = `${baseUrl}/api/catalog/locations/${id}`; + const context = await request.newContext(); + const response = await context.delete(url); + return response.status(); + } + /** + * Registers a new location in the Backstage catalog. + * + * @param target - The target URL of the location to register. + * @returns The status code of the registration operation. + */ + static async registerLocation(target) { + const baseUrl = process.env.RHDH_BASE_URL; + const url = `${baseUrl}/api/catalog/locations`; + const context = await request.newContext(); + const response = await context.post(url, { + data: { + type: "url", + target, + }, + headers: { + "Content-Type": "application/json", + }, + }); + return response.status(); + } + /** + * Fetches the ID of a location from the Backstage catalog by its target URL. + * + * @param target - The target URL of the location to search for. + * @returns The ID string if found, otherwise undefined. + */ + static async getLocationIdByTarget(target) { + const baseUrl = process.env.RHDH_BASE_URL; + const url = `${baseUrl}/api/catalog/locations`; + const context = await request.newContext(); + const response = await context.get(url); + if (response.status() !== 200) { + return undefined; + } + const data = await response.json(); + // data is expected to be an array of objects with a 'data' property + const location = (Array.isArray(data) ? data : []).find((entry) => entry?.data?.target === target); + return location?.data?.id; + } +} diff --git a/dist/playwright/helpers/auth-api-helper.d.ts b/dist/playwright/helpers/auth-api-helper.d.ts new file mode 100644 index 0000000..b148101 --- /dev/null +++ b/dist/playwright/helpers/auth-api-helper.d.ts @@ -0,0 +1,7 @@ +import { Page } from "@playwright/test"; +export declare class AuthApiHelper { + private readonly page; + constructor(page: Page); + getToken(provider?: string, environment?: string): Promise; +} +//# sourceMappingURL=auth-api-helper.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/auth-api-helper.d.ts.map b/dist/playwright/helpers/auth-api-helper.d.ts.map new file mode 100644 index 0000000..d08a61d --- /dev/null +++ b/dist/playwright/helpers/auth-api-helper.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"auth-api-helper.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/auth-api-helper.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAGxC,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAO;gBAEhB,IAAI,EAAE,IAAI;IAIhB,QAAQ,CACZ,QAAQ,GAAE,MAAe,EACzB,WAAW,GAAE,MAAqB;CA8BrC"} \ No newline at end of file diff --git a/dist/playwright/helpers/auth-api-helper.js b/dist/playwright/helpers/auth-api-helper.js new file mode 100644 index 0000000..f72f1a9 --- /dev/null +++ b/dist/playwright/helpers/auth-api-helper.js @@ -0,0 +1,31 @@ +// here, we spy on the request to get the Backstage token to use APIs +export class AuthApiHelper { + page; + constructor(page) { + this.page = page; + } + async getToken(provider = "oidc", environment = "production") { + try { + const response = await this.page.request.get(`/api/auth/${provider}/refresh?optional=&scope=&env=${environment}`, { + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + "x-requested-with": "XMLHttpRequest", + }, + }); + if (!response.ok()) { + throw new Error(`HTTP error! Status: ${response.status()}`); + } + const body = await response.json(); + if (typeof body?.backstageIdentity?.token === "string") { + return body.backstageIdentity.token; + } + else { + throw new TypeError("Token not found in response body"); + } + } + catch (error) { + console.error("Failed to retrieve the token:", error); + throw error; + } + } +} diff --git a/dist/playwright/helpers/common.d.ts b/dist/playwright/helpers/common.d.ts new file mode 100644 index 0000000..c4a06e1 --- /dev/null +++ b/dist/playwright/helpers/common.d.ts @@ -0,0 +1,31 @@ +import { UIhelper } from "./ui-helper.js"; +import type { Browser, Page, TestInfo } from "@playwright/test"; +export declare class LoginHelper { + page: Page; + uiHelper: UIhelper; + constructor(page: Page); + loginAsGuest(): Promise; + signOut(): Promise; + private logintoGithub; + logintoKeycloak(popup: Page, userid: string, password: string): Promise; + loginAsKeycloakUser(userid?: string, password?: string): Promise; + loginAsGithubUser(userid?: string): Promise; + checkAndReauthorizeGithubApp(): Promise; + googleSignIn(email: string): Promise; + checkAndClickOnGHloginPopup(force?: boolean): Promise; + getButtonSelector(label: string): string; + getLoginBtnSelector(): string; + clickOnGHloginPopup(): Promise; + getGitHub2FAOTP(userid: string): string; + getGoogle2FAOTP(): string; + keycloakLogin(username: string, password: string): Promise<"Already logged in" | "Login successful" | "User does not exist">; + private handleGitHubPopupLogin; + githubLogin(username: string, password: string, twofactor: string): Promise; + githubLoginFromSettingsPage(username: string, password: string, twofactor: string): Promise; + microsoftAzureLogin(username: string, password: string): Promise<"Already logged in" | "Login successful" | "User does not exist">; +} +export declare function setupBrowser(browser: Browser, testInfo: TestInfo): Promise<{ + page: Page; + context: import("playwright-core").BrowserContext; +}>; +//# sourceMappingURL=common.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/common.d.ts.map b/dist/playwright/helpers/common.d.ts.map new file mode 100644 index 0000000..e22dffa --- /dev/null +++ b/dist/playwright/helpers/common.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/common.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAG1C,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAOhE,qBAAa,WAAW;IACtB,IAAI,EAAE,IAAI,CAAC;IACX,QAAQ,EAAE,QAAQ,CAAC;gBAEP,IAAI,EAAE,IAAI;IAKhB,YAAY;IAcZ,OAAO;YAMC,aAAa;IAyCrB,eAAe,CAAC,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;IAO7D,mBAAmB,CACvB,MAAM,GAAE,MAAkC,EAC1C,QAAQ,GAAE,MAAkC;IAWxC,iBAAiB,CACrB,MAAM,GAAE,MAA+C;IAwDnD,4BAA4B;IAqB5B,YAAY,CAAC,KAAK,EAAE,MAAM;IA2B1B,2BAA2B,CAAC,KAAK,UAAQ;IAU/C,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM;IAIxC,mBAAmB,IAAI,MAAM;IAIvB,mBAAmB;IAgBzB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM;IAevC,eAAe,IAAI,MAAM;IAKnB,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;YAqCxC,sBAAsB;IAoD9B,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM;IAYjE,2BAA2B,CAC/B,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM;IAYb,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;CA2C7D;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ;;;GAWtE"} \ No newline at end of file diff --git a/dist/playwright/helpers/common.js b/dist/playwright/helpers/common.js new file mode 100644 index 0000000..2636538 --- /dev/null +++ b/dist/playwright/helpers/common.js @@ -0,0 +1,362 @@ +import { UIhelper } from "./ui-helper.js"; +import { authenticator } from "otplib"; +import { test, expect } from "@playwright/test"; +import { SETTINGS_PAGE_COMPONENTS } from "../page-objects/page-obj.js"; +import { UI_HELPER_ELEMENTS } from "../page-objects/global-obj.js"; +import * as path from "path"; +import * as fs from "fs"; +import { DEFAULT_USERS } from "../../deployment/keycloak/constants.js"; +export class LoginHelper { + page; + uiHelper; + constructor(page) { + this.page = page; + this.uiHelper = new UIhelper(page); + } + async loginAsGuest() { + await this.page.goto("/"); + await this.uiHelper.waitForLoad(240000); + // TODO - Remove it after https://issues.redhat.com/browse/RHIDP-2043. A Dynamic plugin for Guest Authentication Provider needs to be created + this.page.on("dialog", async (dialog) => { + console.log(`Dialog message: ${dialog.message()}`); + await dialog.accept(); + }); + await this.uiHelper.verifyHeading("Select a sign-in method"); + await this.uiHelper.clickButton("Enter"); + await this.page.waitForSelector("nav a", { timeout: 10_000 }); + } + async signOut() { + await this.page.click(SETTINGS_PAGE_COMPONENTS.userSettingsMenu); + await this.page.click(SETTINGS_PAGE_COMPONENTS.signOut); + await this.uiHelper.verifyHeading("Select a sign-in method"); + } + async logintoGithub(userid) { + await this.page.goto("https://github.com/login"); + await this.page.waitForSelector("#login_field"); + await this.page.fill("#login_field", userid); + switch (userid) { + case process.env.VAULT_GH_USER_ID: + await this.page.fill("#password", process.env.VAULT_GH_USER_PASS); + break; + case process.env.VAULT_GH_USER2_ID: + await this.page.fill("#password", process.env.VAULT_GH_USER2_PASS); + break; + default: + throw new Error("Invalid User ID"); + } + await this.page.click('[value="Sign in"]'); + await this.page.fill("#app_totp", this.getGitHub2FAOTP(userid)); + test.setTimeout(260_000); + if ((await this.uiHelper.isTextVisible("The two-factor code you entered has already been used")) || + (await this.uiHelper.isTextVisible("too many codes have been submitted", 3000))) { + await this.page.waitForTimeout(60000); + await this.page.fill("#app_totp", this.getGitHub2FAOTP(userid)); + } + await this.page.waitForTimeout(3_000); + } + async logintoKeycloak(popup, userid, password) { + await popup.waitForLoadState(); + await popup.locator("#username").fill(userid); + await popup.locator("#password").fill(password); + await popup.locator("#kc-login").click(); + } + async loginAsKeycloakUser(userid = DEFAULT_USERS[0].username, password = DEFAULT_USERS[0].password) { + await this.page.goto("/"); + await this.uiHelper.waitForLoad(240000); + const popupPromise = this.page.waitForEvent("popup"); + await this.uiHelper.clickButton("Sign In"); + const popup = await popupPromise; + await this.logintoKeycloak(popup, userid, password); + await this.page.waitForSelector("nav a", { timeout: 10_000 }); + } + async loginAsGithubUser(userid = process.env.VAULT_GH_USER_ID) { + const sessionFileName = `authState_${userid}.json`; + // Check if a session file for this specific user already exists + if (fs.existsSync(sessionFileName)) { + // Load and reuse existing authentication state + const cookies = JSON.parse(fs.readFileSync(sessionFileName, "utf-8")).cookies; + await this.page.context().addCookies(cookies); + console.log(`Reusing existing authentication state for user: ${userid}`); + await this.page.goto("/"); + await this.uiHelper.waitForLoad(12000); + await this.uiHelper.clickButton("Sign In"); + // Wait for either: sidebar appears (auto-login) or popup opens (needs auth) + const navPromise = this.page + .waitForSelector("nav a", { timeout: 15_000 }) + .then(() => "nav") + .catch(() => null); + const popupPromise = this.page + .waitForEvent("popup", { timeout: 15_000 }) + .then((popup) => ({ popup })) + .catch(() => null); + const result = await Promise.race([navPromise, popupPromise]); + if (result && typeof result === "object" && "popup" in result) { + // Popup opened β€” handle reauthorization + const popup = result.popup; + await popup.waitForLoadState(); + for (let attempts = 0; attempts < 10 && !popup.isClosed(); attempts++) { + await this.page.waitForTimeout(1000); + } + const locator = popup.locator("button.js-oauth-authorize-btn"); + if (!popup.isClosed() && (await locator.isVisible())) { + await popup.locator("body").click(); + await locator.waitFor(); + await locator.click(); + } + } + } + else { + // Perform login if no session file exists, then save the state + await this.logintoGithub(userid); + await this.page.goto("/"); + await this.uiHelper.waitForLoad(240000); + await this.uiHelper.clickButton("Sign In"); + await this.checkAndReauthorizeGithubApp(); + await this.page.waitForSelector("nav a", { timeout: 10_000 }); + await this.page.context().storageState({ path: sessionFileName }); + console.log(`Authentication state saved for user: ${userid}`); + } + } + async checkAndReauthorizeGithubApp() { + await new Promise((resolve) => { + this.page.once("popup", async (popup) => { + await popup.waitForLoadState(); + // Check for popup closure for up to 10 seconds before proceeding + for (let attempts = 0; attempts < 10 && !popup.isClosed(); attempts++) { + await this.page.waitForTimeout(1000); // Using page here because if the popup closes automatically, it throws an error during the wait + } + const locator = popup.locator("button.js-oauth-authorize-btn"); + if (!popup.isClosed() && (await locator.isVisible())) { + await popup.locator("body").click(); + await locator.waitFor(); + await locator.click(); + } + resolve(); + }); + }); + } + async googleSignIn(email) { + await new Promise((resolve) => { + this.page.once("popup", async (popup) => { + await popup.waitForLoadState(); + const locator = popup + .getByRole("link", { name: email, exact: false }) + .first(); + await popup.waitForTimeout(3000); + await locator.waitFor({ state: "visible" }); + await locator.click({ force: true }); + await popup.waitForTimeout(3000); + await popup + .locator("[name=Passwd]") + .fill(process.env.GOOGLE_USER_PASS); + await popup.locator("[name=Passwd]").press("Enter"); + await popup.waitForTimeout(3500); + await popup.locator("[name=totpPin]").fill(this.getGoogle2FAOTP()); + await popup.locator("[name=totpPin]").press("Enter"); + await popup + .getByRole("button", { name: /Continue|Weiter/ }) + .click({ timeout: 60000 }); + resolve(); + }); + }); + } + async checkAndClickOnGHloginPopup(force = false) { + const frameLocator = this.page.getByLabel("Login Required"); + try { + await frameLocator.waitFor({ state: "visible", timeout: 2000 }); + await this.clickOnGHloginPopup(); + } + catch (error) { + if (force) + throw error; + } + } + getButtonSelector(label) { + return `${UI_HELPER_ELEMENTS.MuiButtonLabel}:has-text("${label}")`; + } + getLoginBtnSelector() { + return 'MuiListItem-root li.MuiListItem-root button.MuiButton-root:has(span.MuiButton-label:text("Log in"))'; + } + async clickOnGHloginPopup() { + const isLoginRequiredVisible = await this.uiHelper.isTextVisible("Sign in"); + if (isLoginRequiredVisible) { + await this.uiHelper.clickButton("Sign in"); + // await this.uiHelper.clickButton("Log in"); + await this.checkAndReauthorizeGithubApp(); + await this.page.waitForSelector(this.getLoginBtnSelector(), { + state: "detached", + }); + } + else { + console.log('"Log in" button is not visible. Skipping login popup actions.'); + } + } + getGitHub2FAOTP(userid) { + const secrets = { + [process.env.VAULT_GH_USER_ID]: process.env.VAULT_GH_2FA_SECRET, + [process.env.VAULT_GH_USER2_ID]: process.env.VAULT_GH_USER2_2FA_SECRET, + }; + const secret = secrets[userid]; + if (!secret) { + throw new Error("Invalid User ID"); + } + return authenticator.generate(secret); + } + getGoogle2FAOTP() { + const secret = process.env.GOOGLE_2FA_SECRET; + return authenticator.generate(secret); + } + async keycloakLogin(username, password) { + await this.page.goto("/"); + await this.page.waitForSelector('p:has-text("Sign in using OIDC")'); + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.uiHelper.clickButton("Sign In"), + ]); + await popup.waitForLoadState("domcontentloaded"); + // Check if popup closes automatically (already logged in) + try { + await popup.waitForEvent("close", { timeout: 5000 }); + return "Already logged in"; + } + catch { + // Popup didn't close, proceed with login + } + try { + await popup.locator("#username").click(); + await popup.locator("#username").fill(username); + await popup.locator("#password").fill(password); + await popup.locator("[name=login]").click({ timeout: 5000 }); + await popup.waitForEvent("close", { timeout: 2000 }); + return "Login successful"; + } + catch (e) { + const usernameError = popup.locator("id=input-error"); + if (await usernameError.isVisible()) { + await popup.close(); + return "User does not exist"; + } + else { + throw e; + } + } + } + async handleGitHubPopupLogin(popup, username, password, twofactor) { + await expect(async () => { + await popup.waitForLoadState("domcontentloaded"); + expect(popup).toBeTruthy(); + }).toPass({ + intervals: [5_000, 10_000], + timeout: 20 * 1000, + }); + // Check if popup closes automatically + try { + await popup.waitForEvent("close", { timeout: 5000 }); + return "Already logged in"; + } + catch { + // Popup didn't close, proceed with login + } + try { + await popup.locator("#login_field").click({ timeout: 5000 }); + await popup.locator("#login_field").fill(username, { timeout: 5000 }); + const cookieLocator = popup.locator("#wcpConsentBannerCtrl"); + if (await cookieLocator.isVisible()) { + await popup.click('button:has-text("Reject")', { timeout: 5000 }); + } + await popup.locator("#password").click({ timeout: 5000 }); + await popup.locator("#password").fill(password, { timeout: 5000 }); + await popup + .locator("[type='submit'][value='Sign in']:not(webauthn-status *)") + .first() + .click({ timeout: 5000 }); + const twofactorcode = authenticator.generate(twofactor); + await popup.locator("#app_totp").click({ timeout: 5000 }); + await popup.locator("#app_totp").fill(twofactorcode, { timeout: 5000 }); + await popup.waitForEvent("close", { timeout: 20000 }); + return "Login successful"; + } + catch (e) { + const authorization = popup.locator("button.js-oauth-authorize-btn"); + if (await authorization.isVisible()) { + await authorization.click(); + return "Login successful"; + } + else { + throw e; + } + } + } + async githubLogin(username, password, twofactor) { + await this.page.goto("/"); + await this.page.waitForSelector('p:has-text("Sign in using GitHub")'); + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.uiHelper.clickButton("Sign In"), + ]); + return this.handleGitHubPopupLogin(popup, username, password, twofactor); + } + async githubLoginFromSettingsPage(username, password, twofactor) { + await this.page.goto("/settings/auth-providers"); + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.page.getByTitle("Sign in to GitHub").click(), + this.uiHelper.clickButton("Log in"), + ]); + return this.handleGitHubPopupLogin(popup, username, password, twofactor); + } + async microsoftAzureLogin(username, password) { + await this.page.goto("/"); + await this.page.waitForSelector('p:has-text("Sign in using Microsoft")'); + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.uiHelper.clickButton("Sign In"), + ]); + await popup.waitForLoadState("domcontentloaded"); + if (popup.url().startsWith(process.env.RHDH_BASE_URL)) { + // an active microsoft session is already logged in and the popup will automatically close + return "Already logged in"; + } + else { + try { + await popup.locator("[name=loginfmt]").click(); + await popup + .locator("[name=loginfmt]") + .fill(username, { timeout: 5000 }); + await popup + .locator('[type=submit]:has-text("Next")') + .click({ timeout: 5000 }); + await popup.locator("[name=passwd]").click(); + await popup.locator("[name=passwd]").fill(password, { timeout: 5000 }); + await popup + .locator('[type=submit]:has-text("Sign in")') + .click({ timeout: 5000 }); + await popup + .locator('[type=button]:has-text("No")') + .click({ timeout: 15000 }); + return "Login successful"; + } + catch (e) { + const usernameError = popup.locator("id=usernameError"); + if (await usernameError.isVisible()) { + return "User does not exist"; + } + else { + throw e; + } + } + } + } +} +export async function setupBrowser(browser, testInfo) { + const context = await browser.newContext({ + recordVideo: { + dir: `test-results/${path + .parse(testInfo.file) + .name.replace(".spec", "")}/${testInfo.titlePath[1]}`, + size: { width: 1920, height: 1080 }, + }, + }); + const page = await context.newPage(); + return { page, context }; +} diff --git a/dist/playwright/helpers/index.d.ts b/dist/playwright/helpers/index.d.ts new file mode 100644 index 0000000..551e804 --- /dev/null +++ b/dist/playwright/helpers/index.d.ts @@ -0,0 +1,7 @@ +export { GITHUB_API_ENDPOINTS } from "./api-endpoints.js"; +export { APIHelper } from "./api-helper.js"; +export { LoginHelper, setupBrowser } from "./common.js"; +export { UIhelper } from "./ui-helper.js"; +export { RbacApiHelper, Policy, Role, Response } from "./rbac-api-helper.js"; +export { AuthApiHelper } from "./auth-api-helper.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/index.d.ts.map b/dist/playwright/helpers/index.d.ts.map new file mode 100644 index 0000000..945dbc7 --- /dev/null +++ b/dist/playwright/helpers/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC"} \ No newline at end of file diff --git a/dist/playwright/helpers/index.js b/dist/playwright/helpers/index.js new file mode 100644 index 0000000..6847fe9 --- /dev/null +++ b/dist/playwright/helpers/index.js @@ -0,0 +1,6 @@ +export { GITHUB_API_ENDPOINTS } from "./api-endpoints.js"; +export { APIHelper } from "./api-helper.js"; +export { LoginHelper, setupBrowser } from "./common.js"; +export { UIhelper } from "./ui-helper.js"; +export { RbacApiHelper, Response } from "./rbac-api-helper.js"; +export { AuthApiHelper } from "./auth-api-helper.js"; diff --git a/dist/playwright/helpers/navbar.d.ts b/dist/playwright/helpers/navbar.d.ts new file mode 100644 index 0000000..79639fc --- /dev/null +++ b/dist/playwright/helpers/navbar.d.ts @@ -0,0 +1,2 @@ +export type SidebarTabs = "Catalog" | "Settings" | "My Group" | "Home" | "Self-service" | "Learning Paths" | "Extensions" | "Bulk import" | "Docs" | "Clusters" | "Tech Radar" | "Notifications" | "Orchestrator"; +//# sourceMappingURL=navbar.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/navbar.d.ts.map b/dist/playwright/helpers/navbar.d.ts.map new file mode 100644 index 0000000..7e46f52 --- /dev/null +++ b/dist/playwright/helpers/navbar.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"navbar.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/navbar.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GACnB,SAAS,GACT,UAAU,GACV,UAAU,GACV,MAAM,GACN,cAAc,GACd,gBAAgB,GAChB,YAAY,GACZ,aAAa,GACb,MAAM,GACN,UAAU,GACV,YAAY,GACZ,eAAe,GACf,cAAc,CAAC"} \ No newline at end of file diff --git a/dist/playwright/helpers/navbar.js b/dist/playwright/helpers/navbar.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/playwright/helpers/navbar.js @@ -0,0 +1 @@ +export {}; diff --git a/dist/playwright/helpers/rbac-api-helper.d.ts b/dist/playwright/helpers/rbac-api-helper.d.ts new file mode 100644 index 0000000..10a7b97 --- /dev/null +++ b/dist/playwright/helpers/rbac-api-helper.d.ts @@ -0,0 +1,43 @@ +import type { PermissionAction, RoleConditionalPolicyDecision } from "@backstage-community/plugin-rbac-common"; +import { APIResponse } from "@playwright/test"; +export interface Policy { + entityReference: string; + permission: string; + policy: string; + effect: string; +} +export interface Role { + memberReferences: string[]; + name: string; +} +/** + * Thin HTTP client for the RHDH RBAC permission API. + * Uses a static factory (`build`) because the Playwright `APIRequestContext` + * must be created asynchronously β€” a constructor cannot await it. + */ +export declare class RbacApiHelper { + private readonly token; + private readonly apiUrl; + private readonly authHeader; + private myContext; + private constructor(); + /** Creates a fully-initialised instance with a live Playwright request context. */ + static build(token: string): Promise; + createRoles(role: Role): Promise; + getRoles(): Promise; + updateRole(role: string, oldRole: Role, newRole: Role): Promise; + deleteRole(role: string): Promise; + getPoliciesByRole(policy: string): Promise; + createPolicies(policy: Policy[]): Promise; + deletePolicy(policy: string, policies: Policy[]): Promise; + /** Fetches all conditional policies across all roles. */ + getConditions(): Promise; + /** Filters a full conditions list down to those belonging to a specific role entity ref. */ + getConditionsByRole(role: string, remainingConditions: RoleConditionalPolicyDecision[]): Promise[]>; + /** `id` comes from the `RoleConditionalPolicyDecision.id` field returned by the API. */ + deleteCondition(id: string): Promise; +} +export declare class Response { + static removeMetadataFromResponse(response: APIResponse): Promise; +} +//# sourceMappingURL=rbac-api-helper.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/rbac-api-helper.d.ts.map b/dist/playwright/helpers/rbac-api-helper.d.ts.map new file mode 100644 index 0000000..9ad0c60 --- /dev/null +++ b/dist/playwright/helpers/rbac-api-helper.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"rbac-api-helper.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/rbac-api-helper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gBAAgB,EAChB,6BAA6B,EAC9B,MAAM,yCAAyC,CAAC;AACjD,OAAO,EAAqB,WAAW,EAAW,MAAM,kBAAkB,CAAC;AAE3E,MAAM,WAAW,MAAM;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,IAAI;IACnB,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,qBAAa,aAAa;IAUJ,OAAO,CAAC,QAAQ,CAAC,KAAK;IAT1C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkD;IACzE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAKzB;IACF,OAAO,CAAC,SAAS,CAAqB;IAEtC,OAAO;IAOP,mFAAmF;WAC/D,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;IAWnD,WAAW,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,WAAW,CAAC;IAI7C,QAAQ,IAAI,OAAO,CAAC,WAAW,CAAC;IAIhC,UAAU,CACrB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,IAAI,EACb,OAAO,EAAE,IAAI,GACZ,OAAO,CAAC,WAAW,CAAC;IAMV,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;IAM9C,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;IAIvD,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,WAAW,CAAC;IAItD,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE;IAQ5D,yDAAyD;IAC5C,aAAa,IAAI,OAAO,CAAC,WAAW,CAAC;IAIlD,4FAA4F;IAC/E,mBAAmB,CAC9B,IAAI,EAAE,MAAM,EACZ,mBAAmB,EAAE,6BAA6B,CAAC,gBAAgB,CAAC,EAAE,GACrE,OAAO,CAAC,6BAA6B,CAAC,gBAAgB,CAAC,EAAE,CAAC;IAM7D,wFAAwF;IAC3E,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;CAG/D;AAED,qBAAa,QAAQ;WACN,0BAA0B,CACrC,QAAQ,EAAE,WAAW,GACpB,OAAO,CAAC,OAAO,EAAE,CAAC;CActB"} \ No newline at end of file diff --git a/dist/playwright/helpers/rbac-api-helper.js b/dist/playwright/helpers/rbac-api-helper.js new file mode 100644 index 0000000..62608f1 --- /dev/null +++ b/dist/playwright/helpers/rbac-api-helper.js @@ -0,0 +1,80 @@ +import { request } from "@playwright/test"; +/** + * Thin HTTP client for the RHDH RBAC permission API. + * Uses a static factory (`build`) because the Playwright `APIRequestContext` + * must be created asynchronously β€” a constructor cannot await it. + */ +export class RbacApiHelper { + token; + apiUrl = process.env.RHDH_BASE_URL + "/api/permission/"; + authHeader; + myContext; + constructor(token) { + this.token = token; + this.authHeader = { + Accept: "application/json", + Authorization: `Bearer ${this.token}`, + }; + } + /** Creates a fully-initialised instance with a live Playwright request context. */ + static async build(token) { + const instance = new RbacApiHelper(token); + instance.myContext = await request.newContext({ + baseURL: instance.apiUrl, + extraHTTPHeaders: instance.authHeader, + }); + return instance; + } + // Roles: + async createRoles(role) { + return await this.myContext.post("roles", { data: role }); + } + async getRoles() { + return await this.myContext.get("roles"); + } + async updateRole(role, oldRole, newRole) { + return await this.myContext.put(`roles/role/default/${role}`, { + data: { oldRole, newRole }, + }); + } + async deleteRole(role) { + return await this.myContext.delete(`roles/role/default/${role}`); + } + // Policies: + async getPoliciesByRole(policy) { + return await this.myContext.get(`policies/role/default/${policy}`); + } + async createPolicies(policy) { + return await this.myContext.post("policies", { data: policy }); + } + async deletePolicy(policy, policies) { + return await this.myContext.delete(`policies/role/default/${policy}`, { + data: policies, + }); + } + // Conditions: + /** Fetches all conditional policies across all roles. */ + async getConditions() { + return await this.myContext.get(`roles/conditions`); + } + /** Filters a full conditions list down to those belonging to a specific role entity ref. */ + async getConditionsByRole(role, remainingConditions) { + return remainingConditions.filter((condition) => condition.roleEntityRef === role); + } + /** `id` comes from the `RoleConditionalPolicyDecision.id` field returned by the API. */ + async deleteCondition(id) { + return await this.myContext.delete(`roles/conditions/${id}`); + } +} +export class Response { + static async removeMetadataFromResponse(response) { + const responseJson = await response.json(); + if (!Array.isArray(responseJson)) { + throw new TypeError(`Expected an array from policy response but received: ${JSON.stringify(responseJson)}`); + } + return responseJson.map((item) => { + delete item.metadata; + return item; + }); + } +} diff --git a/dist/playwright/helpers/ui-helper.d.ts b/dist/playwright/helpers/ui-helper.d.ts new file mode 100644 index 0000000..738046e --- /dev/null +++ b/dist/playwright/helpers/ui-helper.d.ts @@ -0,0 +1,113 @@ +import type { Locator, Page } from "@playwright/test"; +import type { SidebarTabs } from "./navbar.js"; +export declare class UIhelper { + private page; + constructor(page: Page); + waitForLoad(timeout?: number): Promise; + /** + * Closes the quickstart drawer when the "Hide" button is visible (RHDH quickstart plugin), + * so it does not cover catalog or other UI under test. + */ + dismissQuickstartIfVisible(options?: { + waitHiddenMs?: number; + }): Promise; + verifyComponentInCatalog(kind: string, expectedRows: string[]): Promise; + getSideBarMenuItem(menuItem: string): Locator; + fillTextInputByLabel(label: string, text: string): Promise; + /** + * Fills the search input with the provided text. + * + * @param searchText - The text to be entered into the search input field. + */ + searchInputPlaceholder(searchText: string): Promise; + searchInputAriaLabel(searchText: string): Promise; + pressTab(): Promise; + checkCheckbox(text: string): Promise; + uncheckCheckbox(text: string): Promise; + clickButton(label: string | RegExp, options?: { + exact?: boolean; + force?: boolean; + }): Promise; + clickBtnByTitleIfNotPressed(title: string): Promise; + clickByDataTestId(dataTestId: string): Promise; + /** + * Clicks on a button element by its text content, waiting for it to be visible first. + * + * @param buttonText - The text content of the button to click on. + * @param options - Optional configuration for exact match, timeout, and force click. + */ + clickButtonByText(buttonText: string | RegExp, options?: { + exact?: boolean; + timeout?: number; + force?: boolean; + }): Promise; + clickButtonByLabel(label: string | RegExp): Promise; + clickLink(options: string | { + href: string; + } | { + ariaLabel: string; + }): Promise; + openProfileDropdown(): Promise; + goToPageUrl(url: string, heading?: string): Promise; + verifyLink(arg: string | { + label: string; + }, options?: { + exact?: boolean; + notVisible?: boolean; + }): Promise; + private isElementVisible; + isBtnVisibleByTitle(text: string): Promise; + isBtnVisible(text: string): Promise; + isTextVisible(text: string, timeout?: number): Promise; + verifyTextVisible(text: string, exact?: boolean, timeout?: number): Promise; + verifyLinkVisible(text: string, timeout?: number): Promise; + openSidebar(navBarText: SidebarTabs): Promise; + openCatalogSidebar(kind: string): Promise; + openSidebarButton(navBarButtonLabel: string): Promise; + selectMuiBox(label: string, value: string): Promise; + verifyRowsInTable(rowTexts: (string | RegExp)[], exact?: boolean): Promise; + waitForTextDisappear(text: string): Promise; + verifyText(text: string | RegExp, exact?: boolean): Promise; + private verifyTextInLocator; + verifyTextInSelector(selector: string, expectedText: string): Promise; + verifyColumnHeading(rowTexts: string[] | RegExp[], exact?: boolean): Promise; + verifyHeading(heading: string | RegExp, timeout?: number): Promise; + verifyParagraph(paragraph: string): Promise; + waitForTitle(text: string, level?: number): Promise; + clickTab(tabName: string): Promise; + verifyCellsInTable(texts: (string | RegExp)[]): Promise; + verifyButtonURL(label: string | RegExp, url: string | RegExp, options?: { + locator?: string; + exact?: boolean; + }): Promise; + /** + * Verifies that a table row, identified by unique text, contains specific cell texts. + * @param {string} uniqueRowText - The unique text present in one of the cells within the row. This is used to identify the specific row. + * @param {Array} cellTexts - An array of cell texts or regular expressions to match against the cells within the identified row. + * @example + * // Example usage to verify that a row containing "Developer-hub" has cells with the texts "service" and "active": + * await verifyRowInTableByUniqueText('Developer-hub', ['service', 'active']); + */ + verifyRowInTableByUniqueText(uniqueRowText: string, cellTexts: string[] | RegExp[]): Promise; + /** + * Clicks on a link within a table row that contains a unique text and matches a link's text. + * @param {string} uniqueRowText - The unique text present in one of the cells within the row. This is used to identify the specific row. + * @param {string | RegExp} linkText - The text of the link, can be a string or a regular expression. + * @param {boolean} [exact=true] - Whether to match the link text exactly. By default, this is set to true. + */ + clickOnLinkInTableByUniqueText(uniqueRowText: string, linkText: string | RegExp, exact?: boolean): Promise; + /** + * Clicks on a button within a table row that contains a unique text and matches a button's label or aria-label. + * @param {string} uniqueRowText - The unique text present in one of the cells within the row. This is used to identify the specific row. + * @param {string | RegExp} textOrLabel - The text of the button or the `aria-label` attribute, can be a string or a regular expression. + */ + clickOnButtonInTableByUniqueText(uniqueRowText: string, textOrLabel: string | RegExp): Promise; + verifyLinkinCard(cardHeading: string, linkText: string, exact?: boolean): Promise; + clickBtnInCard(cardText: string, btnText: string, exact?: boolean): Promise; + verifyTextinCard(cardHeading: string, text: string | RegExp, exact?: boolean): Promise; + verifyTableHeadingAndRows(texts: string[]): Promise; + verifyTableIsEmpty(): Promise; + verifyAlertErrorMessage(message: string | RegExp): Promise; + verifyTextInTooltip(text: string | RegExp): Promise; +} +//# sourceMappingURL=ui-helper.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/ui-helper.d.ts.map b/dist/playwright/helpers/ui-helper.d.ts.map new file mode 100644 index 0000000..e73c3b2 --- /dev/null +++ b/dist/playwright/helpers/ui-helper.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"ui-helper.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/ui-helper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAMtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAG/C,qBAAa,QAAQ;IACnB,OAAO,CAAC,IAAI,CAAO;gBAEP,IAAI,EAAE,IAAI;IAIhB,WAAW,CAAC,OAAO,SAAS;IASlC;;;OAGG;IACG,0BAA0B,CAAC,OAAO,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE;IAY9D,wBAAwB,CAAC,IAAI,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE;IAMnE,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAIvC,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;IAItD;;;;OAIG;IACG,sBAAsB,CAAC,UAAU,EAAE,MAAM;IAOzC,oBAAoB,CAAC,UAAU,EAAE,MAAM;IAIvC,QAAQ;IAIR,aAAa,CAAC,IAAI,EAAE,MAAM;IAO1B,eAAe,CAAC,IAAI,EAAE,MAAM;IAO5B,WAAW,CACf,KAAK,EAAE,MAAM,GAAG,MAAM,EACtB,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAG1C;IAgBG,2BAA2B,CAAC,KAAK,EAAE,MAAM;IASzC,iBAAiB,CAAC,UAAU,EAAE,MAAM;IAM1C;;;;;OAKG;IACG,iBAAiB,CACrB,UAAU,EAAE,MAAM,GAAG,MAAM,EAC3B,OAAO,GAAE;QACP,KAAK,CAAC,EAAE,OAAO,CAAC;QAChB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,KAAK,CAAC,EAAE,OAAO,CAAC;KAKjB;IAkBG,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM;IAIzC,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE;IAiBpE,mBAAmB;IAQnB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM;IASzC,UAAU,CACd,GAAG,EAAE,MAAM,GAAG;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,EAC/B,OAAO,GAAE;QACP,KAAK,CAAC,EAAE,OAAO,CAAC;QAChB,UAAU,CAAC,EAAE,OAAO,CAAC;KAItB;YAwBW,gBAAgB;IAkBxB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKnD,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAK5C,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,SAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;IAK9D,iBAAiB,CACrB,IAAI,EAAE,MAAM,EACZ,KAAK,UAAQ,EACb,OAAO,SAAQ,GACd,OAAO,CAAC,IAAI,CAAC;IAKV,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,SAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAK/D,WAAW,CAAC,UAAU,EAAE,WAAW;IAQnC,kBAAkB,CAAC,IAAI,EAAE,MAAM;IAa/B,iBAAiB,CAAC,iBAAiB,EAAE,MAAM;IAQ3C,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM;IAOzC,iBAAiB,CACrB,QAAQ,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,EAC7B,KAAK,GAAE,OAAc;IAOjB,oBAAoB,CAAC,IAAI,EAAE,MAAM;IAIjC,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,KAAK,GAAE,OAAc;YAI/C,mBAAmB;IAsB3B,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM;IA+B3D,mBAAmB,CACvB,QAAQ,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,EAC7B,KAAK,GAAE,OAAc;IAajB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,OAAO,GAAE,MAAc;IAU/D,eAAe,CAAC,SAAS,EAAE,MAAM;IASjC,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,GAAE,MAAU;IAI5C,QAAQ,CAAC,OAAO,EAAE,MAAM;IAMxB,kBAAkB,CAAC,KAAK,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE;IAoB7C,eAAe,CACnB,KAAK,EAAE,MAAM,GAAG,MAAM,EACtB,GAAG,EAAE,MAAM,GAAG,MAAM,EACpB,OAAO,GAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAG3C;IAgBH;;;;;;;OAOG;IAEG,4BAA4B,CAChC,aAAa,EAAE,MAAM,EACrB,SAAS,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE;IAWhC;;;;;OAKG;IACG,8BAA8B,CAClC,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,KAAK,GAAE,OAAc;IAWvB;;;;OAIG;IACG,gCAAgC,CACpC,aAAa,EAAE,MAAM,EACrB,WAAW,EAAE,MAAM,GAAG,MAAM;IAYxB,gBAAgB,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,UAAO;IAUpE,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,UAAO;IAW9D,gBAAgB,CACpB,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,MAAM,GAAG,MAAM,EACrB,KAAK,UAAO;IAUR,yBAAyB,CAAC,KAAK,EAAE,MAAM,EAAE;IAiBzC,kBAAkB;IAMlB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM;IAMhD,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;CAIhD"} \ No newline at end of file diff --git a/dist/playwright/helpers/ui-helper.js b/dist/playwright/helpers/ui-helper.js new file mode 100644 index 0000000..6bcfc6a --- /dev/null +++ b/dist/playwright/helpers/ui-helper.js @@ -0,0 +1,455 @@ +import { expect } from "@playwright/test"; +import { UI_HELPER_ELEMENTS, WAIT_OBJECTS, } from "../page-objects/global-obj.js"; +import { SEARCH_OBJECTS_COMPONENTS } from "../page-objects/page-obj.js"; +export class UIhelper { + page; + constructor(page) { + this.page = page; + } + async waitForLoad(timeout = 120000) { + for (const item of Object.values(WAIT_OBJECTS)) { + await this.page.waitForSelector(item, { + state: "hidden", + timeout: timeout, + }); + } + } + /** + * Closes the quickstart drawer when the "Hide" button is visible (RHDH quickstart plugin), + * so it does not cover catalog or other UI under test. + */ + async dismissQuickstartIfVisible(options) { + const waitHiddenMs = options?.waitHiddenMs ?? 5000; + const quickstartHide = this.page.getByRole("button", { name: "Hide" }); + if (await quickstartHide.isVisible()) { + await quickstartHide.click(); + await quickstartHide.waitFor({ + state: "hidden", + timeout: waitHiddenMs, + }); + } + } + async verifyComponentInCatalog(kind, expectedRows) { + await this.openSidebar("Catalog"); + await this.selectMuiBox("Kind", kind); + await this.verifyRowsInTable(expectedRows); + } + getSideBarMenuItem(menuItem) { + return this.page.getByTestId("login-button").getByText(menuItem); + } + async fillTextInputByLabel(label, text) { + await this.page.getByLabel(label).fill(text); + } + /** + * Fills the search input with the provided text. + * + * @param searchText - The text to be entered into the search input field. + */ + async searchInputPlaceholder(searchText) { + await this.page.fill(SEARCH_OBJECTS_COMPONENTS.placeholderSearch, searchText); + } + async searchInputAriaLabel(searchText) { + await this.page.fill(SEARCH_OBJECTS_COMPONENTS.ariaLabelSearch, searchText); + } + async pressTab() { + await this.page.keyboard.press("Tab"); + } + async checkCheckbox(text) { + const locator = this.page.getByRole("checkbox", { + name: text, + }); + await locator.check(); + } + async uncheckCheckbox(text) { + const locator = this.page.getByRole("checkbox", { + name: text, + }); + await locator.uncheck(); + } + async clickButton(label, options = { + exact: true, + force: false, + }) { + const selector = `${UI_HELPER_ELEMENTS.MuiButtonLabel}`; + const button = this.page + .locator(selector) + .getByText(label, { exact: options.exact }) + .first(); + if (options?.force) { + await button.click({ force: true }); + } + else { + await button.click(); + } + return button; + } + async clickBtnByTitleIfNotPressed(title) { + const button = this.page.locator(`button[title="${title}"]`); + const isPressed = await button.getAttribute("aria-pressed"); + if (isPressed === "false") { + await button.click(); + } + } + async clickByDataTestId(dataTestId) { + const element = this.page.getByTestId(dataTestId); + await element.waitFor({ state: "visible" }); + await element.dispatchEvent("click"); + } + /** + * Clicks on a button element by its text content, waiting for it to be visible first. + * + * @param buttonText - The text content of the button to click on. + * @param options - Optional configuration for exact match, timeout, and force click. + */ + async clickButtonByText(buttonText, options = { + exact: true, + timeout: 10000, + force: false, + }) { + const buttonElement = this.page + .getByRole("button") + .getByText(buttonText, { exact: options.exact }); + await buttonElement.waitFor({ + state: "visible", + timeout: options.timeout, + }); + if (options.force) { + await buttonElement.click({ force: true }); + } + else { + await buttonElement.click(); + } + } + async clickButtonByLabel(label) { + await this.page.getByRole("button", { name: label }).first().click(); + } + async clickLink(options) { + let linkLocator; + if (typeof options === "string") { + linkLocator = this.page.locator("a").filter({ hasText: options }).first(); + } + else if ("href" in options) { + linkLocator = this.page.locator(`a[href="${options.href}"]`).first(); + } + else { + linkLocator = this.page + .locator(`div[aria-label='${options.ariaLabel}'] a`) + .first(); + } + await linkLocator.waitFor({ state: "visible" }); + await linkLocator.click(); + } + async openProfileDropdown() { + const header = this.page.locator("nav[id='global-header']"); + await expect(header).toBeVisible(); + await header + .locator("[data-testid='KeyboardArrowDownOutlinedIcon']") + .click(); + } + async goToPageUrl(url, heading) { + await this.page.goto(url); + await expect(this.page).toHaveURL(url); + await this.waitForLoad(); + if (heading) { + await this.verifyHeading(heading); + } + } + async verifyLink(arg, options = { + exact: true, + notVisible: false, + }) { + let linkLocator; + let notVisibleCheck; + if (typeof arg != "object") { + linkLocator = this.page + .locator("a") + .getByText(arg, { exact: options.exact }) + .first(); + notVisibleCheck = options?.notVisible ?? false; + } + else { + linkLocator = this.page.locator(`div[aria-label="${arg.label}"] a`); + notVisibleCheck = false; + } + if (notVisibleCheck) { + await expect(linkLocator).toBeHidden(); + } + else { + await expect(linkLocator).toBeVisible(); + } + } + async isElementVisible(locator, timeout = 10000, force = false) { + try { + await this.page.waitForSelector(locator, { + state: "visible", + timeout: timeout, + }); + const button = this.page.locator(locator).first(); + return button.isVisible(); + } + catch (error) { + if (force) + throw error; + return false; + } + } + async isBtnVisibleByTitle(text) { + const locator = `BUTTON[title="${text}"]`; + return await this.isElementVisible(locator); + } + async isBtnVisible(text) { + const locator = `button:has-text("${text}")`; + return await this.isElementVisible(locator); + } + async isTextVisible(text, timeout = 10000) { + const locator = `:has-text("${text}")`; + return await this.isElementVisible(locator, timeout); + } + async verifyTextVisible(text, exact = false, timeout = 10000) { + const locator = this.page.getByText(text, { exact }); + await expect(locator).toBeVisible({ timeout }); + } + async verifyLinkVisible(text, timeout = 10000) { + const locator = this.page.locator(`a:has-text("${text}")`); + await expect(locator).toBeVisible({ timeout }); + } + async openSidebar(navBarText) { + const navLink = this.page + .locator(`nav a:has-text("${navBarText}")`) + .first(); + await navLink.waitFor({ state: "visible", timeout: 15_000 }); + await navLink.dispatchEvent("click"); + } + async openCatalogSidebar(kind) { + await this.openSidebar("Catalog"); + await this.selectMuiBox("Kind", kind); + await expect(async () => { + await this.clickByDataTestId("user-picker-all"); + await this.page.waitForTimeout(1_500); + await this.verifyHeading(new RegExp(`all ${kind}`, "i")); + }).toPass({ + intervals: [3_000], + timeout: 20_000, + }); + } + async openSidebarButton(navBarButtonLabel) { + const navLink = this.page.locator(`nav button[aria-label="${navBarButtonLabel}"]`); + await navLink.waitFor({ state: "visible" }); + await navLink.click(); + } + async selectMuiBox(label, value) { + await this.page.click(`div[aria-label="${label}"]`); + const optionSelector = `li[role="option"]:has-text("${value}")`; + await this.page.waitForSelector(optionSelector); + await this.page.click(optionSelector); + } + async verifyRowsInTable(rowTexts, exact = true) { + for (const rowText of rowTexts) { + await this.verifyTextInLocator(`tr>td`, rowText, exact); + } + } + async waitForTextDisappear(text) { + await this.page.waitForSelector(`text=${text}`, { state: "detached" }); + } + async verifyText(text, exact = true) { + await this.verifyTextInLocator("", text, exact); + } + async verifyTextInLocator(locator, text, exact) { + const elementLocator = locator + ? this.page.locator(locator).getByText(text, { exact }).first() + : this.page.getByText(text, { exact }).first(); + await elementLocator.waitFor({ state: "visible" }); + await elementLocator.waitFor({ state: "attached" }); + try { + await elementLocator.scrollIntoViewIfNeeded(); + } + catch (error) { + console.warn(`Warning: Could not scroll element into view. Error: ${error instanceof Error ? error.message : String(error)}`); + } + await expect(elementLocator).toBeVisible(); + } + async verifyTextInSelector(selector, expectedText) { + const elementLocator = this.page + .locator(selector) + .getByText(expectedText, { exact: true }); + try { + await elementLocator.waitFor({ state: "visible" }); + const actualText = (await elementLocator.textContent()) || "No content"; + if (actualText.trim() !== expectedText.trim()) { + console.error(`Verification failed for text: Expected "${expectedText}", but got "${actualText}"`); + throw new Error(`Expected text "${expectedText}" not found. Actual content: "${actualText}".`); + } + console.log(`Text "${expectedText}" verified successfully in selector: ${selector}`); + } + catch (error) { + const allTextContent = await this.page + .locator(selector) + .allTextContents(); + console.error(`Verification failed for text: Expected "${expectedText}". Selector content: ${allTextContent.join(", ")}`); + throw error; + } + } + async verifyColumnHeading(rowTexts, exact = true) { + for (const rowText of rowTexts) { + const rowLocator = this.page + .locator(`tr>th`) + .getByText(rowText, { exact: exact }) + .first(); + await rowLocator.waitFor({ state: "visible" }); + await rowLocator.scrollIntoViewIfNeeded(); + await expect(rowLocator).toBeVisible(); + } + } + async verifyHeading(heading, timeout = 20000) { + const headingLocator = this.page + .locator("h1, h2, h3, h4, h5, h6") + .filter({ hasText: heading }) + .first(); + await headingLocator.waitFor({ state: "visible", timeout: timeout }); + await expect(headingLocator).toBeVisible(); + } + async verifyParagraph(paragraph) { + const headingLocator = this.page + .locator("p") + .filter({ hasText: paragraph }) + .first(); + await headingLocator.waitFor({ state: "visible", timeout: 20000 }); + await expect(headingLocator).toBeVisible(); + } + async waitForTitle(text, level = 1) { + await this.page.waitForSelector(`h${level}:has-text("${text}")`); + } + async clickTab(tabName) { + const tabLocator = this.page.getByRole("tab", { name: tabName }); + await tabLocator.waitFor({ state: "visible" }); + await tabLocator.click(); + } + async verifyCellsInTable(texts) { + for (const text of texts) { + const cellLocator = this.page + .locator(UI_HELPER_ELEMENTS.MuiTableCell) + .filter({ hasText: text }); + const count = await cellLocator.count(); + if (count === 0) { + throw new Error(`Expected at least one cell with text matching ${text}, but none were found.`); + } + // Checks if all matching cells are visible. + for (let i = 0; i < count; i++) { + await expect(cellLocator.nth(i)).toBeVisible(); + } + } + } + async verifyButtonURL(label, url, options = { + locator: "", + exact: true, + }) { + // To verify the button URL if it is in a specific locator + const baseLocator = !options.locator || options.locator === "" + ? this.page + : this.page.locator(options.locator); + const buttonUrl = await baseLocator + .getByRole("button", { name: label, exact: options.exact }) + .first() + .getAttribute("href"); + expect(buttonUrl).toContain(url); + } + /** + * Verifies that a table row, identified by unique text, contains specific cell texts. + * @param {string} uniqueRowText - The unique text present in one of the cells within the row. This is used to identify the specific row. + * @param {Array} cellTexts - An array of cell texts or regular expressions to match against the cells within the identified row. + * @example + * // Example usage to verify that a row containing "Developer-hub" has cells with the texts "service" and "active": + * await verifyRowInTableByUniqueText('Developer-hub', ['service', 'active']); + */ + async verifyRowInTableByUniqueText(uniqueRowText, cellTexts) { + const row = this.page.locator(UI_HELPER_ELEMENTS.rowByText(uniqueRowText)); + await row.waitFor(); + for (const cellText of cellTexts) { + await expect(row.locator("td").filter({ hasText: cellText }).first()).toBeVisible(); + } + } + /** + * Clicks on a link within a table row that contains a unique text and matches a link's text. + * @param {string} uniqueRowText - The unique text present in one of the cells within the row. This is used to identify the specific row. + * @param {string | RegExp} linkText - The text of the link, can be a string or a regular expression. + * @param {boolean} [exact=true] - Whether to match the link text exactly. By default, this is set to true. + */ + async clickOnLinkInTableByUniqueText(uniqueRowText, linkText, exact = true) { + const row = this.page.locator(UI_HELPER_ELEMENTS.rowByText(uniqueRowText)); + await row.waitFor(); + await row + .locator("a") + .getByText(linkText, { exact: exact }) + .first() + .click(); + } + /** + * Clicks on a button within a table row that contains a unique text and matches a button's label or aria-label. + * @param {string} uniqueRowText - The unique text present in one of the cells within the row. This is used to identify the specific row. + * @param {string | RegExp} textOrLabel - The text of the button or the `aria-label` attribute, can be a string or a regular expression. + */ + async clickOnButtonInTableByUniqueText(uniqueRowText, textOrLabel) { + const row = this.page.locator(UI_HELPER_ELEMENTS.rowByText(uniqueRowText)); + await row.waitFor(); + await row + .locator(`button:has-text("${textOrLabel}"), button[aria-label="${textOrLabel}"]`) + .first() + .click(); + } + async verifyLinkinCard(cardHeading, linkText, exact = true) { + const link = this.page + .locator(UI_HELPER_ELEMENTS.MuiCard(cardHeading)) + .locator("a") + .getByText(linkText, { exact: exact }) + .first(); + await link.scrollIntoViewIfNeeded(); + await expect(link).toBeVisible(); + } + async clickBtnInCard(cardText, btnText, exact = true) { + const cardLocator = this.page + .locator(UI_HELPER_ELEMENTS.MuiCardRoot(cardText)) + .first(); + await cardLocator.scrollIntoViewIfNeeded(); + await cardLocator + .getByRole("button", { name: btnText, exact: exact }) + .first() + .click(); + } + async verifyTextinCard(cardHeading, text, exact = true) { + const locator = this.page + .locator(UI_HELPER_ELEMENTS.MuiCard(cardHeading)) + .getByText(text, { exact: exact }) + .first(); + await locator.scrollIntoViewIfNeeded(); + await expect(locator).toBeVisible(); + } + async verifyTableHeadingAndRows(texts) { + // Wait for the table to load by checking for the presence of table rows + await this.page.waitForSelector("table tbody tr", { state: "visible" }); + for (const column of texts) { + const columnSelector = `table th:has-text("${column}")`; + //check if columnSelector has at least one element or more + const columnCount = await this.page.locator(columnSelector).count(); + expect(columnCount).toBeGreaterThan(0); + } + // Checks if the table has at least one row with data + // Excludes rows that have cells spanning multiple columns, such as "No data available" messages + const rowSelector = `table tbody tr:not(:has(td[colspan]))`; + const rowCount = await this.page.locator(rowSelector).count(); + expect(rowCount).toBeGreaterThan(0); + } + async verifyTableIsEmpty() { + const rowSelector = `table tbody tr:not(:has(td[colspan]))`; + const rowCount = await this.page.locator(rowSelector).count(); + expect(rowCount).toEqual(0); + } + async verifyAlertErrorMessage(message) { + const alert = this.page.getByRole("alert"); + await alert.waitFor(); + await expect(alert).toHaveText(message); + } + async verifyTextInTooltip(text) { + const tooltip = this.page.getByRole("tooltip").getByText(text); + await expect(tooltip).toBeVisible(); + } +} diff --git a/dist/playwright/page-objects/global-obj.d.ts b/dist/playwright/page-objects/global-obj.d.ts new file mode 100644 index 0000000..8772255 --- /dev/null +++ b/dist/playwright/page-objects/global-obj.d.ts @@ -0,0 +1,25 @@ +export declare const WAIT_OBJECTS: { + MuiLinearProgress: string; + MuiCircularProgress: string; +}; +export declare const UI_HELPER_ELEMENTS: { + MuiButtonLabel: string; + MuiToggleButtonLabel: string; + MuiBoxLabel: string; + MuiTableHead: string; + MuiTableCell: string; + MuiTableRow: string; + MuiTypographyColorPrimary: string; + MuiSwitchColorPrimary: string; + MuiButtonTextPrimary: string; + MuiCard: (cardHeading: string) => string; + MuiCardRoot: (cardText: string) => string; + MuiTable: string; + MuiCardHeader: string; + MuiInputBase: string; + MuiTypography: string; + MuiAlert: string; + tabs: string; + rowByText: (text: string) => string; +}; +//# sourceMappingURL=global-obj.d.ts.map \ No newline at end of file diff --git a/dist/playwright/page-objects/global-obj.d.ts.map b/dist/playwright/page-objects/global-obj.d.ts.map new file mode 100644 index 0000000..666c326 --- /dev/null +++ b/dist/playwright/page-objects/global-obj.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"global-obj.d.ts","sourceRoot":"","sources":["../../../src/playwright/page-objects/global-obj.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,YAAY;;;CAGxB,CAAC;AAEF,eAAO,MAAM,kBAAkB;;;;;;;;;;2BAWN,MAAM;4BAEL,MAAM;;;;;;;sBAQZ,MAAM;CACzB,CAAC"} \ No newline at end of file diff --git a/dist/playwright/page-objects/global-obj.js b/dist/playwright/page-objects/global-obj.js new file mode 100644 index 0000000..e4b6629 --- /dev/null +++ b/dist/playwright/page-objects/global-obj.js @@ -0,0 +1,24 @@ +export const WAIT_OBJECTS = { + MuiLinearProgress: 'div[class*="MuiLinearProgress-root"]', + MuiCircularProgress: '[class*="MuiCircularProgress-root"]', +}; +export const UI_HELPER_ELEMENTS = { + MuiButtonLabel: 'span[class^="MuiButton-label"],button[class*="MuiButton-root"]', + MuiToggleButtonLabel: 'span[class^="MuiToggleButton-label"]', + MuiBoxLabel: 'div[class*="MuiBox-root"] label', + MuiTableHead: 'th[class*="MuiTableCell-root"]', + MuiTableCell: 'td[class*="MuiTableCell-root"]', + MuiTableRow: 'tr[class*="MuiTableRow-root"]', + MuiTypographyColorPrimary: ".MuiTypography-colorPrimary", + MuiSwitchColorPrimary: ".MuiSwitch-colorPrimary", + MuiButtonTextPrimary: ".MuiButton-textPrimary", + MuiCard: (cardHeading) => `//div[contains(@class,'MuiCardHeader-root') and descendant::*[text()='${cardHeading}']]/..`, + MuiCardRoot: (cardText) => `//div[contains(@class,'MuiCard-root')][descendant::text()[contains(., '${cardText}')]]`, + MuiTable: "table.MuiTable-root", + MuiCardHeader: 'div[class*="MuiCardHeader-root"]', + MuiInputBase: 'div[class*="MuiInputBase-root"]', + MuiTypography: 'span[class*="MuiTypography-root"]', + MuiAlert: 'div[class*="MuiAlert-message"]', + tabs: '[role="tab"]', + rowByText: (text) => `tr:has(:text-is("${text}"))`, +}; diff --git a/dist/playwright/page-objects/page-obj.d.ts b/dist/playwright/page-objects/page-obj.d.ts new file mode 100644 index 0000000..c0992bc --- /dev/null +++ b/dist/playwright/page-objects/page-obj.d.ts @@ -0,0 +1,41 @@ +export declare const HOME_PAGE_COMPONENTS: { + MuiAccordion: string; + MuiCard: string; +}; +export declare const SEARCH_OBJECTS_COMPONENTS: { + ariaLabelSearch: string; + placeholderSearch: string; +}; +export declare const CATALOG_IMPORT_COMPONENTS: { + componentURL: string; +}; +export declare const KUBERNETES_COMPONENTS: { + MuiAccordion: string; + statusOk: string; + podLogs: string; + MuiSnackbarContent: string; +}; +export declare const BACKSTAGE_SHOWCASE_COMPONENTS: { + tableNextPage: string; + tablePreviousPage: string; + tableLastPage: string; + tableFirstPage: string; + tableRows: string; + tablePageSelectBox: string; +}; +export declare const SETTINGS_PAGE_COMPONENTS: { + userSettingsMenu: string; + signOut: string; +}; +export declare const ROLES_PAGE_COMPONENTS: { + editRole: (name: string) => string; + deleteRole: (name: string) => string; +}; +export declare const DELETE_ROLE_COMPONENTS: { + roleName: string; +}; +export declare const ROLE_OVERVIEW_COMPONENTS_TEST_ID: { + updatePolicies: string; + updateMembers: string; +}; +//# sourceMappingURL=page-obj.d.ts.map \ No newline at end of file diff --git a/dist/playwright/page-objects/page-obj.d.ts.map b/dist/playwright/page-objects/page-obj.d.ts.map new file mode 100644 index 0000000..14dc83c --- /dev/null +++ b/dist/playwright/page-objects/page-obj.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"page-obj.d.ts","sourceRoot":"","sources":["../../../src/playwright/page-objects/page-obj.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,oBAAoB;;;CAGhC,CAAC;AAEF,eAAO,MAAM,yBAAyB;;;CAGrC,CAAC;AAEF,eAAO,MAAM,yBAAyB;;CAErC,CAAC;AAEF,eAAO,MAAM,qBAAqB;;;;;CAKjC,CAAC;AAEF,eAAO,MAAM,6BAA6B;;;;;;;CAOzC,CAAC;AAEF,eAAO,MAAM,wBAAwB;;;CAGpC,CAAC;AAEF,eAAO,MAAM,qBAAqB;qBACf,MAAM;uBACJ,MAAM;CAC1B,CAAC;AAEF,eAAO,MAAM,sBAAsB;;CAElC,CAAC;AAEF,eAAO,MAAM,gCAAgC;;;CAG5C,CAAC"} \ No newline at end of file diff --git a/dist/playwright/page-objects/page-obj.js b/dist/playwright/page-objects/page-obj.js new file mode 100644 index 0000000..e673e2b --- /dev/null +++ b/dist/playwright/page-objects/page-obj.js @@ -0,0 +1,40 @@ +export const HOME_PAGE_COMPONENTS = { + MuiAccordion: 'div[class*="MuiAccordion-root-"]', + MuiCard: 'div[class*="MuiCard-root-"]', +}; +export const SEARCH_OBJECTS_COMPONENTS = { + ariaLabelSearch: 'input[aria-label="Search"]', + placeholderSearch: 'input[placeholder="Search"]', +}; +export const CATALOG_IMPORT_COMPONENTS = { + componentURL: 'input[name="url"]', +}; +export const KUBERNETES_COMPONENTS = { + MuiAccordion: 'div[class*="MuiAccordion-root-"]', + statusOk: 'span[aria-label="Status ok"]', + podLogs: 'label[aria-label="get logs"]', + MuiSnackbarContent: 'div[class*="MuiSnackbarContent-message-"]', +}; +export const BACKSTAGE_SHOWCASE_COMPONENTS = { + tableNextPage: 'button[aria-label="Next Page"]', + tablePreviousPage: 'button[aria-label="Previous Page"]', + tableLastPage: 'button[aria-label="Last Page"]', + tableFirstPage: 'button[aria-label="First Page"]', + tableRows: 'table[class*="MuiTable-root-"] tbody tr', + tablePageSelectBox: 'div[class*="MuiTablePagination-input"]', +}; +export const SETTINGS_PAGE_COMPONENTS = { + userSettingsMenu: 'button[data-testid="user-settings-menu"]', + signOut: 'li[data-testid="sign-out"]', +}; +export const ROLES_PAGE_COMPONENTS = { + editRole: (name) => `button[data-testid="edit-role-${name}"]`, + deleteRole: (name) => `button[data-testid="delete-role-${name}"]`, +}; +export const DELETE_ROLE_COMPONENTS = { + roleName: 'input[name="delete-role"]', +}; +export const ROLE_OVERVIEW_COMPONENTS_TEST_ID = { + updatePolicies: "update-policies", + updateMembers: "update-members", +}; diff --git a/dist/playwright/pages/catalog-import.d.ts b/dist/playwright/pages/catalog-import.d.ts new file mode 100644 index 0000000..869dde9 --- /dev/null +++ b/dist/playwright/pages/catalog-import.d.ts @@ -0,0 +1,31 @@ +import type { Page } from "@playwright/test"; +export declare class CatalogImportPage { + private page; + private uiHelper; + constructor(page: Page); + /** + * Fills the component URL input and clicks the "Analyze" button. + * Waits until the analyze button is no longer visible (processing done). + * + * @param url - The URL of the component to analyze + */ + private analyzeAndWait; + /** + * Returns true if the component is already registered + * (i.e., "Refresh" button is visible instead of "Import"). + * + * @returns boolean indicating if the component is already registered + */ + isComponentAlreadyRegistered(): Promise; + /** + * Registers an existing component if it has not been registered yet. + * If already registered, clicks the "Refresh" button instead. + * + * @param url - The component URL to register + * @param clickViewComponent - Whether to click "View Component" after import + */ + registerExistingComponent(url: string, clickViewComponent?: boolean): Promise; + analyzeComponent(url: string): Promise; + inspectEntityAndVerifyYaml(text: string): Promise; +} +//# sourceMappingURL=catalog-import.d.ts.map \ No newline at end of file diff --git a/dist/playwright/pages/catalog-import.d.ts.map b/dist/playwright/pages/catalog-import.d.ts.map new file mode 100644 index 0000000..7113acf --- /dev/null +++ b/dist/playwright/pages/catalog-import.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"catalog-import.d.ts","sourceRoot":"","sources":["../../../src/playwright/pages/catalog-import.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAK7C,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,IAAI,CAAO;IACnB,OAAO,CAAC,QAAQ,CAAW;gBAEf,IAAI,EAAE,IAAI;IAKtB;;;;;OAKG;YACW,cAAc;IAO5B;;;;;OAKG;IACG,4BAA4B,IAAI,OAAO,CAAC,OAAO,CAAC;IAItD;;;;;;OAMG;IACG,yBAAyB,CAC7B,GAAG,EAAE,MAAM,EACX,kBAAkB,GAAE,OAAc;IAiB9B,gBAAgB,CAAC,GAAG,EAAE,MAAM;IAK5B,0BAA0B,CAAC,IAAI,EAAE,MAAM;CAO9C"} \ No newline at end of file diff --git a/dist/playwright/pages/catalog-import.js b/dist/playwright/pages/catalog-import.js new file mode 100644 index 0000000..182ea94 --- /dev/null +++ b/dist/playwright/pages/catalog-import.js @@ -0,0 +1,65 @@ +import { expect } from "@playwright/test"; +import { UIhelper } from "../helpers/ui-helper.js"; +import { CATALOG_IMPORT_COMPONENTS } from "../page-objects/page-obj.js"; +export class CatalogImportPage { + page; + uiHelper; + constructor(page) { + this.page = page; + this.uiHelper = new UIhelper(page); + } + /** + * Fills the component URL input and clicks the "Analyze" button. + * Waits until the analyze button is no longer visible (processing done). + * + * @param url - The URL of the component to analyze + */ + async analyzeAndWait(url) { + await this.page.fill(CATALOG_IMPORT_COMPONENTS.componentURL, url); + await expect(await this.uiHelper.clickButton("Analyze")).not.toBeVisible({ + timeout: 25_000, + }); + } + /** + * Returns true if the component is already registered + * (i.e., "Refresh" button is visible instead of "Import"). + * + * @returns boolean indicating if the component is already registered + */ + async isComponentAlreadyRegistered() { + return await this.uiHelper.isBtnVisible("Refresh"); + } + /** + * Registers an existing component if it has not been registered yet. + * If already registered, clicks the "Refresh" button instead. + * + * @param url - The component URL to register + * @param clickViewComponent - Whether to click "View Component" after import + */ + async registerExistingComponent(url, clickViewComponent = true) { + await this.analyzeAndWait(url); + const isComponentAlreadyRegistered = await this.isComponentAlreadyRegistered(); + if (isComponentAlreadyRegistered) { + await this.uiHelper.clickButton("Refresh"); + expect(await this.uiHelper.isBtnVisible("Register another")).toBeTruthy(); + } + else { + await this.uiHelper.clickButton("Import"); + if (clickViewComponent) { + await this.uiHelper.clickButton("View Component"); + } + } + return isComponentAlreadyRegistered; + } + async analyzeComponent(url) { + await this.page.fill(CATALOG_IMPORT_COMPONENTS.componentURL, url); + await this.uiHelper.clickButton("Analyze"); + } + async inspectEntityAndVerifyYaml(text) { + await this.page.getByTitle("More").click(); + await this.page.getByRole("menuitem").getByText("Inspect entity").click(); + await this.uiHelper.clickTab("Raw YAML"); + await expect(this.page.getByTestId("code-snippet")).toContainText(text); + await this.uiHelper.clickButton("Close"); + } +} diff --git a/dist/playwright/pages/catalog.d.ts b/dist/playwright/pages/catalog.d.ts new file mode 100644 index 0000000..0ab9687 --- /dev/null +++ b/dist/playwright/pages/catalog.d.ts @@ -0,0 +1,14 @@ +import type { Locator, Page } from "@playwright/test"; +export declare class CatalogPage { + private page; + private uiHelper; + private searchField; + constructor(page: Page); + go(): Promise; + goToByName(name: string): Promise; + goToBackstageJanusProjectCITab(): Promise; + goToBackstageJanusProject(): Promise; + search(s: string): Promise; + tableRow(content: string): Promise; +} +//# sourceMappingURL=catalog.d.ts.map \ No newline at end of file diff --git a/dist/playwright/pages/catalog.d.ts.map b/dist/playwright/pages/catalog.d.ts.map new file mode 100644 index 0000000..078039c --- /dev/null +++ b/dist/playwright/pages/catalog.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../../../src/playwright/pages/catalog.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAItD,qBAAa,WAAW;IACtB,OAAO,CAAC,IAAI,CAAO;IACnB,OAAO,CAAC,QAAQ,CAAW;IAC3B,OAAO,CAAC,WAAW,CAAU;gBAEjB,IAAI,EAAE,IAAI;IAMhB,EAAE;IAIF,UAAU,CAAC,IAAI,EAAE,MAAM;IAKvB,8BAA8B;IAO9B,yBAAyB;IAIzB,MAAM,CAAC,CAAC,EAAE,MAAM;IAWhB,QAAQ,CAAC,OAAO,EAAE,MAAM;CAG/B"} \ No newline at end of file diff --git a/dist/playwright/pages/catalog.js b/dist/playwright/pages/catalog.js new file mode 100644 index 0000000..33320fd --- /dev/null +++ b/dist/playwright/pages/catalog.js @@ -0,0 +1,37 @@ +import { UIhelper } from "../helpers/ui-helper.js"; +//${RHDH_BASE_URL}/catalog page +export class CatalogPage { + page; + uiHelper; + searchField; + constructor(page) { + this.page = page; + this.uiHelper = new UIhelper(page); + this.searchField = page.locator("#input-with-icon-adornment"); + } + async go() { + await this.uiHelper.openSidebar("Catalog"); + } + async goToByName(name) { + await this.uiHelper.openCatalogSidebar("Component"); + await this.uiHelper.clickLink(name); + } + async goToBackstageJanusProjectCITab() { + await this.goToBackstageJanusProject(); + await this.uiHelper.clickTab("CI"); + await this.page.waitForSelector('h2:text("Pipeline Runs")'); + await this.uiHelper.verifyHeading("Pipeline Runs"); + } + async goToBackstageJanusProject() { + await this.goToByName("backstage-janus"); + } + async search(s) { + await this.searchField.clear(); + const searchResponse = this.page.waitForResponse(new RegExp(`${process.env.RHDH_BASE_URL}/api/catalog/entities/by-query/*`)); + await this.searchField.fill(s); + await searchResponse; + } + async tableRow(content) { + return this.page.locator(`tr >> a >> text="${content}"`); + } +} diff --git a/dist/playwright/pages/extensions.d.ts b/dist/playwright/pages/extensions.d.ts new file mode 100644 index 0000000..c0dd8f9 --- /dev/null +++ b/dist/playwright/pages/extensions.d.ts @@ -0,0 +1,38 @@ +import type { Page, Locator } from "@playwright/test"; +export declare class ExtensionsPage { + private page; + badge: Locator; + private uiHelper; + private commonHeadings; + private tableHeaders; + constructor(page: Page); + clickReadMoreByPluginTitle(pluginTitle: string): Promise; + selectDropdown(name: string): Promise; + toggleOption(name: string): Promise; + clickAway(): Promise; + selectSupportTypeFilter(supportType: string): Promise; + resetSupportTypeFilter(supportType: string): Promise; + verifyMultipleHeadings(headings?: string[]): Promise; + waitForSearchResults(searchText: string): Promise; + verifyPluginDetails({ pluginName, badgeLabel, badgeText, headings, includeTable, includeAbout, }: { + pluginName: string; + badgeLabel: string; + badgeText: string; + headings?: string[]; + includeTable?: boolean; + includeAbout?: boolean; + }): Promise; + verifySupportTypeBadge({ supportType, pluginName, badgeLabel, badgeText, tooltipText, searchTerm, headings, includeTable, includeAbout, }: { + supportType: string; + pluginName?: string; + badgeLabel: string; + badgeText: string; + tooltipText: string; + searchTerm?: string; + headings?: string[]; + includeTable?: boolean; + includeAbout?: boolean; + }): Promise; + verifyKeyValueRowElements(rowTitle: string, rowValue: string): Promise; +} +//# sourceMappingURL=extensions.d.ts.map \ No newline at end of file diff --git a/dist/playwright/pages/extensions.d.ts.map b/dist/playwright/pages/extensions.d.ts.map new file mode 100644 index 0000000..670d56d --- /dev/null +++ b/dist/playwright/pages/extensions.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"extensions.d.ts","sourceRoot":"","sources":["../../../src/playwright/pages/extensions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAItD,qBAAa,cAAc;IACzB,OAAO,CAAC,IAAI,CAAO;IACZ,KAAK,EAAE,OAAO,CAAC;IACtB,OAAO,CAAC,QAAQ,CAAW;IAE3B,OAAO,CAAC,cAAc,CAOpB;IACF,OAAO,CAAC,YAAY,CAMlB;gBAEU,IAAI,EAAE,IAAI;IAMhB,0BAA0B,CAAC,WAAW,EAAE,MAAM;IAM9C,cAAc,CAAC,IAAI,EAAE,MAAM;IAO3B,YAAY,CAAC,IAAI,EAAE,MAAM;IAOzB,SAAS;IAIT,uBAAuB,CAAC,WAAW,EAAE,MAAM;IAM3C,sBAAsB,CAAC,WAAW,EAAE,MAAM;IAM1C,sBAAsB,CAAC,QAAQ,GAAE,MAAM,EAAwB;IAO/D,oBAAoB,CAAC,UAAU,EAAE,MAAM;IAMvC,mBAAmB,CAAC,EACxB,UAAU,EACV,UAAU,EACV,SAAS,EACT,QAA8B,EAC9B,YAAmB,EACnB,YAAoB,GACrB,EAAE;QACD,UAAU,EAAE,MAAM,CAAC;QACnB,UAAU,EAAE,MAAM,CAAC;QACnB,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,YAAY,CAAC,EAAE,OAAO,CAAC;QACvB,YAAY,CAAC,EAAE,OAAO,CAAC;KACxB;IAuBK,sBAAsB,CAAC,EAC3B,WAAW,EACX,UAAU,EACV,UAAU,EACV,SAAS,EACT,WAAW,EACX,UAAU,EACV,QAA8B,EAC9B,YAAmB,EACnB,YAAoB,GACrB,EAAE;QACD,WAAW,EAAE,MAAM,CAAC;QACpB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,UAAU,EAAE,MAAM,CAAC;QACnB,SAAS,EAAE,MAAM,CAAC;QAClB,WAAW,EAAE,MAAM,CAAC;QACpB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,YAAY,CAAC,EAAE,OAAO,CAAC;QACvB,YAAY,CAAC,EAAE,OAAO,CAAC;KACxB;IA2BK,yBAAyB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;CAMnE"} \ No newline at end of file diff --git a/dist/playwright/pages/extensions.js b/dist/playwright/pages/extensions.js new file mode 100644 index 0000000..5d8d124 --- /dev/null +++ b/dist/playwright/pages/extensions.js @@ -0,0 +1,110 @@ +import { expect } from "@playwright/test"; +import { UIhelper } from "../helpers/ui-helper.js"; +export class ExtensionsPage { + page; + badge; + uiHelper; + commonHeadings = [ + "Versions", + "Author", + "Tags", + "Category", + "Publisher", + "Support Provider", + ]; + tableHeaders = [ + "Package name", + "Version", + "Role", + "Backstage compatibility version", + "Status", + ]; + constructor(page) { + this.page = page; + this.badge = this.page.getByTestId("TaskAltIcon"); + this.uiHelper = new UIhelper(page); + } + async clickReadMoreByPluginTitle(pluginTitle) { + const allCards = this.page.locator(".v5-MuiPaper-outlined"); + const targetCard = allCards.filter({ hasText: pluginTitle }); + await targetCard.getByRole("link", { name: "Read more" }).click(); + } + async selectDropdown(name) { + await this.page + .getByLabel(name) + .getByRole("button", { name: "Open" }) + .click(); + } + async toggleOption(name) { + await this.page + .getByRole("option", { name: name }) + .getByRole("checkbox") + .click(); + } + async clickAway() { + await this.page.locator("#menu- div").first().click(); + } + async selectSupportTypeFilter(supportType) { + await this.selectDropdown("Support type"); + await this.toggleOption(supportType); + await this.page.keyboard.press("Escape"); + } + async resetSupportTypeFilter(supportType) { + await this.selectDropdown("Support type"); + await this.toggleOption(supportType); + await this.page.keyboard.press("Escape"); + } + async verifyMultipleHeadings(headings = this.commonHeadings) { + for (const heading of headings) { + console.log(`Verifying heading: ${heading}`); + await this.uiHelper.verifyHeading(heading); + } + } + async waitForSearchResults(searchText) { + await expect(this.page.locator(".v5-MuiPaper-outlined").first()).toContainText(searchText, { timeout: 10000 }); + } + async verifyPluginDetails({ pluginName, badgeLabel, badgeText, headings = this.commonHeadings, includeTable = true, includeAbout = false, }) { + await this.clickReadMoreByPluginTitle(pluginName); + await expect(this.page.getByLabel(badgeLabel).getByText(badgeText)).toBeVisible(); + if (includeAbout) { + await this.uiHelper.verifyText("About"); + } + await this.verifyMultipleHeadings(headings); + if (includeTable) { + await this.uiHelper.verifyTableHeadingAndRows(this.tableHeaders); + } + await this.page + .getByRole("button", { + name: "close", + }) + .click(); + } + async verifySupportTypeBadge({ supportType, pluginName, badgeLabel, badgeText, tooltipText, searchTerm, headings = this.commonHeadings, includeTable = true, includeAbout = false, }) { + await this.selectSupportTypeFilter(supportType); + if (searchTerm) { + await this.uiHelper.searchInputPlaceholder(searchTerm); + await this.waitForSearchResults(searchTerm); + } + if (pluginName) { + await this.verifyPluginDetails({ + pluginName, + badgeLabel, + badgeText, + headings, + includeTable, + includeAbout, + }); + } + else { + await expect(this.page.getByLabel(badgeLabel).first()).toBeVisible(); + await expect(this.badge.first()).toBeVisible(); + await this.badge.first().hover(); + await this.uiHelper.verifyTextInTooltip(tooltipText); + } + await this.resetSupportTypeFilter(supportType); + } + async verifyKeyValueRowElements(rowTitle, rowValue) { + const rowLocator = this.page.locator(".v5-MuiTableRow-root"); + await expect(rowLocator.filter({ hasText: rowTitle })).toContainText(rowValue); + } +} diff --git a/dist/playwright/pages/home-page.d.ts b/dist/playwright/pages/home-page.d.ts new file mode 100644 index 0000000..ce91ea4 --- /dev/null +++ b/dist/playwright/pages/home-page.d.ts @@ -0,0 +1,10 @@ +import type { Page } from "@playwright/test"; +export declare class HomePage { + private page; + private uiHelper; + constructor(page: Page); + verifyQuickSearchBar(text: string): Promise; + verifyQuickAccess(section: string, quickAccessItem: string, expand?: boolean): Promise; + verifyVisitedCardContent(section: string): Promise; +} +//# sourceMappingURL=home-page.d.ts.map \ No newline at end of file diff --git a/dist/playwright/pages/home-page.d.ts.map b/dist/playwright/pages/home-page.d.ts.map new file mode 100644 index 0000000..41222cc --- /dev/null +++ b/dist/playwright/pages/home-page.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"home-page.d.ts","sourceRoot":"","sources":["../../../src/playwright/pages/home-page.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAG7C,qBAAa,QAAQ;IACnB,OAAO,CAAC,IAAI,CAAO;IACnB,OAAO,CAAC,QAAQ,CAAW;gBAEf,IAAI,EAAE,IAAI;IAIhB,oBAAoB,CAAC,IAAI,EAAE,MAAM;IAUjC,iBAAiB,CACrB,OAAO,EAAE,MAAM,EACf,eAAe,EAAE,MAAM,EACvB,MAAM,UAAQ;IAyBV,wBAAwB,CAAC,OAAO,EAAE,MAAM;CAY/C"} \ No newline at end of file diff --git a/dist/playwright/pages/home-page.js b/dist/playwright/pages/home-page.js new file mode 100644 index 0000000..c98f0bd --- /dev/null +++ b/dist/playwright/pages/home-page.js @@ -0,0 +1,46 @@ +import { HOME_PAGE_COMPONENTS, SEARCH_OBJECTS_COMPONENTS, } from "../page-objects/page-obj.js"; +import { UIhelper } from "../helpers/ui-helper.js"; +import { expect } from "@playwright/test"; +export class HomePage { + page; + uiHelper; + constructor(page) { + this.page = page; + this.uiHelper = new UIhelper(page); + } + async verifyQuickSearchBar(text) { + const searchBar = this.page.locator(SEARCH_OBJECTS_COMPONENTS.ariaLabelSearch); + await searchBar.waitFor(); + await searchBar.fill(""); + await searchBar.type(text + "\n"); // '\n' simulates pressing the Enter key + await this.uiHelper.verifyLink(text); + } + async verifyQuickAccess(section, quickAccessItem, expand = false) { + await this.page.waitForSelector(HOME_PAGE_COMPONENTS.MuiAccordion, { + state: "visible", + }); + const sectionLocator = this.page + .locator(HOME_PAGE_COMPONENTS.MuiAccordion) + .filter({ hasText: section }); + if (expand) { + await sectionLocator.click(); + await this.page.waitForTimeout(500); + } + const itemLocator = sectionLocator + .locator(`a div[class*="MuiListItemText-root"]`) + .filter({ hasText: quickAccessItem }); + await itemLocator.waitFor({ state: "visible" }); + const isVisible = itemLocator; + await expect(isVisible).toBeVisible(); + } + async verifyVisitedCardContent(section) { + await this.page.waitForSelector(HOME_PAGE_COMPONENTS.MuiCard, { + state: "visible", + }); + const sectionLocator = this.page + .locator(HOME_PAGE_COMPONENTS.MuiCard) + .filter({ hasText: section }); + const itemLocator = sectionLocator.locator(`li[class*="MuiListItem-root"]`); + expect(await itemLocator.count()).toBeGreaterThanOrEqual(0); + } +} diff --git a/dist/playwright/pages/index.d.ts b/dist/playwright/pages/index.d.ts new file mode 100644 index 0000000..29a5a43 --- /dev/null +++ b/dist/playwright/pages/index.d.ts @@ -0,0 +1,7 @@ +export { CatalogImportPage } from "./catalog-import.js"; +export { CatalogPage } from "./catalog.js"; +export { ExtensionsPage } from "./extensions.js"; +export { HomePage } from "./home-page.js"; +export { NotificationPage } from "./notifications.js"; +export { OrchestratorPage } from "./orchestrator.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/playwright/pages/index.d.ts.map b/dist/playwright/pages/index.d.ts.map new file mode 100644 index 0000000..e0dfea1 --- /dev/null +++ b/dist/playwright/pages/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/playwright/pages/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC"} \ No newline at end of file diff --git a/dist/playwright/pages/index.js b/dist/playwright/pages/index.js new file mode 100644 index 0000000..7d0d12e --- /dev/null +++ b/dist/playwright/pages/index.js @@ -0,0 +1,6 @@ +export { CatalogImportPage } from "./catalog-import.js"; +export { CatalogPage } from "./catalog.js"; +export { ExtensionsPage } from "./extensions.js"; +export { HomePage } from "./home-page.js"; +export { NotificationPage } from "./notifications.js"; +export { OrchestratorPage } from "./orchestrator.js"; diff --git a/dist/playwright/pages/notifications.d.ts b/dist/playwright/pages/notifications.d.ts new file mode 100644 index 0000000..81bec0e --- /dev/null +++ b/dist/playwright/pages/notifications.d.ts @@ -0,0 +1,24 @@ +import { type Page } from "@playwright/test"; +export declare class NotificationPage { + private readonly page; + private readonly uiHelper; + constructor(page: Page); + clickNotificationsNavBarItem(): Promise; + notificationContains(text: string | RegExp): Promise; + clickNotificationHeadingLink(text: string | RegExp): Promise; + markAllNotificationsAsRead(): Promise; + selectAllNotifications(): Promise; + selectNotification(nth?: number): Promise; + selectSeverity(severity?: string): Promise; + saveSelected(): Promise; + saveAllSelected(): Promise; + viewSaved(): Promise; + markLastNotificationAsRead(): Promise; + markNotificationAsRead(text: string): Promise; + markLastNotificationAsUnRead(): Promise; + viewRead(): Promise; + viewUnRead(): Promise; + sortByOldestOnTop(): Promise; + sortByNewestOnTop(): Promise; +} +//# sourceMappingURL=notifications.d.ts.map \ No newline at end of file diff --git a/dist/playwright/pages/notifications.d.ts.map b/dist/playwright/pages/notifications.d.ts.map new file mode 100644 index 0000000..a40a3e7 --- /dev/null +++ b/dist/playwright/pages/notifications.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"notifications.d.ts","sourceRoot":"","sources":["../../../src/playwright/pages/notifications.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAGrD,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAO;IAC5B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAW;gBAExB,IAAI,EAAE,IAAI;IAKhB,4BAA4B;IAO5B,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAW1C,4BAA4B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAOlD,0BAA0B;IAiB1B,sBAAsB;IAItB,kBAAkB,CAAC,GAAG,SAAI;IAI1B,cAAc,CAAC,QAAQ,SAAK;IAW5B,YAAY;IAWZ,eAAe;IAWf,SAAS;IAQT,0BAA0B;IAK1B,sBAAsB,CAAC,IAAI,EAAE,MAAM;IAKnC,4BAA4B;IAK5B,QAAQ;IAUR,UAAU;IAUV,iBAAiB;IAQjB,iBAAiB;CAOxB"} \ No newline at end of file diff --git a/dist/playwright/pages/notifications.js b/dist/playwright/pages/notifications.js new file mode 100644 index 0000000..6cd5ac8 --- /dev/null +++ b/dist/playwright/pages/notifications.js @@ -0,0 +1,112 @@ +import { expect } from "@playwright/test"; +import { UIhelper } from "../helpers/ui-helper.js"; +export class NotificationPage { + page; + uiHelper; + constructor(page) { + this.page = page; + this.uiHelper = new UIhelper(page); + } + async clickNotificationsNavBarItem() { + await this.uiHelper.openSidebar("Notifications"); + await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); + } + async notificationContains(text) { + await this.page.getByLabel(/.*rows/).click(); + // always expand the notifications table to show as many notifications as possible + await this.page.getByRole("option", { name: "20" }).click(); + await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); + const row = this.page.locator(`tr`, { hasText: text }).first(); + await expect(row).toHaveCount(1); + } + async clickNotificationHeadingLink(text) { + await this.page + .getByRole("cell", { name: text, exact: true }) + .first() + .getByRole("heading") + .click(); + } + async markAllNotificationsAsRead() { + const markAllNotificationsAsReadIsVisible = await this.page + .getByTitle("Mark all read") + .getByRole("button") + .isVisible(); + console.log(markAllNotificationsAsReadIsVisible); + // If button isn't visible there are no records in the notification table + if (markAllNotificationsAsReadIsVisible.toString() != "false") { + await this.page.getByTitle("Mark all read").getByRole("button").click(); + await this.page.getByRole("button", { name: "MARK ALL" }).click(); + await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); + await expect(this.page.getByText("No records to display")).toBeVisible(); + } + } + async selectAllNotifications() { + await this.page.getByRole("checkbox").first().click(); + } + async selectNotification(nth = 1) { + await this.page.getByRole("checkbox").nth(nth).click(); + } + async selectSeverity(severity = "") { + await this.page.getByLabel("Severity").click(); + await this.page.getByRole("option", { name: severity }).click(); + await expect(this.page.getByRole("table").filter({ hasText: "Rows per page" })).toBeVisible(); + await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); + } + async saveSelected() { + await this.page + .locator("thead") + .getByTitle("Save selected for later") + .getByRole("button") + .click(); + await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); + } + async saveAllSelected() { + await this.page + .locator("thead") + .getByTitle("Save selected for later") + .getByRole("button") + .click(); + await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); + } + async viewSaved() { + await this.page.getByLabel("View").click(); + await this.page.getByRole("option", { name: "Saved" }).click(); + await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); + } + async markLastNotificationAsRead() { + const row = this.page.locator("td:nth-child(3) > div").first(); + await row.getByRole("button").nth(1).click(); + } + async markNotificationAsRead(text) { + const row = this.page.locator(`tr:has-text("${text}")`); + await row.getByRole("button").nth(1).click(); + } + async markLastNotificationAsUnRead() { + const row = this.page.locator("td:nth-child(3) > div").first(); + await row.getByRole("button").nth(1).click(); + } + async viewRead() { + await this.page.getByLabel("View").click(); + await this.page + .getByRole("option", { name: "Read notifications", exact: true }) + .click(); + await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); + } + async viewUnRead() { + await this.page.getByLabel("View").click(); + await this.page + .getByRole("option", { name: "Unread notifications", exact: true }) + .click(); + await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); + } + async sortByOldestOnTop() { + await this.page.getByLabel("Sort by").click(); + await this.page.getByRole("option", { name: "Oldest on top" }).click(); + await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); + } + async sortByNewestOnTop() { + await this.page.getByLabel("Sort by").click(); + await this.page.getByRole("option", { name: "Newest on top" }).click(); + await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); + } +} diff --git a/dist/playwright/pages/orchestrator.d.ts b/dist/playwright/pages/orchestrator.d.ts new file mode 100644 index 0000000..eb98ce4 --- /dev/null +++ b/dist/playwright/pages/orchestrator.d.ts @@ -0,0 +1,23 @@ +import { type Page } from "@playwright/test"; +export declare class OrchestratorPage { + private readonly page; + constructor(page: Page); + selectGreetingWorkflowItem(timeout?: number): Promise; + runGreetingWorkflow(language?: string, status?: string): Promise; + reRunGreetingWorkflow(language?: string, status?: string): Promise; + validateGreetingWorkflow(): Promise; + validateWorkflowRunsDetails(): Promise; + validateWorkflowAllRuns(): Promise; + validateWorkflowAllRunsStatusIcons(): Promise; + abortWorkflow(): Promise; + selectFailSwitchWorkflowItem(timeout?: number): Promise; + runFailSwitchWorkflow(input?: string): Promise; + validateWorkflowStatusDetails(status?: string): Promise; + validateCurrentWorkflowStatus(status?: string, timeout?: number): Promise; + reRunFailSwitchWorkflow(input?: string): Promise; + reRunOnFailure(input?: string): Promise; + verifyWorkflowsTabVisible(): Promise; + verifyWorkflowInEntityTab(workflowName: string): Promise; + clickWorkflowsTab(): Promise; +} +//# sourceMappingURL=orchestrator.d.ts.map \ No newline at end of file diff --git a/dist/playwright/pages/orchestrator.d.ts.map b/dist/playwright/pages/orchestrator.d.ts.map new file mode 100644 index 0000000..43d65e9 --- /dev/null +++ b/dist/playwright/pages/orchestrator.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"orchestrator.d.ts","sourceRoot":"","sources":["../../../src/playwright/pages/orchestrator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAGrD,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAO;gBAEhB,IAAI,EAAE,IAAI;IAIhB,0BAA0B,CAAC,OAAO,GAAE,MAAc;IAclD,mBAAmB,CAAC,QAAQ,SAAY,EAAE,MAAM,SAAc;IAe9D,qBAAqB,CAAC,QAAQ,SAAY,EAAE,MAAM,SAAc;IAchE,wBAAwB;IAwDxB,2BAA2B;IAS3B,uBAAuB;IAiDvB,kCAAkC;IA0BlC,aAAa;IAab,4BAA4B,CAAC,OAAO,GAAE,MAAc;IAepD,qBAAqB,CAAC,KAAK,SAAO;IAsBlC,6BAA6B,CAAC,MAAM,SAAc;IA+DlD,6BAA6B,CAAC,MAAM,SAAc,EAAE,OAAO,SAAS;IAQpE,uBAAuB,CAAC,KAAK,SAAO;IASpC,cAAc,CAAC,KAAK,SAAoB;IAQxC,yBAAyB;IAKzB,yBAAyB,CAAC,YAAY,EAAE,MAAM;IAK9C,iBAAiB;CAIxB"} \ No newline at end of file diff --git a/dist/playwright/pages/orchestrator.js b/dist/playwright/pages/orchestrator.js new file mode 100644 index 0000000..4684950 --- /dev/null +++ b/dist/playwright/pages/orchestrator.js @@ -0,0 +1,248 @@ +import { expect } from "@playwright/test"; +import { workflowsTable } from "./workflows.js"; +export class OrchestratorPage { + page; + constructor(page) { + this.page = page; + } + async selectGreetingWorkflowItem(timeout = 30000) { + const workflowHeader = this.page.getByRole("heading", { + name: "Workflows", + }); + await expect(workflowHeader).toBeVisible(); + await expect(workflowHeader).toHaveText("Workflows"); + await expect(workflowsTable(this.page)).toBeVisible(); + const greetingLink = this.page.getByRole("link", { + name: "Greeting workflow", + }); + await expect(greetingLink).toBeVisible({ timeout }); + await greetingLink.click(); + } + async runGreetingWorkflow(language = "English", status = "Completed") { + const runButton = this.page.getByRole("button", { name: "Run" }); + await expect(runButton).toBeVisible(); + await runButton.click(); + await this.page.getByLabel("Language").click(); + await this.page.getByRole("option", { name: language }).click(); + await this.page.getByRole("button", { name: "Next" }).click(); + await this.page.getByRole("button", { name: "Run" }).click(); + await expect(this.page.getByText(`${status}`, { exact: true })).toBeVisible({ + timeout: 600000, + }); + } + async reRunGreetingWorkflow(language = "English", status = "Completed") { + await expect(this.page.getByText("Run again")).toBeVisible(); + await this.page.getByText("Run again").click(); + await this.page.getByLabel("Language").click(); + await this.page.getByRole("option", { name: language }).click(); + await this.page.getByRole("button", { name: "Next" }).click(); + await this.page.getByRole("button", { name: "Run" }).click(); + await expect(this.page.getByText(`${status}`, { exact: true })).toBeVisible({ + timeout: 600000, + }); + } + async validateGreetingWorkflow() { + await this.page.getByRole("tab", { name: "Workflows" }).click(); + const workflowHeader = this.page.getByRole("heading", { + name: "Workflows", + }); + await expect(workflowHeader).toBeVisible(); + await expect(workflowHeader).toHaveText("Workflows"); + await expect(workflowsTable(this.page)).toBeVisible(); + await expect(this.page.locator(`input[aria-label="Search"]`)).toHaveAttribute("placeholder", "Filter"); + await expect(this.page.getByRole("columnheader", { name: "Name", exact: true })).toBeVisible(); + await expect(this.page.getByRole("columnheader", { + name: "Workflow Status", + exact: true, + })).toBeVisible(); + await expect(this.page.getByRole("columnheader", { name: "Last run", exact: true })).toBeVisible(); + await expect(this.page.getByRole("columnheader", { + name: "Last run status", + exact: true, + })).toBeVisible(); + await expect(this.page.getByRole("columnheader", { name: "Actions", exact: true })).toBeVisible(); + const workFlowRow = this.page.locator(`tr:has-text("Greeting workflow")`); + await expect(workFlowRow.locator("td").nth(0)).toHaveText("Greeting workflow"); + await expect(workFlowRow.locator("td").nth(1)).toHaveText("Available"); + await expect(workFlowRow.locator("td").nth(2)).toHaveText(/^\d{1,2}\/\d{1,2}\/\d{4}, \d{1,2}:\d{1,2}:\d{1,2} (AM|PM)$/); + await expect(workFlowRow.locator("td").nth(3)).toHaveText("Completed"); + await expect(workFlowRow.locator("td").nth(4)).toHaveText("YAML based greeting workflow"); + await expect(workFlowRow.getByRole("button", { name: "Run", exact: true }).first()).toBeVisible(); + await expect(workFlowRow.getByRole("button", { name: "View runs" }).first()).toBeVisible(); + await expect(workFlowRow.getByRole("button", { name: "View input schema" }).first()).toBeVisible(); + } + async validateWorkflowRunsDetails() { + await expect(this.page.getByText("Details")).toBeVisible(); + await expect(this.page.getByText("Results")).toBeVisible(); + await expect(this.page.getByText("Workflow progress")).toBeVisible(); + await expect(this.page.locator("div").filter({ hasText: "Completed" }).first()).toBeVisible(); + } + async validateWorkflowAllRuns() { + await this.page.getByRole("tab", { name: "all runs" }).click(); + await expect(this.page + .locator("tbody") + .getByRole("row") + .nth(0) + .getByRole("cell") + .nth(0)).toBeVisible(); + await expect(this.page.getByTestId("select").first()).toHaveAttribute("aria-label", "Status"); + await this.page + .getByLabel("Status") + .getByRole("button", { name: "All" }) + .click(); + const statuses = ["All", "Running", "Failed", "Completed", "Aborted"]; + for (const status of statuses) { + await expect(this.page.getByRole("option", { name: status })).toHaveText(status); + await this.page.getByRole("option", { name: status }).click(); + await this.page + .getByLabel("Status") + .getByRole("button", { name: status }) + .click(); + } + await this.page.getByRole("option", { name: "All" }).click(); + const columnHeaders = [ + "ID", + "Workflow name", + "Run Status", + "Started", + "Duration", + ]; + for (const columnHeader of columnHeaders) { + await expect(this.page.getByRole("columnheader", { + name: columnHeader, + exact: true, + })).toBeVisible(); + } + } + async validateWorkflowAllRunsStatusIcons() { + await this.page.getByRole("tab", { name: "all runs" }).click(); + const statuses = ["Running", "Failed", "Completed", "-- Aborted"]; + for (const status of statuses) { + await expect(this.page.getByText(status).first()).toHaveText(status); + } + await expect(this.page + .getByRole("cell", { name: /Running/ }) + .locator("svg") + .first()).toBeVisible(); + await expect(this.page + .getByRole("cell", { name: /Completed/ }) + .locator("svg") + .first()).toBeVisible(); + await expect(this.page + .getByRole("cell", { name: /Failed/ }) + .locator("svg") + .first()).toBeVisible(); + } + async abortWorkflow() { + await expect(this.page.getByRole("button", { name: "Abort" })).toBeEnabled(); + await this.page.getByRole("button", { name: "Abort" }).click(); + await this.page + .getByRole("dialog", { name: /Abort workflow run\?/i }) + .getByRole("button", { name: "Abort" }) + .click(); + await expect(this.page.getByText("Run has aborted")).toBeVisible(); + await expect(this.page.getByText("-- Aborted")).toBeVisible(); + } + async selectFailSwitchWorkflowItem(timeout = 30000) { + const workflowHeader = this.page.getByRole("heading", { + name: "Workflows", + }); + await expect(workflowHeader).toBeVisible(); + await expect(workflowHeader).toHaveText("Workflows"); + await expect(workflowsTable(this.page)).toBeVisible(); + // Wait for the workflow to be visible with explicit timeout for RBAC permission propagation + const failSwitchLink = this.page.getByRole("link", { + name: "FailSwitch workflow", + }); + await expect(failSwitchLink).toBeVisible({ timeout }); + await failSwitchLink.click(); + } + async runFailSwitchWorkflow(input = "OK") { + const runButton = this.page.getByRole("button", { name: "Run" }); + await expect(runButton).toBeVisible(); + await runButton.click(); + await this.page.getByLabel(/switch/i).click(); + await this.page.getByRole("option", { name: input }).click(); + await this.page.getByRole("button", { name: "Next" }).click(); + await this.page.getByRole("button", { name: "Run" }).click(); + switch (input) { + case "OK": + await this.validateCurrentWorkflowStatus("Completed"); + break; + case "KO": + await this.validateCurrentWorkflowStatus("Failed"); + break; + case "Wait": + await this.validateCurrentWorkflowStatus("Running"); + break; + } + } + async validateWorkflowStatusDetails(status = "Completed") { + const details = this.page + .getByRole("article") + .filter({ has: this.page.getByRole("heading", { name: "Workflow" }) }); + if (status === "Running") { + // Verify Run status heading and spinner in details area + await expect(details.getByRole("heading", { name: /Run\s*status/i })).toBeVisible(); + await expect(this.page + .locator("b") + .filter({ hasText: "Running" }) + .getByRole("progressbar")).toBeVisible(); + // Verify a button shows 'Running' text and has a spinner + const workflowButtons = this.page + .locator("div") + .filter({ hasText: "Abort Running..." }) + .nth(4); + await expect(workflowButtons).toHaveText(/Running/i); + await expect(workflowButtons.getByRole("progressbar")).toBeVisible(); + await expect(this.page.getByTestId("info-card-subheader").getByRole("img")).toBeVisible(); + // Verify workflow is running message is visible with timestamp + // Note: Following line is blocked in main branch due to bug RHDHBUGS-2220. TODO: Uncomment this once the bug is fixed. + await expect(this.page.getByText(/workflow is running\.?\s*Started at\s+\d{1,2}\/\d{1,2}\/\d{4},\s+\d{1,2}:\d{2}:\d{2}\s+(AM|PM)/i)).toBeVisible(); + } + if (status === "Failed") { + await expect(details.getByTestId("ErrorOutlineOutlinedIcon")).toBeVisible(); + await expect(this.page.getByText(/Run has failed at\s+\d{1,2}\/\d{1,2}\/\d{4},\s+\d{1,2}:\d{2}:\d{2}\s+(AM|PM)/)).toBeVisible(); + await expect(this.page.getByTestId("ErrorOutlineOutlinedIcon")).toBeVisible(); + } + if (status === "Completed") { + await expect(this.page + .locator("b") + .filter({ hasText: "Completed" }) + .getByTestId("CheckCircleOutlinedIcon")).toBeVisible(); + await expect(this.page.getByText(/Run completed at\s+\d{1,2}\/\d{1,2}\/\d{4},\s+\d{1,2}:\d{2}:\d{2}\s+(AM|PM)/)).toBeVisible(); + await expect(this.page.getByTestId("SuccessOutlinedIcon")).toBeVisible(); + } + } + async validateCurrentWorkflowStatus(status = "Completed", timeout = 120000) { + await expect(this.page.getByText(`${status}`, { exact: true })).toBeVisible({ + timeout, + }); + } + async reRunFailSwitchWorkflow(input = "OK") { + await expect(this.page.getByText("Run again")).toBeVisible(); + await this.page.getByText("Run again").click(); + await this.page.getByLabel("switch").click(); + await this.page.getByRole("option", { name: input }).click(); + await this.page.getByRole("button", { name: "Next" }).click(); + await this.page.getByRole("button", { name: "Run" }).click(); + } + async reRunOnFailure(input = "Entire workflow") { + await expect(this.page.getByText("Run again")).toBeVisible(); + await this.page.getByText("Run again").click(); + await this.page.getByRole("menuitem", { name: input }).click(); + } + // Entity-Workflow Integration Methods (RHIDP-11833 through RHIDP-11840) + async verifyWorkflowsTabVisible() { + const workflowsTab = this.page.getByRole("tab", { name: "Workflows" }); + await expect(workflowsTab).toBeVisible(); + } + async verifyWorkflowInEntityTab(workflowName) { + const workflowLink = this.page.getByRole("link", { name: workflowName }); + await expect(workflowLink).toBeVisible(); + } + async clickWorkflowsTab() { + await this.page.getByRole("tab", { name: "Workflows" }).click(); + await this.page.waitForLoadState("load"); + } +} diff --git a/dist/playwright/pages/workflows.d.ts b/dist/playwright/pages/workflows.d.ts new file mode 100644 index 0000000..f44189c --- /dev/null +++ b/dist/playwright/pages/workflows.d.ts @@ -0,0 +1,3 @@ +import type { Page } from "@playwright/test"; +export declare const workflowsTable: (page: Page) => import("playwright-core").Locator; +//# sourceMappingURL=workflows.d.ts.map \ No newline at end of file diff --git a/dist/playwright/pages/workflows.d.ts.map b/dist/playwright/pages/workflows.d.ts.map new file mode 100644 index 0000000..dc65d8e --- /dev/null +++ b/dist/playwright/pages/workflows.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"workflows.d.ts","sourceRoot":"","sources":["../../../src/playwright/pages/workflows.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAE7C,eAAO,MAAM,cAAc,GAAI,MAAM,IAAI,sCAC0B,CAAC"} \ No newline at end of file diff --git a/dist/playwright/pages/workflows.js b/dist/playwright/pages/workflows.js new file mode 100644 index 0000000..8e97449 --- /dev/null +++ b/dist/playwright/pages/workflows.js @@ -0,0 +1 @@ +export const workflowsTable = (page) => page.locator("#root div").filter({ hasText: "Workflows" }).nth(2); diff --git a/dist/playwright/run-once.d.ts b/dist/playwright/run-once.d.ts new file mode 100644 index 0000000..f06d16e --- /dev/null +++ b/dist/playwright/run-once.d.ts @@ -0,0 +1,11 @@ +/** + * Executes a function only once per test run, even across multiple workers. + * Automatically resets between test runs (each run uses a unique flag directory). + * Safe for fullyParallel: true (uses proper-lockfile for cross-process coordination). + * + * @param key - Unique identifier for this setup operation + * @param fn - Function to execute once + * @returns true if executed, false if skipped (already ran) + */ +export declare function runOnce(key: string, fn: () => Promise | void): Promise; +//# sourceMappingURL=run-once.d.ts.map \ No newline at end of file diff --git a/dist/playwright/run-once.d.ts.map b/dist/playwright/run-once.d.ts.map new file mode 100644 index 0000000..707ffe9 --- /dev/null +++ b/dist/playwright/run-once.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"run-once.d.ts","sourceRoot":"","sources":["../../src/playwright/run-once.ts"],"names":[],"mappings":"AAQA;;;;;;;;GAQG;AACH,wBAAsB,OAAO,CAC3B,GAAG,EAAE,MAAM,EACX,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,GAC7B,OAAO,CAAC,OAAO,CAAC,CAyBlB"} \ No newline at end of file diff --git a/dist/playwright/run-once.js b/dist/playwright/run-once.js new file mode 100644 index 0000000..08ff4b7 --- /dev/null +++ b/dist/playwright/run-once.js @@ -0,0 +1,40 @@ +import fs from "fs"; +import path from "path"; +import os from "os"; +import lockfile from "proper-lockfile"; +// Each test run gets its own flag directory (ppid = Playwright runner PID) +const flagDir = path.join(os.tmpdir(), `playwright-once-${process.ppid}`); +/** + * Executes a function only once per test run, even across multiple workers. + * Automatically resets between test runs (each run uses a unique flag directory). + * Safe for fullyParallel: true (uses proper-lockfile for cross-process coordination). + * + * @param key - Unique identifier for this setup operation + * @param fn - Function to execute once + * @returns true if executed, false if skipped (already ran) + */ +export async function runOnce(key, fn) { + const flagFile = path.join(flagDir, `${key}.done`); + const lockTarget = path.join(flagDir, key); + fs.mkdirSync(flagDir, { recursive: true }); + // already executed, skip without locking + if (fs.existsSync(flagFile)) + return false; + // Ensure lock target file exists + fs.writeFileSync(lockTarget, "", { flag: "a" }); + const release = await lockfile.lock(lockTarget, { + retries: { retries: 30, minTimeout: 200 }, + stale: 300_000, + }); + try { + // Double-check after acquiring lock + if (fs.existsSync(flagFile)) + return false; + await fn(); + fs.writeFileSync(flagFile, ""); + return true; + } + finally { + await release(); + } +} diff --git a/dist/playwright/teardown-namespaces.d.ts b/dist/playwright/teardown-namespaces.d.ts new file mode 100644 index 0000000..9a01426 --- /dev/null +++ b/dist/playwright/teardown-namespaces.d.ts @@ -0,0 +1,11 @@ +/** + * Registers a namespace for teardown after all tests in a project complete. + * Used by consumers who deploy to custom namespaces (not matching the project name). + */ +export declare function registerTeardownNamespace(projectName: string, namespace: string): void; +/** + * Returns all custom namespaces registered for teardown for a project. + * Used by the teardown reporter. + */ +export declare function getTeardownNamespaces(projectName: string): string[]; +//# sourceMappingURL=teardown-namespaces.d.ts.map \ No newline at end of file diff --git a/dist/playwright/teardown-namespaces.d.ts.map b/dist/playwright/teardown-namespaces.d.ts.map new file mode 100644 index 0000000..3bc7d0f --- /dev/null +++ b/dist/playwright/teardown-namespaces.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"teardown-namespaces.d.ts","sourceRoot":"","sources":["../../src/playwright/teardown-namespaces.ts"],"names":[],"mappings":"AAsBA;;;GAGG;AACH,wBAAgB,yBAAyB,CACvC,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,GAChB,IAAI,CASN;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,EAAE,CAEnE"} \ No newline at end of file diff --git a/dist/playwright/teardown-namespaces.js b/dist/playwright/teardown-namespaces.js new file mode 100644 index 0000000..c0c90fa --- /dev/null +++ b/dist/playwright/teardown-namespaces.js @@ -0,0 +1,34 @@ +import fs from "fs"; +import path from "path"; +import os from "os"; +// Workers use process.ppid (Playwright runner PID) +// Reporter (main process) uses process.pid +// Both resolve to the same directory. +const TEARDOWN_DIR = path.join(os.tmpdir(), `playwright-teardown-${process.ppid || process.pid}`); +const TEARDOWN_FILE = path.join(TEARDOWN_DIR, "namespaces.json"); +function read() { + if (!fs.existsSync(TEARDOWN_FILE)) + return {}; + return JSON.parse(fs.readFileSync(TEARDOWN_FILE, "utf-8")); +} +/** + * Registers a namespace for teardown after all tests in a project complete. + * Used by consumers who deploy to custom namespaces (not matching the project name). + */ +export function registerTeardownNamespace(projectName, namespace) { + fs.mkdirSync(TEARDOWN_DIR, { recursive: true }); + const registry = read(); + const namespaces = registry[projectName] ?? []; + if (!namespaces.includes(namespace)) { + namespaces.push(namespace); + registry[projectName] = namespaces; + fs.writeFileSync(TEARDOWN_FILE, JSON.stringify(registry)); + } +} +/** + * Returns all custom namespaces registered for teardown for a project. + * Used by the teardown reporter. + */ +export function getTeardownNamespaces(projectName) { + return read()[projectName] ?? []; +} diff --git a/dist/playwright/teardown-reporter.d.ts b/dist/playwright/teardown-reporter.d.ts new file mode 100644 index 0000000..59ab13d --- /dev/null +++ b/dist/playwright/teardown-reporter.d.ts @@ -0,0 +1,29 @@ +import type { Reporter, Suite, TestCase, TestResult } from "@playwright/test/reporter"; +/** + * Playwright reporter that deletes namespaces per-project as soon as all tests + * in that project finish. This frees cluster resources early instead of waiting + * for the entire suite to complete. + * + * Handles retries: a test is only counted as done when it passes/is skipped, + * or exhausts all retry attempts. + * + * Falls back in onEnd() to clean up any projects that didn't complete naturally + * (e.g., interrupted runs, maxFailures). + * + * Diagnostic log collection runs always (CI and local). + * Namespace deletion only runs when process.env.CI === "true". + * + * By default, deletes the namespace matching the project name. + * For custom namespaces, consumers can register them via registerTeardownNamespace(). + */ +export default class TeardownReporter implements Reporter { + private _projectTestCounts; + private _projectCompleted; + private _projectsWithFailures; + private _pendingDeletions; + onBegin(_config: unknown, suite: Suite): void; + onTestEnd(test: TestCase, result: TestResult): void; + onEnd(): Promise; + private _deleteProjectNamespaces; +} +//# sourceMappingURL=teardown-reporter.d.ts.map \ No newline at end of file diff --git a/dist/playwright/teardown-reporter.d.ts.map b/dist/playwright/teardown-reporter.d.ts.map new file mode 100644 index 0000000..d341630 --- /dev/null +++ b/dist/playwright/teardown-reporter.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"teardown-reporter.d.ts","sourceRoot":"","sources":["../../src/playwright/teardown-reporter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,KAAK,EACL,QAAQ,EACR,UAAU,EACX,MAAM,2BAA2B,CAAC;AAKnC;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,OAAO,OAAO,gBAAiB,YAAW,QAAQ;IACvD,OAAO,CAAC,kBAAkB,CAA6B;IACvD,OAAO,CAAC,iBAAiB,CAA6B;IACtD,OAAO,CAAC,qBAAqB,CAAqB;IAClD,OAAO,CAAC,iBAAiB,CAAoC;IAE7D,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,GAAG,IAAI;IAa7C,SAAS,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,GAAG,IAAI;IA6B7C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAcd,wBAAwB;CAqDvC"} \ No newline at end of file diff --git a/dist/playwright/teardown-reporter.js b/dist/playwright/teardown-reporter.js new file mode 100644 index 0000000..0aada2f --- /dev/null +++ b/dist/playwright/teardown-reporter.js @@ -0,0 +1,105 @@ +import path from "path"; +import { KubernetesClientHelper } from "../utils/kubernetes-client.js"; +import { getTeardownNamespaces } from "./teardown-namespaces.js"; +/** + * Playwright reporter that deletes namespaces per-project as soon as all tests + * in that project finish. This frees cluster resources early instead of waiting + * for the entire suite to complete. + * + * Handles retries: a test is only counted as done when it passes/is skipped, + * or exhausts all retry attempts. + * + * Falls back in onEnd() to clean up any projects that didn't complete naturally + * (e.g., interrupted runs, maxFailures). + * + * Diagnostic log collection runs always (CI and local). + * Namespace deletion only runs when process.env.CI === "true". + * + * By default, deletes the namespace matching the project name. + * For custom namespaces, consumers can register them via registerTeardownNamespace(). + */ +export default class TeardownReporter { + _projectTestCounts = new Map(); + _projectCompleted = new Map(); + _projectsWithFailures = new Set(); + _pendingDeletions = new Map(); + onBegin(_config, suite) { + for (const test of suite.allTests()) { + const name = test.parent.project()?.name; + if (name) { + this._projectTestCounts.set(name, (this._projectTestCounts.get(name) ?? 0) + 1); + this._projectCompleted.set(name, 0); + } + } + } + onTestEnd(test, result) { + const project = test.parent.project(); + if (!project) + return; + const isDone = result.status === "passed" || + result.status === "skipped" || + result.retry >= project.retries; + if (!isDone) + return; + const name = project.name; + if (result.status !== "passed" && result.status !== "skipped") { + this._projectsWithFailures.add(name); + } + const completed = (this._projectCompleted.get(name) ?? 0) + 1; + this._projectCompleted.set(name, completed); + // Start cleanup immediately (fire-and-forget here, awaited in onEnd) + if (completed === this._projectTestCounts.get(name) && + !this._pendingDeletions.has(name)) { + this._pendingDeletions.set(name, this._deleteProjectNamespaces(name)); + } + } + async onEnd() { + // Await all in-flight cleanups started from onTestEnd + await Promise.all(this._pendingDeletions.values()); + // Fallback: clean up projects that didn't complete naturally + // (e.g., interrupted run, maxFailures hit) β€” always collect diagnostics + for (const [project] of this._projectTestCounts) { + if (!this._pendingDeletions.has(project)) { + this._projectsWithFailures.add(project); + await this._deleteProjectNamespaces(project); + } + } + } + async _deleteProjectNamespaces(projectName) { + let k8sClient; + try { + k8sClient = new KubernetesClientHelper(); + } + catch (error) { + console.error(`[TeardownReporter] Cannot connect to cluster, skipping cleanup:`, error); + return; + } + const customNamespaces = getTeardownNamespaces(projectName); + const namespaces = customNamespaces.length > 0 ? customNamespaces : [projectName]; + // Collect diagnostic logs on failure (always, regardless of CI) + if (this._projectsWithFailures.has(projectName)) { + for (const ns of namespaces) { + const outputDir = path.join("node_modules", ".cache", "e2e-test-results", "logs", projectName); + await k8sClient.collectDiagnosticLogs(ns, outputDir); + } + } + // Retry + catch to avoid crashing Playwright if the cluster becomes unreachable. + const maxAttempts = 2; + if (process.env.CI === "true") { + for (const ns of namespaces) { + console.log(`[TeardownReporter] Deleting namespace "${ns}" (project: ${projectName})`); + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + await k8sClient.deleteNamespace(ns); + break; + } + catch (error) { + console.error(`[TeardownReporter] Failed to delete namespace "${ns}" (attempt ${attempt}/${maxAttempts}):`, error); + if (attempt < maxAttempts) + await new Promise((r) => setTimeout(r, 5000)); + } + } + } + } + } +} diff --git a/dist/utils/bash.d.ts b/dist/utils/bash.d.ts new file mode 100644 index 0000000..a9f1964 --- /dev/null +++ b/dist/utils/bash.d.ts @@ -0,0 +1,9 @@ +import { $ } from "zx"; +/** + * Runs a shell command with stdout/stderr captured. On success, output is not printed. + * On non-zero exit, stdout and stderr are written to console.error and an error is thrown. + * Use for noisy commands that should stay quiet on success but show output when they fail. + */ +export declare function runQuietUnlessFailure(strings: TemplateStringsArray, ...values: unknown[]): Promise; +export { $ }; +//# sourceMappingURL=bash.d.ts.map \ No newline at end of file diff --git a/dist/utils/bash.d.ts.map b/dist/utils/bash.d.ts.map new file mode 100644 index 0000000..568ff19 --- /dev/null +++ b/dist/utils/bash.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"bash.d.ts","sourceRoot":"","sources":["../../src/utils/bash.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,IAAI,CAAC;AAYvB;;;;GAIG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,oBAAoB,EAC7B,GAAG,MAAM,EAAE,OAAO,EAAE,GACnB,OAAO,CAAC,IAAI,CAAC,CAuBf;AAED,OAAO,EAAE,CAAC,EAAE,CAAC"} \ No newline at end of file diff --git a/dist/utils/bash.js b/dist/utils/bash.js new file mode 100644 index 0000000..a27e762 --- /dev/null +++ b/dist/utils/bash.js @@ -0,0 +1,25 @@ +import { $ } from "zx"; +$.quiet = true; +$.stdio = ["inherit", "inherit", "inherit"]; +/** + * Runs a shell command with stdout/stderr captured. On success, output is not printed. + * On non-zero exit, stdout and stderr are written to console.error and an error is thrown. + * Use for noisy commands that should stay quiet on success but show output when they fail. + */ +export async function runQuietUnlessFailure(strings, ...values) { + const runWithPipe = $({ + stdio: ["pipe", "pipe", "pipe"], + nothrow: true, + }); + const result = (await runWithPipe(strings, ...values)); + if (result.exitCode !== 0) { + if (result.stdout?.trim()) { + console.error("[command stdout]:", result.stdout.trim()); + } + if (result.stderr?.trim()) { + console.error("[command stderr]:", result.stderr.trim()); + } + throw new Error(`Command failed with exit code ${result.exitCode}. Output above.`); + } +} +export { $ }; diff --git a/dist/utils/common.d.ts b/dist/utils/common.d.ts new file mode 100644 index 0000000..02bf12a --- /dev/null +++ b/dist/utils/common.d.ts @@ -0,0 +1,4 @@ +declare function envsubst(str: string, env?: NodeJS.ProcessEnv): string; +declare function requireEnv(...varNames: [string, ...string[]]): void; +export { envsubst, requireEnv }; +//# sourceMappingURL=common.d.ts.map \ No newline at end of file diff --git a/dist/utils/common.d.ts.map b/dist/utils/common.d.ts.map new file mode 100644 index 0000000..ad460e2 --- /dev/null +++ b/dist/utils/common.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../src/utils/common.ts"],"names":[],"mappings":"AAAA,iBAAS,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,oBAAc,UAS/C;AAED,iBAAS,UAAU,CAAC,GAAG,QAAQ,EAAE,CAAC,MAAM,EAAE,GAAG,MAAM,EAAE,CAAC,GAAG,IAAI,CAO5D;AAED,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC"} \ No newline at end of file diff --git a/dist/utils/common.js b/dist/utils/common.js new file mode 100644 index 0000000..d1cb394 --- /dev/null +++ b/dist/utils/common.js @@ -0,0 +1,16 @@ +function envsubst(str, env = process.env) { + const out = str.replace(/\$([A-Za-z_]\w*)|\$\{([A-Za-z_]\w*)(?::-(.*?))?\}/g, (_, v1, v2, def) => { + const k = v1 || v2; + return env[k] ?? (def !== undefined ? def : ""); + }); + return out; +} +function requireEnv(...varNames) { + for (const varName of varNames) { + const value = process.env[varName]; + if (!value) { + throw new Error(`Required variable ${varName} is not set`); + } + } +} +export { envsubst, requireEnv }; diff --git a/dist/utils/index.d.ts b/dist/utils/index.d.ts new file mode 100644 index 0000000..bd4a5e1 --- /dev/null +++ b/dist/utils/index.d.ts @@ -0,0 +1,6 @@ +export { envsubst, requireEnv } from "./common.js"; +export { $, runQuietUnlessFailure } from "./bash.js"; +export { mergeYamlFiles, mergeYamlFilesIfExists, mergeYamlFilesToFile, } from "./merge-yamls.js"; +export { KubernetesClientHelper } from "./kubernetes-client.js"; +export { WorkspacePaths } from "./workspace-paths.js"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/utils/index.d.ts.map b/dist/utils/index.d.ts.map new file mode 100644 index 0000000..d884ef7 --- /dev/null +++ b/dist/utils/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,EAAE,CAAC,EAAE,qBAAqB,EAAE,MAAM,WAAW,CAAC;AACrD,OAAO,EACL,cAAc,EACd,sBAAsB,EACtB,oBAAoB,GACrB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC"} \ No newline at end of file diff --git a/dist/utils/index.js b/dist/utils/index.js new file mode 100644 index 0000000..7b1aef2 --- /dev/null +++ b/dist/utils/index.js @@ -0,0 +1,5 @@ +export { envsubst, requireEnv } from "./common.js"; +export { $, runQuietUnlessFailure } from "./bash.js"; +export { mergeYamlFiles, mergeYamlFilesIfExists, mergeYamlFilesToFile, } from "./merge-yamls.js"; +export { KubernetesClientHelper } from "./kubernetes-client.js"; +export { WorkspacePaths } from "./workspace-paths.js"; diff --git a/dist/utils/kubernetes-client.d.ts b/dist/utils/kubernetes-client.d.ts new file mode 100644 index 0000000..dfe5f12 --- /dev/null +++ b/dist/utils/kubernetes-client.d.ts @@ -0,0 +1,106 @@ +import * as k8s from "@kubernetes/client-node"; +/** + * Kubernetes client wrapper with proper abstraction + */ +declare class KubernetesClientHelper { + private _kc; + private _k8sApi; + private _appsApi; + private _customObjectsApi; + constructor(); + /** + * Create or update a ConfigMap from a file + */ + createOrUpdateConfigMap(name: string, namespace: string, configFilePath: string, dataKey?: string): Promise; + /** + * Create a namespace if it doesn't exist + */ + createNamespaceIfNotExists(namespace: string): Promise; + /** + * Apply a Kubernetes manifest from a YAML file + // */ + /** + * Apply a Kubernetes resource dynamically + */ + /** + * Create or update a Secret + */ + private _applySecret; + /** + * Create or update a ConfigMap from a plain object + */ + applyConfigMapFromObject(name: string, data: Record, namespace: string): Promise; + /** + * Create or update a Secret from a plain object + */ + applySecretFromObject(name: string, data: { + stringData?: Record; + }, namespace: string): Promise; + /** + * Delete a namespace and wait for it to be fully terminated + */ + deleteNamespace(namespace: string, waitForDeletion?: boolean, timeoutSeconds?: number): Promise; + /** + * Wait for a namespace to be fully deleted + */ + private _waitForNamespaceDeletion; + /** + * Check if an error is a "not found" (404) error. + * Handles different error formats from various k8s client versions. + */ + private _isNotFoundError; + /** + * Check if a StatefulSet is ready (all replicas are available) + */ + isStatefulSetReady(namespace: string, name: string): Promise; + /** + * Wait for a StatefulSet to be ready (all replicas available) + */ + waitForStatefulSetReady(namespace: string, name: string, timeoutSeconds?: number, pollIntervalMs?: number): Promise; + /** + * Get the cluster's ingress domain from OpenShift config + * Equivalent to: oc get ingresses.config.openshift.io cluster -o jsonpath='{.spec.domain}' + */ + getClusterIngressDomain(): Promise; + /** + * Get the URL/location of an OpenShift Route by name + * + * @param namespace - The namespace to search in + * @param name - The route name + * @returns The route URL (e.g., https://myapp.apps.cluster.example.com) + */ + getRouteLocation(namespace: string, name: string): Promise; + /** + * Extract the URL from a route object + */ + private _extractRouteUrl; + /** + * Failure states that indicate a pod will not recover without intervention + */ + private static readonly failureReasons; + /** + * Wait for pods matching a label selector to be ready, with early failure detection. + * Fails fast when it detects unrecoverable states like CrashLoopBackOff. + * + * @param namespace - Namespace to watch + * @param labelSelector - Label selector (e.g., "app=myapp") + * @param timeoutSeconds - Maximum time to wait (default: 300) + * @param pollIntervalMs - How often to check pod status (default: 5000) + */ + waitForPodsWithFailureDetection(namespace: string, labelSelector: string, timeoutSeconds?: number, pollIntervalMs?: number): Promise; + /** + * Collects diagnostic logs for all resources in a namespace and saves them as files. + * Uses kubectl for cross-platform compatibility (works on OpenShift, EKS, GKE, etc.). + * OpenShift-specific resources (routes) are collected on a best-effort basis. + * + * @param namespace - Namespace to collect diagnostics from + * @param outputDir - Directory to write log files to (defaults to playwright-report/logs/) + */ + collectDiagnosticLogs(namespace: string, outputDir?: string): Promise; + /** + * Check if a pod is in a failure state. Returns failure info or null if healthy. + */ + private _checkPodFailure; +} +export { KubernetesClientHelper }; +//# sourceMappingURL=kubernetes-client.d.ts.map \ No newline at end of file diff --git a/dist/utils/kubernetes-client.d.ts.map b/dist/utils/kubernetes-client.d.ts.map new file mode 100644 index 0000000..8a05a5c --- /dev/null +++ b/dist/utils/kubernetes-client.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"kubernetes-client.d.ts","sourceRoot":"","sources":["../../src/utils/kubernetes-client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,GAAG,MAAM,yBAAyB,CAAC;AAO/C;;GAEG;AACH,cAAM,sBAAsB;IAC1B,OAAO,CAAC,GAAG,CAAiB;IAC5B,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,QAAQ,CAAgB;IAChC,OAAO,CAAC,iBAAiB,CAAuB;;IAqChD;;OAEG;IACG,uBAAuB,CAC3B,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,EACtB,OAAO,CAAC,EAAE,MAAM,GACf,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC;IA8C3B;;OAEG;IACG,0BAA0B,CAC9B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC;IA+B3B;;UAEM;IAmBN;;OAEG;IAsBH;;OAEG;YACW,YAAY;IA8B1B;;OAEG;IACG,wBAAwB,CAC5B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC;IA0ChB;;OAEG;IACG,qBAAqB,CACzB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,EAC7C,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC;IAqChB;;OAEG;IACG,eAAe,CACnB,SAAS,EAAE,MAAM,EACjB,eAAe,GAAE,OAAc,EAC/B,cAAc,GAAE,MAAY,GAC3B,OAAO,CAAC,IAAI,CAAC;IAyBhB;;OAEG;YACW,yBAAyB;IAsCvC;;;OAGG;IACH,OAAO,CAAC,gBAAgB;IA2BxB;;OAEG;IACG,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAc3E;;OAEG;IACG,uBAAuB,CAC3B,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,cAAc,GAAE,MAAY,EAC5B,cAAc,GAAE,MAAa,GAC5B,OAAO,CAAC,OAAO,CAAC;IAiBnB;;;OAGG;IACG,uBAAuB,IAAI,OAAO,CAAC,MAAM,CAAC;IAuBhD;;;;;;OAMG;IACG,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAmBxE;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAmBxB;;OAEG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,cAAc,CAQnC;IAEH;;;;;;;;OAQG;IACG,+BAA+B,CACnC,SAAS,EAAE,MAAM,EACjB,aAAa,EAAE,MAAM,EACrB,cAAc,GAAE,MAAY,EAC5B,cAAc,GAAE,MAAa,GAC5B,OAAO,CAAC,IAAI,CAAC;IAwFhB;;;;;;;OAOG;IACG,qBAAqB,CACzB,SAAS,EAAE,MAAM,EACjB,SAAS,GAAE,MAMV,GACA,OAAO,CAAC,IAAI,CAAC;IAiGhB;;OAEG;IACH,OAAO,CAAC,gBAAgB;CA8BzB;AAED,OAAO,EAAE,sBAAsB,EAAE,CAAC"} \ No newline at end of file diff --git a/dist/utils/kubernetes-client.js b/dist/utils/kubernetes-client.js new file mode 100644 index 0000000..ce41b16 --- /dev/null +++ b/dist/utils/kubernetes-client.js @@ -0,0 +1,623 @@ +import { $ } from "./bash.js"; +import * as k8s from "@kubernetes/client-node"; +import * as fs from "fs"; +import * as path from "path"; +import * as yaml from "js-yaml"; +$.verbose = true; +/** + * Kubernetes client wrapper with proper abstraction + */ +class KubernetesClientHelper { + _kc; + _k8sApi; + _appsApi; + _customObjectsApi; + constructor() { + this._kc = new k8s.KubeConfig(); + this._kc.loadFromDefault(); + try { + this._k8sApi = this._kc.makeApiClient(k8s.CoreV1Api); + this._appsApi = this._kc.makeApiClient(k8s.AppsV1Api); + this._customObjectsApi = this._kc.makeApiClient(k8s.CustomObjectsApi); + } + catch (error) { + if (error instanceof Error && + error.message.includes("No active cluster")) { + const currentContext = this._kc.getCurrentContext(); + const contexts = this._kc.getContexts().map((c) => c.name); + throw new Error(`No active Kubernetes cluster found.\n\n` + + `The kubeconfig was loaded but no cluster is configured or the current context is invalid.\n\n` + + `Current context: ${currentContext || "(none)"}\n` + + `Available contexts: ${contexts.length > 0 ? contexts.join(", ") : "(none)"}\n\n` + + `To fix this:\n` + + ` 1. Log in to your k8s cluster: oc login or kubectl login\n` + + ` 2. Or set a valid context: kubectl config use-context \n` + + ` 3. Verify your connection: oc whoami && oc cluster-info\n\n` + + `Kubeconfig locations checked:\n` + + ` - KUBECONFIG env: ${process.env.KUBECONFIG || "(not set)"}\n` + + ` - Default: ~/.kube/config`, { cause: error }); + } + throw error; + } + } + /** + * Create or update a ConfigMap from a file + */ + async createOrUpdateConfigMap(name, namespace, configFilePath, dataKey) { + try { + const fileContent = fs.readFileSync(configFilePath, "utf-8"); + const key = dataKey || path.basename(configFilePath); + const configMap = { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { + name, + namespace, + }, + data: { + [key]: fileContent, + }, + }; + // Check if ConfigMap exists first + try { + await this._k8sApi.readNamespacedConfigMap({ name, namespace }); + // Exists, so update it + const response = await this._k8sApi.replaceNamespacedConfigMap({ + name, + namespace, + body: configMap, + }); + console.log(`βœ“ Updated ConfigMap ${name} in namespace ${namespace}`); + return response; + } + catch { + // Doesn't exist, create it + const response = await this._k8sApi.createNamespacedConfigMap({ + namespace, + body: configMap, + }); + console.log(`βœ“ Created ConfigMap ${name} in namespace ${namespace}`); + return response; + } + } + catch (error) { + console.error(`βœ— Failed to create/update ConfigMap ${name}:`, error instanceof Error ? error.message : error); + throw error; + } + } + /** + * Create a namespace if it doesn't exist + */ + async createNamespaceIfNotExists(namespace) { + if (!namespace?.trim()) + throw new Error("Namespace is required"); + try { + const response = await this._k8sApi.readNamespace({ name: namespace }); + console.log(`βœ“ Namespace ${namespace} already exists`); + return response; + } + catch { + // If read fails (likely 404), try to create + try { + const namespaceObj = { + apiVersion: "v1", + kind: "Namespace", + metadata: { + name: namespace, + }, + }; + const response = await this._k8sApi.createNamespace({ + body: namespaceObj, + }); + console.log(`βœ“ Created namespace ${namespace}`); + return response; + } + catch (createError) { + console.error(`βœ— Failed to create namespace ${namespace}:`, createError instanceof Error ? createError.message : createError); + throw createError; + } + } + } + /** + * Apply a Kubernetes manifest from a YAML file + // */ + // async applyManifest(filePath: string, namespace: string): Promise { + // try { + // const fileContent = fs.readFileSync(filePath, "utf-8"); + // const docs = yaml.loadAll(fileContent) as any[]; + // for (const doc of docs) { + // if (!doc || !doc.kind) continue; + // doc.metadata = doc.metadata || {}; + // doc.metadata.namespace = namespace; + // await this.applyResource(doc, namespace); + // } + // } catch (error: any) { + // console.error(`βœ— Failed to apply manifest ${filePath}:`, error.message); + // throw error; + // } + // } + /** + * Apply a Kubernetes resource dynamically + */ + // private async applyResource(resource: any, namespace: string): Promise { + // const kind = resource.kind; + // const name = resource.metadata.name; + // try { + // switch (kind) { + // case "Secret": + // await this.applySecret(resource, namespace); + // break; + // case "ConfigMap": + // await this.applyConfigMap(resource, namespace); + // break; + // default: + // console.warn(`⚠ Skipping unsupported resource type: ${kind}`); + // } + // } catch (error: any) { + // console.error(`βœ— Failed to apply ${kind} ${name}:`, error.message); + // throw error; + // } + // } + /** + * Create or update a Secret + */ + async _applySecret(secret, namespace) { + const name = secret.metadata.name; + try { + await this._k8sApi.replaceNamespacedSecret({ + name, + namespace, + body: secret, + }); + console.log(`βœ“ Updated Secret ${name} in namespace ${namespace}`); + } + catch { + // If replace fails (likely 404), try to create + try { + await this._k8sApi.createNamespacedSecret({ + namespace, + body: secret, + }); + console.log(`βœ“ Created Secret ${name} in namespace ${namespace}`); + } + catch (createError) { + console.error(`βœ— Failed to create/update Secret ${name} in namespace ${namespace}:`, createError instanceof Error ? createError.message : createError); + throw createError; + } + } + } + /** + * Create or update a ConfigMap from a plain object + */ + async applyConfigMapFromObject(name, data, namespace) { + // Convert the data object to a YAML string + const yamlContent = yaml.dump(data); + // Create proper ConfigMap structure + const fullConfigMap = { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { + name, + namespace, + }, + data: { + [name + ".yaml"]: yamlContent, + }, + }; + try { + await this._k8sApi.replaceNamespacedConfigMap({ + name, + namespace, + body: fullConfigMap, + }); + console.log(`βœ“ Updated ConfigMap ${name} in namespace ${namespace}`); + } + catch { + // Check for 404 status in different possible error structures + try { + await this._k8sApi.createNamespacedConfigMap({ + namespace, + body: fullConfigMap, + }); + console.log(`βœ“ Created ConfigMap ${name} in namespace ${namespace}`); + } + catch (createError) { + console.error(`βœ— Failed to create/update ConfigMap ${name} in namespace ${namespace}:`, createError instanceof Error ? createError.message : createError); + throw createError; + } + } + } + /** + * Create or update a Secret from a plain object + */ + async applySecretFromObject(name, data, namespace) { + // Create proper Secret structure + const fullSecret = { + apiVersion: "v1", + kind: "Secret", + metadata: { + name, + namespace, + }, + stringData: data.stringData, + }; + try { + await this._k8sApi.replaceNamespacedSecret({ + name, + namespace, + body: fullSecret, + }); + console.log(`βœ“ Updated Secret ${name} in namespace ${namespace}`); + } + catch { + // If replace fails (likely 404), try to create + try { + await this._k8sApi.createNamespacedSecret({ + namespace, + body: fullSecret, + }); + console.log(`βœ“ Created Secret ${name} in namespace ${namespace}`); + } + catch (createError) { + console.error(`βœ— Failed to create/update Secret ${name} in namespace ${namespace}:`, createError instanceof Error ? createError.message : createError); + throw createError; + } + } + } + /** + * Delete a namespace and wait for it to be fully terminated + */ + async deleteNamespace(namespace, waitForDeletion = true, timeoutSeconds = 180) { + try { + await this._k8sApi.deleteNamespace({ name: namespace }); + console.log(`[K8sHelper] Deleting namespace ${namespace}...`); + } + catch (error) { + // Ignore if namespace doesn't exist (already deleted), but throw other errors + if (this._isNotFoundError(error)) { + console.log(`βœ“ Namespace ${namespace} already deleted or doesn't exist`); + return; + } + else { + console.error(`βœ— Failed to delete namespace ${namespace}:`, error instanceof Error ? error.message : error); + throw error; + } + } + if (waitForDeletion) { + await this._waitForNamespaceDeletion(namespace, timeoutSeconds); + } + } + /** + * Wait for a namespace to be fully deleted + */ + async _waitForNamespaceDeletion(namespace, timeoutSeconds = 180) { + const startTime = Date.now(); + const timeoutMs = timeoutSeconds * 1000; + const pollIntervalMs = 3000; + while (Date.now() - startTime < timeoutMs) { + try { + const ns = await this._k8sApi.readNamespace({ name: namespace }); + const phase = ns.status?.phase; + // Namespace still exists, wait and retry + if (phase === "Terminating") { + // Only log occasionally to avoid spam + const elapsed = Math.round((Date.now() - startTime) / 1000); + if (elapsed % 10 === 0) { + console.log(`[K8sHelper] Namespace ${namespace} still terminating (${elapsed}s)...`); + } + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + catch (error) { + // Check for 404 in various error formats from different k8s client versions + if (this._isNotFoundError(error)) { + console.log(`βœ“ Namespace ${namespace} fully deleted`); + return; + } + throw error; + } + } + throw new Error(`Timeout waiting for namespace ${namespace} to be deleted after ${timeoutSeconds}s`); + } + /** + * Check if an error is a "not found" (404) error. + * Handles different error formats from various k8s client versions. + */ + _isNotFoundError(error) { + if (!error) + return false; + // Check error message for "404" or "not found" + if (error instanceof Error) { + const msg = error.message.toLowerCase(); + if (msg.includes("404") || msg.includes("not found")) { + return true; + } + } + // Check various object properties for 404 status codes + const err = error; + return (err.body?.code === 404 || + err.response?.statusCode === 404 || + err.statusCode === 404 || + err.code === 404); + } + /** + * Check if a StatefulSet is ready (all replicas are available) + */ + async isStatefulSetReady(namespace, name) { + try { + const statefulSet = await this._appsApi.readNamespacedStatefulSet({ + name, + namespace, + }); + const replicas = statefulSet.spec?.replicas ?? 1; + const readyReplicas = statefulSet.status?.readyReplicas ?? 0; + return readyReplicas >= replicas; + } + catch { + return false; + } + } + /** + * Wait for a StatefulSet to be ready (all replicas available) + */ + async waitForStatefulSetReady(namespace, name, timeoutSeconds = 300, pollIntervalMs = 5000) { + const startTime = Date.now(); + const timeoutMs = timeoutSeconds * 1000; + while (Date.now() - startTime < timeoutMs) { + if (await this.isStatefulSetReady(namespace, name)) { + console.log(`βœ“ StatefulSet ${name} is ready`); + return true; + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + throw new Error(`StatefulSet ${name} in namespace ${namespace} not ready after ${timeoutSeconds}s`); + } + /** + * Get the cluster's ingress domain from OpenShift config + * Equivalent to: oc get ingresses.config.openshift.io cluster -o jsonpath='{.spec.domain}' + */ + async getClusterIngressDomain() { + try { + const ingress = await this._customObjectsApi.getClusterCustomObject({ + group: "config.openshift.io", + version: "v1", + plural: "ingresses", + name: "cluster", + }); + const domain = ingress.spec?.domain; + if (!domain) { + throw new Error("Ingress domain not found in cluster config"); + } + return domain; + } + catch (error) { + throw new Error(`Failed to get cluster ingress domain: ${error instanceof Error ? error.message : error}`, { cause: error }); + } + } + /** + * Get the URL/location of an OpenShift Route by name + * + * @param namespace - The namespace to search in + * @param name - The route name + * @returns The route URL (e.g., https://myapp.apps.cluster.example.com) + */ + async getRouteLocation(namespace, name) { + try { + const route = await this._customObjectsApi.getNamespacedCustomObject({ + group: "route.openshift.io", + version: "v1", + namespace, + plural: "routes", + name, + }); + return this._extractRouteUrl(route, name); + } + catch (error) { + throw new Error(`Failed to get route ${name} in namespace ${namespace}: ${error instanceof Error ? error.message : error}`, { cause: error }); + } + } + /** + * Extract the URL from a route object + */ + _extractRouteUrl(route, routeName) { + const routeObj = route; + // Try to get host from spec first, then from status + const host = routeObj.spec?.host || routeObj.status?.ingress?.[0]?.host; + if (!host) { + throw new Error(`Route ${routeName} does not have a host configured`); + } + // Determine protocol based on TLS configuration + const protocol = routeObj.spec?.tls ? "https" : "http"; + return `${protocol}://${host}`; + } + /** + * Failure states that indicate a pod will not recover without intervention + */ + static failureReasons = new Set([ + "CrashLoopBackOff", + "Error", + "ImagePullBackOff", + "ErrImagePull", + "InvalidImageName", + "CreateContainerConfigError", + "CreateContainerError", + ]); + /** + * Wait for pods matching a label selector to be ready, with early failure detection. + * Fails fast when it detects unrecoverable states like CrashLoopBackOff. + * + * @param namespace - Namespace to watch + * @param labelSelector - Label selector (e.g., "app=myapp") + * @param timeoutSeconds - Maximum time to wait (default: 300) + * @param pollIntervalMs - How often to check pod status (default: 5000) + */ + async waitForPodsWithFailureDetection(namespace, labelSelector, timeoutSeconds = 500, pollIntervalMs = 5000) { + const startTime = Date.now(); + const timeoutMs = timeoutSeconds * 1000; + console.log(`[K8sHelper] Waiting for pods (${labelSelector}) in ${namespace}...`); + while (Date.now() - startTime < timeoutMs) { + let pods; + try { + pods = (await this._k8sApi.listNamespacedPod({ namespace, labelSelector })).items; + } + catch (err) { + console.log(`[K8sHelper] API error, retrying: ${err}`); + await new Promise((r) => setTimeout(r, pollIntervalMs)); + continue; + } + if (pods.length === 0) { + await new Promise((r) => setTimeout(r, pollIntervalMs)); + continue; + } + for (const pod of pods) { + const podName = pod.metadata?.name || "unknown"; + const failure = this._checkPodFailure(pod); + if (failure) { + console.log(`[K8sHelper] Pod ${podName} failed: ${failure.reason}`); + try { + if (failure.container) { + await $ `oc logs ${podName} -n ${namespace} -c ${failure.container} --tail=100`; + } + else { + await $ `oc logs ${podName} -n ${namespace} --tail=100`; + } + } + catch { + // Ignore log fetch errors + } + throw new Error(`Pod ${podName} failed: ${failure.reason}`); + } + } + // Check if all pods are ready + const allReady = pods.every((pod) => { + const ready = pod.status?.conditions?.find((c) => c.type === "Ready"); + return ready?.status === "True"; + }); + if (allReady) { + console.log(`[K8sHelper] All ${pods.length} pod(s) ready in ${namespace}`); + return; + } + // Log pod status every 20 seconds + const elapsedSec = Math.floor((Date.now() - startTime) / 1000); + if (elapsedSec > 0 && elapsedSec % 20 === 0) { + try { + await $ `oc get pods -n ${namespace} -l ${labelSelector}`; + } + catch { + // Ignore errors + } + } + await new Promise((r) => setTimeout(r, pollIntervalMs)); + } + // Timeout reached - print diagnostics to stdio before throwing + console.log(`\n[K8sHelper] ═══ Pod Diagnostics (timeout reached) ═══`); + try { + console.log(`\n[K8sHelper] ─── Pod Status ───`); + await $ `oc get pods -n ${namespace} -l ${labelSelector} -o wide`; + console.log(`\n[K8sHelper] ─── Pod Logs ───`); + await $ `oc logs -n ${namespace} -l ${labelSelector} --all-containers --tail=100 2>&1 || true`; + } + catch { + // Ignore errors from diagnostic commands + } + console.log(`\n[K8sHelper] ═══ End Pod Diagnostics ═══\n`); + throw new Error(`Timeout waiting for pods (${labelSelector}) after ${timeoutSeconds}s`); + } + /** + * Collects diagnostic logs for all resources in a namespace and saves them as files. + * Uses kubectl for cross-platform compatibility (works on OpenShift, EKS, GKE, etc.). + * OpenShift-specific resources (routes) are collected on a best-effort basis. + * + * @param namespace - Namespace to collect diagnostics from + * @param outputDir - Directory to write log files to (defaults to playwright-report/logs/) + */ + async collectDiagnosticLogs(namespace, outputDir = path.join("node_modules", ".cache", "e2e-test-results", "logs", namespace)) { + fs.mkdirSync(outputDir, { recursive: true }); + console.log(`[K8sHelper] Collecting diagnostic logs for "${namespace}" β†’ ${outputDir}`); + const quiet = $({ + stdio: ["pipe", "pipe", "pipe"], + timeout: "20s", + }); + const save = async (filePath, cmd) => { + try { + const result = await cmd; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, result.stdout); + } + catch { + // ignore β€” resource type may not exist on this cluster + } + }; + await Promise.allSettled([ + save(path.join(outputDir, "events.txt"), quiet `kubectl get events -n ${namespace} --sort-by='.lastTimestamp'`), + save(path.join(outputDir, "pods.txt"), quiet `kubectl get pods -n ${namespace} -o wide`), + save(path.join(outputDir, "describe-pods.txt"), quiet `kubectl describe pods -n ${namespace}`), + save(path.join(outputDir, "deployments.txt"), quiet `kubectl get deployments -n ${namespace} -o wide`), + save(path.join(outputDir, "describe-deployments.txt"), quiet `kubectl describe deployments -n ${namespace}`), + save(path.join(outputDir, "statefulsets.txt"), quiet `kubectl get statefulsets -n ${namespace} -o wide`), + save(path.join(outputDir, "routes.txt"), quiet `kubectl get routes -n ${namespace} -o wide`), + ]); + try { + const pods = (await this._k8sApi.listNamespacedPod({ namespace })).items; + const saveLogs = async (filePath, cmd) => { + try { + const result = await cmd; + if (result.stdout.trim()) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, result.stdout); + } + } + catch { + // ignore β€” container may not have started or no previous logs + } + }; + await Promise.allSettled(pods + .filter((pod) => pod.metadata?.name) + .flatMap((pod) => { + const podName = pod.metadata.name; + const podDir = path.join(outputDir, "pods", podName); + const containers = [ + ...(pod.spec?.initContainers ?? []), + ...(pod.spec?.containers ?? []), + ]; + return containers + .filter((c) => c.name) + .flatMap((c) => [ + saveLogs(path.join(podDir, `${c.name}.log`), quiet `kubectl logs ${podName} -n ${namespace} -c ${c.name}`), + saveLogs(path.join(podDir, `${c.name}.previous.log`), quiet `kubectl logs ${podName} -n ${namespace} -c ${c.name} --previous`), + ]); + })); + } + catch { + // ignore + } + } + /** + * Check if a pod is in a failure state. Returns failure info or null if healthy. + */ + _checkPodFailure(pod) { + // Check init containers first + for (const cs of pod.status?.initContainerStatuses || []) { + const reason = cs.state?.waiting?.reason; + if (reason && KubernetesClientHelper.failureReasons.has(reason)) { + return { reason: `Init:${reason}`, container: cs.name }; + } + if (cs.state?.terminated?.exitCode && + cs.state.terminated.exitCode !== 0) { + return { + reason: `Init:Error (exit ${cs.state.terminated.exitCode})`, + container: cs.name, + }; + } + } + // Check main containers + for (const cs of pod.status?.containerStatuses || []) { + const reason = cs.state?.waiting?.reason; + if (reason && KubernetesClientHelper.failureReasons.has(reason)) { + return { reason, container: cs.name }; + } + } + return null; + } +} +export { KubernetesClientHelper }; diff --git a/dist/utils/merge-yamls.d.ts b/dist/utils/merge-yamls.d.ts new file mode 100644 index 0000000..c6d3443 --- /dev/null +++ b/dist/utils/merge-yamls.d.ts @@ -0,0 +1,53 @@ +import yaml from "js-yaml"; +/** + * Array merge strategy options for YAML merging. + */ +export type ArrayMergeStrategy = "replace" | "concat" | { + byKey: string; + /** Optional: normalize key for matching so different values (e.g. OCI vs local path) map to the same entry. Source wins when merging. */ + normalizeKey?: (item: unknown) => string; +}; +/** + * Options for YAML merging. + */ +export interface MergeOptions { + /** + * Strategy for merging arrays. + * - "replace": Replace arrays entirely (default) + * - "concat": Concatenate arrays + * - { byKey: "keyName" }: Merge arrays of objects by a specific key + * - { byKey: "keyName", normalizeKey }: Same, but match by normalized key (e.g. for plugin deduplication) + */ + arrayMergeStrategy?: ArrayMergeStrategy; +} +/** + * Deeply merges two YAML-compatible objects. + * Array handling is controlled by the arrayMergeStrategy option. + */ +export declare function deepMerge(target: Record, source: Record, options?: MergeOptions): Record; +/** + * Merge multiple YAML files into one object. + * + * @param paths List of YAML file paths (base first, overlays last) + * @param options Optional merge options (e.g., arrayMergeStrategy) + * @returns Merged YAML object + */ +export declare function mergeYamlFiles(paths: string[], options?: MergeOptions): Promise>; +/** + * Merge multiple YAML files if they exist. + * + * @param paths List of YAML file paths + * @param options Optional merge options (e.g., arrayMergeStrategy) + * @returns Merged YAML object + */ +export declare function mergeYamlFilesIfExists(paths: string[], options?: MergeOptions): Promise>; +/** + * Merge multiple YAML files and write the result to an output file. + * + * @param inputPaths List of input YAML files + * @param outputPath Output YAML file path + * @param dumpOptions Optional dump formatting + * @param mergeOptions Optional merge options (e.g., arrayMergeStrategy) + */ +export declare function mergeYamlFilesToFile(inputPaths: string[], outputPath: string, dumpOptions?: yaml.DumpOptions, mergeOptions?: MergeOptions): Promise; +//# sourceMappingURL=merge-yamls.d.ts.map \ No newline at end of file diff --git a/dist/utils/merge-yamls.d.ts.map b/dist/utils/merge-yamls.d.ts.map new file mode 100644 index 0000000..933401c --- /dev/null +++ b/dist/utils/merge-yamls.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"merge-yamls.d.ts","sourceRoot":"","sources":["../../src/utils/merge-yamls.ts"],"names":[],"mappings":"AACA,OAAO,IAAI,MAAM,SAAS,CAAC;AAG3B;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAC1B,SAAS,GACT,QAAQ,GACR;IACE,KAAK,EAAE,MAAM,CAAC;IACd,yIAAyI;IACzI,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,MAAM,CAAC;CAC1C,CAAC;AAEN;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;CACzC;AA2DD;;;GAGG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,OAAO,GAAE,YAAiB,GACzB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAkBzB;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAClC,KAAK,EAAE,MAAM,EAAE,EACf,OAAO,GAAE,YAAiB,GACzB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAUlC;AAED;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,MAAM,EAAE,EACf,OAAO,GAAE,YAAiB,GACzB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CASlC;AAED;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CACxC,UAAU,EAAE,MAAM,EAAE,EACpB,UAAU,EAAE,MAAM,EAClB,WAAW,GAAE,IAAI,CAAC,WAA+B,EACjD,YAAY,GAAE,YAAiB,GAC9B,OAAO,CAAC,IAAI,CAAC,CAKf"} \ No newline at end of file diff --git a/dist/utils/merge-yamls.js b/dist/utils/merge-yamls.js new file mode 100644 index 0000000..c9590b9 --- /dev/null +++ b/dist/utils/merge-yamls.js @@ -0,0 +1,107 @@ +import fs from "fs-extra"; +import yaml from "js-yaml"; +import mergeWith from "lodash.mergewith"; +/** + * Returns the merge key for an item: normalized if normalizeKey is provided, else raw key value. + * Returns null if the item is not an object or has no key (and no normalizer is provided). + */ +function getMergeKey(item, key, normalizeKey) { + if (typeof item !== "object" || item === null) { + return null; + } + if (normalizeKey) { + return normalizeKey(item); + } + if (key in item) { + return String(item[key]); + } + return null; +} +/** + * Merges two arrays of objects by a specific key (optionally normalized). + * Objects with matching keys are deeply merged, new objects are appended. Source wins. + */ +function mergeArraysByKey(target, source, keyStrategy, mergeOptions) { + const { byKey: key, normalizeKey } = keyStrategy; + const result = [...target]; + for (const srcItem of source) { + const srcKeyValue = getMergeKey(srcItem, key, normalizeKey); + if (srcKeyValue === null) { + result.push(srcItem); + continue; + } + const existingIndex = result.findIndex((item) => getMergeKey(item, key, normalizeKey) === srcKeyValue); + if (existingIndex !== -1) { + result[existingIndex] = deepMerge(result[existingIndex], srcItem, mergeOptions); + } + else { + result.push(srcItem); + } + } + return result; +} +/** + * Deeply merges two YAML-compatible objects. + * Array handling is controlled by the arrayMergeStrategy option. + */ +export function deepMerge(target, source, options = {}) { + const strategy = options.arrayMergeStrategy ?? "replace"; + return mergeWith({ ...target }, source, (objValue, srcValue) => { + if (Array.isArray(objValue) && Array.isArray(srcValue)) { + if (strategy === "replace") { + return srcValue; + } + else if (strategy === "concat") { + return [...objValue, ...srcValue]; + } + else if (typeof strategy === "object" && "byKey" in strategy) { + return mergeArraysByKey(objValue, srcValue, strategy, options); + } + } + }); +} +/** + * Merge multiple YAML files into one object. + * + * @param paths List of YAML file paths (base first, overlays last) + * @param options Optional merge options (e.g., arrayMergeStrategy) + * @returns Merged YAML object + */ +export async function mergeYamlFiles(paths, options = {}) { + let merged = {}; + for (const path of paths) { + const content = await fs.readFile(path, "utf8"); + const parsed = (yaml.load(content) || {}); + merged = deepMerge(merged, parsed, options); + } + return merged; +} +/** + * Merge multiple YAML files if they exist. + * + * @param paths List of YAML file paths + * @param options Optional merge options (e.g., arrayMergeStrategy) + * @returns Merged YAML object + */ +export async function mergeYamlFilesIfExists(paths, options = {}) { + return await mergeYamlFiles(paths.filter((path) => { + const exists = fs.existsSync(path); + if (!exists) + console.log(`YAML file ${path} does not exist`); + return exists; + }), options); +} +/** + * Merge multiple YAML files and write the result to an output file. + * + * @param inputPaths List of input YAML files + * @param outputPath Output YAML file path + * @param dumpOptions Optional dump formatting + * @param mergeOptions Optional merge options (e.g., arrayMergeStrategy) + */ +export async function mergeYamlFilesToFile(inputPaths, outputPath, dumpOptions = { lineWidth: -1 }, mergeOptions = {}) { + const merged = await mergeYamlFiles(inputPaths, mergeOptions); + const yamlString = yaml.dump(merged, dumpOptions); + await fs.outputFile(outputPath, yamlString); + console.log(`Merged ${inputPaths.length} YAML files into ${outputPath}`); +} diff --git a/dist/utils/merge-yamls.test.d.ts b/dist/utils/merge-yamls.test.d.ts new file mode 100644 index 0000000..2ac75ee --- /dev/null +++ b/dist/utils/merge-yamls.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=merge-yamls.test.d.ts.map \ No newline at end of file diff --git a/dist/utils/merge-yamls.test.d.ts.map b/dist/utils/merge-yamls.test.d.ts.map new file mode 100644 index 0000000..be1e18a --- /dev/null +++ b/dist/utils/merge-yamls.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"merge-yamls.test.d.ts","sourceRoot":"","sources":["../../src/utils/merge-yamls.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/utils/merge-yamls.test.js b/dist/utils/merge-yamls.test.js new file mode 100644 index 0000000..bbf81f9 --- /dev/null +++ b/dist/utils/merge-yamls.test.js @@ -0,0 +1,54 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { deepMerge } from "./merge-yamls.js"; +import { getNormalizedPluginMergeKey } from "./plugin-metadata.js"; +describe("deepMerge with arrayMergeStrategy byKey", () => { + it("keeps two plugin entries when package values differ and no normalizeKey", () => { + const target = { + plugins: [ + { + package: "oci://ghcr.io/org/repo/backstage-community-plugin-catalog-backend-module-keycloak:tag!alias", + disabled: false, + }, + ], + }; + const source = { + plugins: [ + { + package: "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic", + disabled: false, + }, + ], + }; + const result = deepMerge(target, source, { + arrayMergeStrategy: { byKey: "package" }, + }); + const plugins = result.plugins; + assert.strictEqual(plugins.length, 2, "without normalizeKey both entries are kept"); + }); + it("merges into one plugin when normalizeKey maps both to same key and source wins", () => { + const target = { + plugins: [ + { + package: "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic", + disabled: false, + }, + ], + }; + const source = { + plugins: [ + { + package: "oci://ghcr.io/org/repo/backstage-community-plugin-catalog-backend-module-keycloak:pr_1__1.0!keycloak", + disabled: false, + }, + ], + }; + const normalizeKey = (item) => getNormalizedPluginMergeKey(item); + const result = deepMerge(target, source, { + arrayMergeStrategy: { byKey: "package", normalizeKey }, + }); + const plugins = result.plugins; + assert.strictEqual(plugins.length, 1, "same normalized key yields one entry"); + assert.ok(plugins[0].package?.startsWith("oci://"), "source (OCI) wins over target (local path)"); + }); +}); diff --git a/dist/utils/plugin-metadata.d.ts b/dist/utils/plugin-metadata.d.ts new file mode 100644 index 0000000..6c6d40e --- /dev/null +++ b/dist/utils/plugin-metadata.d.ts @@ -0,0 +1,96 @@ +export interface PluginMetadata { + packagePath: string; + pluginConfig: Record; + packageName: string; + sourceFile: string; +} +export interface PluginEntry { + package: string; + disabled?: boolean; + pluginConfig?: Record; + [key: string]: unknown; +} +export interface DynamicPluginsConfig { + plugins?: PluginEntry[]; + includes?: string[]; + [key: string]: unknown; +} +/** + * Detects if we're running in a nightly/periodic job context. + * Controls the entire nightly vs PR routing in deployment: + * - Nightly: uses metadata OCI refs (latest published versions), skips metadata injection + * - PR/local: uses metadata + OCI URL replacement + * + * Returns true when: + * - JOB_NAME contains "periodic-" (OpenShift CI nightly/periodic jobs), OR + * - E2E_NIGHTLY_MODE is set (manual override for local testing) + */ +export declare function isNightlyJob(): boolean; +/** + * Extracts the plugin name from a package path or OCI reference. + * Strips the `-dynamic` suffix so local paths and OCI refs for the same + * logical plugin produce the same key. + * + * Handles various formats: + * - Local path: ./dynamic-plugins/dist/backstage-community-plugin-tech-radar-dynamic + * - OCI with alias: oci://quay.io/rhdh/plugin@sha256:...!backstage-community-plugin-tech-radar + * - OCI without alias: oci://quay.io/rhdh/backstage-community-plugin-tech-radar:tag + */ +export declare function extractPluginName(packageRef: string): string; +export declare const DEFAULT_METADATA_PATH = "../metadata"; +export declare function getMetadataDirectory(metadataPath?: string): string | null; +export declare function parseMetadataFile(filePath: string): Promise; +export declare function parseAllMetadataFiles(metadataDir: string): Promise>; +/** + * Resolves plugin package references to their target OCI URLs where applicable. + * + * Resolution priority for each plugin: + * 1. PR OCI URL β€” if GIT_PR_NUMBER set and a PR image was published for this plugin + * 2. Metadata OCI ref β€” uses dynamicArtifact from metadata (latest published version) + * 3. Unchanged β€” local paths, npm packages, or other formats kept as-is + */ +/** + * Returns a stable merge key for a plugin entry so OCI and local path for the same + * logical plugin match when merging dynamic-plugins configs. Strips a trailing + * "-dynamic" so e.g. backstage-community-plugin-catalog-backend-module-keycloak-dynamic + * and ...-keycloak (from OCI) map to the same key. + */ +export declare function getNormalizedPluginMergeKey(entry: { + package?: string; +}): string; +/** + * Generates dynamic-plugins configuration for wrapper plugins + * that need to be disabled. Each plugin entry contains: + * - package: ./dynamic-plugins/dist/$plugin-name + * - disabled: true + * + * @param plugins list of wrapper plugin names + * @returns Dynamic plugins configuration that disables listed wrapper plugins + */ +export declare function disablePluginWrappers(plugins: string[]): DynamicPluginsConfig; +/** + * Auto-generates plugin entries from workspace metadata files. + * Creates raw entries with local paths and disabled: false. + * Does NOT include pluginConfig β€” that's handled by processPluginsForDeployment. + * + * @param metadataPath Optional custom path to metadata directory + * @returns Plugin entries discovered from metadata + */ +export declare function generatePluginsFromMetadata(metadataPath?: string): Promise; +/** + * Processes a dynamic plugins configuration for deployment. + * Single entry point for both PR and nightly flows. + * + * Operations (in order): + * 1. Inject appConfigExamples from metadata (PR mode only, unless RHDH_SKIP_PLUGIN_METADATA_INJECTION is set) + * 2. Resolve all packages to OCI references: + * - PR with GIT_PR_NUMBER: workspace plugins in PR build β†’ pr_ tags, rest unchanged + * - PR without GIT_PR_NUMBER: OCI plugins with metadata β†’ metadata refs, rest unchanged + * - Nightly: OCI plugins with metadata β†’ metadata refs, rest unchanged + * + * @param config The merged dynamic plugins configuration + * @param metadataPath Optional custom path to metadata directory + * @returns Processed configuration ready for deployment + */ +export declare function processPluginsForDeployment(config: DynamicPluginsConfig, metadataPath?: string): Promise; +//# sourceMappingURL=plugin-metadata.d.ts.map \ No newline at end of file diff --git a/dist/utils/plugin-metadata.d.ts.map b/dist/utils/plugin-metadata.d.ts.map new file mode 100644 index 0000000..5b8bf7a --- /dev/null +++ b/dist/utils/plugin-metadata.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"plugin-metadata.d.ts","sourceRoot":"","sources":["../../src/utils/plugin-metadata.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACpB;AAaD,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAID;;;;;;;;;GASG;AACH,wBAAgB,YAAY,IAAI,OAAO,CAqBtC;AAID;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAI5D;AAYD,eAAO,MAAM,qBAAqB,gBAAgB,CAAC;AAEnD,wBAAgB,oBAAoB,CAClC,YAAY,GAAE,MAA8B,GAC3C,MAAM,GAAG,IAAI,CAQf;AAED,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,cAAc,CAAC,CAyBzB;AAED,wBAAsB,qBAAqB,CACzC,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAwBtC;AA2JD;;;;;;;GAOG;AACH;;;;;GAKG;AACH,wBAAgB,2BAA2B,CAAC,KAAK,EAAE;IACjD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,MAAM,CAMT;AA0GD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,oBAAoB,CAW7E;AAED;;;;;;;GAOG;AACH,wBAAsB,2BAA2B,CAC/C,YAAY,GAAE,MAA8B,GAC3C,OAAO,CAAC,oBAAoB,CAAC,CAwB/B;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,2BAA2B,CAC/C,MAAM,EAAE,oBAAoB,EAC5B,YAAY,GAAE,MAA8B,GAC3C,OAAO,CAAC,oBAAoB,CAAC,CA6B/B"} \ No newline at end of file diff --git a/dist/utils/plugin-metadata.js b/dist/utils/plugin-metadata.js new file mode 100644 index 0000000..d1ec76b --- /dev/null +++ b/dist/utils/plugin-metadata.js @@ -0,0 +1,364 @@ +import fs from "fs-extra"; +import path from "path"; +import yaml from "js-yaml"; +import { glob } from "zx"; +import { deepMerge } from "./merge-yamls.js"; +const OCI_REGISTRY_PREFIX = "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays"; +// ── Detection ───────────────────────────────────────────────────────────────── +/** + * Detects if we're running in a nightly/periodic job context. + * Controls the entire nightly vs PR routing in deployment: + * - Nightly: uses metadata OCI refs (latest published versions), skips metadata injection + * - PR/local: uses metadata + OCI URL replacement + * + * Returns true when: + * - JOB_NAME contains "periodic-" (OpenShift CI nightly/periodic jobs), OR + * - E2E_NIGHTLY_MODE is set (manual override for local testing) + */ +export function isNightlyJob() { + // PR check takes precedence over nightly mode + if (process.env.GIT_PR_NUMBER) { + return false; + } + if (process.env.E2E_NIGHTLY_MODE === "true" || + process.env.E2E_NIGHTLY_MODE === "1") { + console.log("[PluginMetadata] Nightly mode (E2E_NIGHTLY_MODE is set)"); + return true; + } + const jobName = process.env.JOB_NAME || ""; + if (jobName.includes("periodic-")) { + console.log("[PluginMetadata] Nightly mode (periodic job detected)"); + return true; + } + return false; +} +// ── Utilities ───────────────────────────────────────────────────────────────── +/** + * Extracts the plugin name from a package path or OCI reference. + * Strips the `-dynamic` suffix so local paths and OCI refs for the same + * logical plugin produce the same key. + * + * Handles various formats: + * - Local path: ./dynamic-plugins/dist/backstage-community-plugin-tech-radar-dynamic + * - OCI with alias: oci://quay.io/rhdh/plugin@sha256:...!backstage-community-plugin-tech-radar + * - OCI without alias: oci://quay.io/rhdh/backstage-community-plugin-tech-radar:tag + */ +export function extractPluginName(packageRef) { + const ref = packageRef.includes("!") ? packageRef.split("!")[0] : packageRef; + const match = ref.match(/\/([^/:@]+)(?:[:@].*)?$/); + return (match?.[1] || packageRef).replace(/-dynamic$/, ""); +} +/** + * Derives the displayName from a packageName. + * @backstage-community/plugin-tech-radar β†’ backstage-community-plugin-tech-radar + */ +function toDisplayName(packageName) { + return packageName.replace(/^@/, "").replace(/\//g, "-"); +} +// ── Metadata Loading ────────────────────────────────────────────────────────── +export const DEFAULT_METADATA_PATH = "../metadata"; +export function getMetadataDirectory(metadataPath = DEFAULT_METADATA_PATH) { + const resolvedPath = path.resolve(metadataPath); + if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) { + console.log(`[PluginMetadata] Using metadata directory: ${resolvedPath}`); + return resolvedPath; + } + console.log(`[PluginMetadata] Metadata directory not found: ${resolvedPath}`); + return null; +} +export async function parseMetadataFile(filePath) { + const content = await fs.readFile(filePath, "utf8"); + const parsed = yaml.load(content); + const packagePath = parsed?.spec?.dynamicArtifact; + const packageName = parsed?.spec?.packageName; + const pluginConfig = parsed?.spec?.appConfigExamples?.[0]?.content; + if (!packagePath) { + throw new Error(`[PluginMetadata] Missing required field spec.dynamicArtifact in ${filePath}`); + } + if (!packageName) { + throw new Error(`[PluginMetadata] Missing required field spec.packageName in ${filePath}`); + } + return { + packagePath, + pluginConfig: pluginConfig || {}, + packageName, + sourceFile: filePath, + }; +} +export async function parseAllMetadataFiles(metadataDir) { + const pattern = path.join(metadataDir, "*.yaml"); + const files = await glob(pattern); + console.log(`[PluginMetadata] Found ${files.length} metadata files in ${metadataDir}`); + const metadataMap = new Map(); + for (const file of files) { + const metadata = await parseMetadataFile(file); + const pluginName = extractPluginName(metadata.packagePath); + metadataMap.set(pluginName, metadata); + console.log(`[PluginMetadata] Mapped plugin: ${pluginName} <- ${metadata.packagePath}`); + } + console.log(`[PluginMetadata] Successfully parsed ${metadataMap.size} plugin metadata entries`); + return metadataMap; +} +/** + * Loads and validates metadata from the workspace metadata directory. + * @throws Error if metadata directory not found or no valid metadata files + */ +async function loadMetadata(metadataPath) { + const metadataDir = getMetadataDirectory(metadataPath); + if (!metadataDir) { + throw new Error(`[PluginMetadata] Metadata directory not found at: ${path.resolve(metadataPath)}`); + } + const metadataMap = await parseAllMetadataFiles(metadataDir); + if (metadataMap.size === 0) { + throw new Error(`[PluginMetadata] No valid metadata files found in ${metadataDir}`); + } + return [metadataDir, metadataMap]; +} +/** + * Tries to load metadata, returns empty map if not available. + * Used by processPluginsForDeployment where metadata is optional. + */ +async function tryLoadMetadata(metadataPath) { + const metadataDir = getMetadataDirectory(metadataPath); + if (!metadataDir) + return new Map(); + return await parseAllMetadataFiles(metadataDir); +} +// ── PR: Fetch OCI URLs ─────────────────────────────────────────────────────── +/** + * Fetches plugin versions from source repo and builds OCI URL map. + * Only called when GIT_PR_NUMBER is set. + */ +async function getOCIUrlsForPR(workspacePath, prNumber) { + const ociUrls = new Map(); + const sourceJsonPath = path.join(workspacePath, "source.json"); + const pluginsListPath = path.join(workspacePath, "plugins-list.yaml"); + if (!fs.existsSync(sourceJsonPath)) { + throw new Error(`[PluginMetadata] PR build requires source.json but not found at: ${sourceJsonPath}`); + } + if (!fs.existsSync(pluginsListPath)) { + throw new Error(`[PluginMetadata] PR build requires plugins-list.yaml but not found at: ${pluginsListPath}`); + } + const sourceJson = JSON.parse(await fs.readFile(sourceJsonPath, "utf-8")); + const { repo, "repo-ref": ref, "repo-flat": repoFlat } = sourceJson; + if (!repo) { + throw new Error(`[PluginMetadata] source.json is missing required 'repo' field: ${sourceJsonPath}`); + } + if (!ref) { + throw new Error(`[PluginMetadata] source.json is missing required 'repo-ref' field: ${sourceJsonPath}`); + } + const match = repo.match(/github\.com\/(.+?)(?:\.git)?$/); + if (!match) { + throw new Error(`[PluginMetadata] Failed to parse GitHub repo from source.json: ${repo}`); + } + const ownerRepo = match[1]; + const pluginsListContent = await fs.readFile(pluginsListPath, "utf-8"); + const pluginsListData = yaml.load(pluginsListContent); + if (!pluginsListData || typeof pluginsListData !== "object") { + throw new Error(`[PluginMetadata] plugins-list.yaml is empty or invalid: ${pluginsListPath}`); + } + const pluginPaths = Object.keys(pluginsListData); + const workspaceName = path.basename(workspacePath); + console.log(`[PluginMetadata] Fetching versions for ${pluginPaths.length} plugins from source...`); + for (const pluginPath of pluginPaths) { + const pkgJsonPath = repoFlat + ? `${pluginPath}/package.json` + : `workspaces/${workspaceName}/${pluginPath}/package.json`; + const rawUrl = `https://raw.githubusercontent.com/${ownerRepo}/${ref}/${pkgJsonPath}`; + const res = await fetch(rawUrl); + if (!res.ok) { + throw new Error(`[PluginMetadata] Failed to fetch package.json for ${pluginPath}: ${res.status} ${res.statusText}\n` + + ` URL: ${rawUrl}`); + } + const pkgJson = (await res.json()); + if (!pkgJson.name) { + throw new Error(`[PluginMetadata] package.json is missing 'name' field for ${pluginPath}\n` + + ` URL: ${rawUrl}`); + } + if (!pkgJson.version) { + throw new Error(`[PluginMetadata] package.json is missing 'version' field for ${pluginPath}\n` + + ` URL: ${rawUrl}`); + } + const { name, version } = pkgJson; + const displayName = toDisplayName(name); + // TODO(RHDHBUGS-2530): Remove !alias suffix once Konflux builds include + // io.backstage.dynamic-packages annotation. + const ociUrl = `${OCI_REGISTRY_PREFIX}/${displayName}:pr_${prNumber}__${version}!${displayName}`; + ociUrls.set(displayName, ociUrl); + console.log(`[PluginMetadata] ${displayName} -> ${ociUrl}`); + } + return ociUrls; +} +// ── Core: Unified Plugin Processing ────────────────────────────────────────── +/** + * Resolves plugin package references to their target OCI URLs where applicable. + * + * Resolution priority for each plugin: + * 1. PR OCI URL β€” if GIT_PR_NUMBER set and a PR image was published for this plugin + * 2. Metadata OCI ref β€” uses dynamicArtifact from metadata (latest published version) + * 3. Unchanged β€” local paths, npm packages, or other formats kept as-is + */ +/** + * Returns a stable merge key for a plugin entry so OCI and local path for the same + * logical plugin match when merging dynamic-plugins configs. Strips a trailing + * "-dynamic" so e.g. backstage-community-plugin-catalog-backend-module-keycloak-dynamic + * and ...-keycloak (from OCI) map to the same key. + */ +export function getNormalizedPluginMergeKey(entry) { + const pkg = entry?.package; + if (pkg === undefined || pkg === "") { + return ""; + } + return extractPluginName(pkg); +} +async function resolvePluginPackages(plugins, metadataMap, metadataPath) { + // Build PR OCI URLs if applicable + const prNumber = process.env.GIT_PR_NUMBER; + let prOciUrls = null; + if (prNumber) { + console.log(`[PluginMetadata] PR build detected (PR #${prNumber}), fetching OCI URLs...`); + const workspacePath = path.resolve(metadataPath, ".."); + prOciUrls = await getOCIUrlsForPR(workspacePath, prNumber); + } + return plugins.map((plugin) => { + const pkg = plugin.package; + const pluginName = extractPluginName(pkg); + const metadata = metadataMap.get(pluginName); + // 1. With metadata: resolve to PR OCI URL or metadata's dynamicArtifact + if (metadata?.packageName) { + const displayName = toDisplayName(metadata.packageName); + // PR: use PR-specific OCI URL if this plugin is part of the PR build + if (prOciUrls) { + const prUrl = prOciUrls.get(displayName); + if (prUrl) { + console.log(`[PluginMetadata] PR: ${pkg} β†’ ${prUrl}`); + return { ...plugin, package: prUrl }; + } + } + // Use metadata's dynamicArtifact directly (latest published version). + // This is more accurate than {{inherit}} because metadata is updated daily + // while the DPDY in the catalog index may lag behind. + if (metadata.packagePath.startsWith("oci://")) { + console.log(`[PluginMetadata] ${pkg} β†’ ${metadata.packagePath}`); + return { ...plugin, package: metadata.packagePath }; + } + // Wrapper (local path): metadata is the source of truth. + // The user config may have a stale OCI ref from a previous version. + if (pkg !== metadata.packagePath) { + console.log(`[PluginMetadata] ${pkg} β†’ ${metadata.packagePath}`); + } + return { ...plugin, package: metadata.packagePath }; + } + // 2. Local paths (./dynamic-plugins/dist/...) and other formats β€” keep as-is. + // Local paths reference plugins bundled in the RHDH container image and work + // without OCI resolution. When the catalog index moves all plugins to OCI refs, + // they'll be handled by step 1 or 2 above automatically. + return plugin; + }); +} +/** + * Injects plugin configurations from metadata into a dynamic plugins config. + * Metadata config serves as the base, user-provided pluginConfig overrides it. + */ +function injectMetadataConfig(dynamicPluginsConfig, metadataMap) { + if (!dynamicPluginsConfig.plugins) { + return dynamicPluginsConfig; + } + const augmentedPlugins = dynamicPluginsConfig.plugins.map((plugin) => { + const pluginName = extractPluginName(plugin.package); + const metadata = metadataMap.get(pluginName); + if (!metadata) { + console.log(`[PluginMetadata] No metadata found for: ${pluginName} (from ${plugin.package})`); + return plugin; + } + console.log(`[PluginMetadata] Injecting config for: ${pluginName} (from ${plugin.package})`); + const mergedPluginConfig = deepMerge(metadata.pluginConfig, plugin.pluginConfig || {}); + return { + ...plugin, + pluginConfig: mergedPluginConfig, + }; + }); + return { + ...dynamicPluginsConfig, + plugins: augmentedPlugins, + }; +} +// ── Public API ──────────────────────────────────────────────────────────────── +/** + * Generates dynamic-plugins configuration for wrapper plugins + * that need to be disabled. Each plugin entry contains: + * - package: ./dynamic-plugins/dist/$plugin-name + * - disabled: true + * + * @param plugins list of wrapper plugin names + * @returns Dynamic plugins configuration that disables listed wrapper plugins + */ +export function disablePluginWrappers(plugins) { + const pluginConfig = { + plugins: [], + }; + for (const plugin of plugins) { + pluginConfig.plugins.push({ + package: `./dynamic-plugins/dist/${plugin}`, + disabled: true, + }); + } + return pluginConfig; +} +/** + * Auto-generates plugin entries from workspace metadata files. + * Creates raw entries with local paths and disabled: false. + * Does NOT include pluginConfig β€” that's handled by processPluginsForDeployment. + * + * @param metadataPath Optional custom path to metadata directory + * @returns Plugin entries discovered from metadata + */ +export async function generatePluginsFromMetadata(metadataPath = DEFAULT_METADATA_PATH) { + console.log("[PluginMetadata] Auto-generating plugin entries from metadata..."); + const [, metadataMap] = await loadMetadata(metadataPath); + const plugins = []; + for (const [pluginName, metadata] of metadataMap) { + console.log(`[PluginMetadata] Adding plugin: ${pluginName} (${metadata.packagePath})`); + plugins.push({ + package: metadata.packagePath, + disabled: false, + }); + } + console.log(`[PluginMetadata] Generated ${plugins.length} plugin entries from metadata`); + return { plugins }; +} +/** + * Processes a dynamic plugins configuration for deployment. + * Single entry point for both PR and nightly flows. + * + * Operations (in order): + * 1. Inject appConfigExamples from metadata (PR mode only, unless RHDH_SKIP_PLUGIN_METADATA_INJECTION is set) + * 2. Resolve all packages to OCI references: + * - PR with GIT_PR_NUMBER: workspace plugins in PR build β†’ pr_ tags, rest unchanged + * - PR without GIT_PR_NUMBER: OCI plugins with metadata β†’ metadata refs, rest unchanged + * - Nightly: OCI plugins with metadata β†’ metadata refs, rest unchanged + * + * @param config The merged dynamic plugins configuration + * @param metadataPath Optional custom path to metadata directory + * @returns Processed configuration ready for deployment + */ +export async function processPluginsForDeployment(config, metadataPath = DEFAULT_METADATA_PATH) { + if (!config.plugins) + return config; + const metadataMap = await tryLoadMetadata(metadataPath); + let result = { ...config }; + // Inject appConfigExamples from metadata (PR mode only) + if (!isNightlyJob() && + process.env.RHDH_SKIP_PLUGIN_METADATA_INJECTION !== "true" && + metadataMap.size > 0) { + console.log("[PluginMetadata] Injecting metadata configs..."); + result = injectMetadataConfig(result, metadataMap); + } + // Resolve all packages to OCI references + console.log("[PluginMetadata] Resolving plugin packages to OCI..."); + result = { + ...result, + plugins: await resolvePluginPackages(result.plugins, metadataMap, metadataPath), + }; + return result; +} diff --git a/dist/utils/tests/helpers.d.ts b/dist/utils/tests/helpers.d.ts new file mode 100644 index 0000000..50bde86 --- /dev/null +++ b/dist/utils/tests/helpers.d.ts @@ -0,0 +1,26 @@ +/** Saves and restores process.env around each test. */ +export declare function withCleanEnv(): { + save(): void; + restore(): void; +}; +/** Creates a temporary metadata directory with Package CRD YAML files. */ +export declare function createMetadataFixture(plugins: Array<{ + name: string; + packageName: string; + dynamicArtifact: string; + appConfigExamples?: Record; +}>): Promise; +/** + * Creates a workspace-like directory structure with metadata, source.json, + * and plugins-list.yaml. Used for tests that trigger PR OCI URL fetching. + */ +export declare function createWorkspaceFixture(plugins: Array<{ + name: string; + packageName: string; + dynamicArtifact: string; + appConfigExamples?: Record; +}>): Promise<{ + wsDir: string; + metadataDir: string; +}>; +//# sourceMappingURL=helpers.d.ts.map \ No newline at end of file diff --git a/dist/utils/tests/helpers.d.ts.map b/dist/utils/tests/helpers.d.ts.map new file mode 100644 index 0000000..15e31fa --- /dev/null +++ b/dist/utils/tests/helpers.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/helpers.ts"],"names":[],"mappings":"AAQA,uDAAuD;AACvD,wBAAgB,YAAY;;;EAa3B;AAED,0EAA0E;AAC1E,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,KAAK,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC7C,CAAC,GACD,OAAO,CAAC,MAAM,CAAC,CAyBjB;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,KAAK,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC7C,CAAC,GACD,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC,CAyCjD"} \ No newline at end of file diff --git a/dist/utils/tests/helpers.js b/dist/utils/tests/helpers.js new file mode 100644 index 0000000..f50593d --- /dev/null +++ b/dist/utils/tests/helpers.js @@ -0,0 +1,84 @@ +/** + * Shared test helpers for plugin-metadata tests. + */ +import fs from "fs-extra"; +import path from "path"; +import os from "os"; +import yaml from "js-yaml"; +/** Saves and restores process.env around each test. */ +export function withCleanEnv() { + let savedEnv; + return { + save() { + savedEnv = { ...process.env }; + }, + restore() { + for (const key of Object.keys(process.env)) { + if (!(key in savedEnv)) + delete process.env[key]; + } + Object.assign(process.env, savedEnv); + }, + }; +} +/** Creates a temporary metadata directory with Package CRD YAML files. */ +export async function createMetadataFixture(plugins) { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "metadata-test-")); + for (const plugin of plugins) { + const content = { + apiVersion: "extensions.backstage.io/v1alpha1", + kind: "Package", + metadata: { name: plugin.name }, + spec: { + packageName: plugin.packageName, + dynamicArtifact: plugin.dynamicArtifact, + ...(plugin.appConfigExamples + ? { + appConfigExamples: [ + { title: "Default", content: plugin.appConfigExamples }, + ], + } + : {}), + }, + }; + await fs.writeFile(path.join(tmpDir, `${plugin.name}.yaml`), yaml.dump(content)); + } + return tmpDir; +} +/** + * Creates a workspace-like directory structure with metadata, source.json, + * and plugins-list.yaml. Used for tests that trigger PR OCI URL fetching. + */ +export async function createWorkspaceFixture(plugins) { + const wsDir = await fs.mkdtemp(path.join(os.tmpdir(), "workspace-test-")); + const metadataDir = path.join(wsDir, "metadata"); + await fs.mkdir(metadataDir); + /* eslint-disable @typescript-eslint/naming-convention */ + await fs.writeFile(path.join(wsDir, "source.json"), JSON.stringify({ + repo: "https://github.com/test/repo", + "repo-ref": "main", + "repo-flat": false, + })); + /* eslint-enable @typescript-eslint/naming-convention */ + await fs.writeFile(path.join(wsDir, "plugins-list.yaml"), "{}"); + for (const plugin of plugins) { + const content = { + apiVersion: "extensions.backstage.io/v1alpha1", + kind: "Package", + metadata: { name: plugin.name }, + spec: { + packageName: plugin.packageName, + dynamicArtifact: plugin.dynamicArtifact, + ...(plugin.appConfigExamples + ? { + appConfigExamples: [ + { title: "Default", content: plugin.appConfigExamples }, + ], + } + : {}), + }, + }; + await fs.writeFile(path.join(metadataDir, `${plugin.name}.yaml`), yaml.dump(content)); + } + return { wsDir, metadataDir }; +} diff --git a/dist/utils/tests/plugin-metadata.fixtures.test.d.ts b/dist/utils/tests/plugin-metadata.fixtures.test.d.ts new file mode 100644 index 0000000..b1a7a32 --- /dev/null +++ b/dist/utils/tests/plugin-metadata.fixtures.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=plugin-metadata.fixtures.test.d.ts.map \ No newline at end of file diff --git a/dist/utils/tests/plugin-metadata.fixtures.test.d.ts.map b/dist/utils/tests/plugin-metadata.fixtures.test.d.ts.map new file mode 100644 index 0000000..e2580bd --- /dev/null +++ b/dist/utils/tests/plugin-metadata.fixtures.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"plugin-metadata.fixtures.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/plugin-metadata.fixtures.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/utils/tests/plugin-metadata.fixtures.test.js b/dist/utils/tests/plugin-metadata.fixtures.test.js new file mode 100644 index 0000000..ebbb32a --- /dev/null +++ b/dist/utils/tests/plugin-metadata.fixtures.test.js @@ -0,0 +1,563 @@ +/** + * Realistic workspace fixture tests β€” based on actual workspace configurations. + * Each test simulates a real workspace's dynamic-plugins.yaml pattern. + */ +/* eslint-disable @typescript-eslint/naming-convention -- test fixtures use real plugin config keys with dots/dashes */ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import fs from "fs-extra"; +import { processPluginsForDeployment, generatePluginsFromMetadata, } from "../plugin-metadata.js"; +import { withCleanEnv, createMetadataFixture } from "./helpers.js"; +describe("processPluginsForDeployment β€” workspace fixtures", () => { + const env = withCleanEnv(); + beforeEach(() => env.save()); + afterEach(() => env.restore()); + // ── argocd-like ───────────────────────────────────────────────────────── + describe("argocd-like workspace (OCI with aliases + local kubernetes)", () => { + it("resolves OCI plugins to metadata refs and keeps local plugins unchanged", async () => { + delete process.env.GIT_PR_NUMBER; + delete process.env.E2E_NIGHTLY_MODE; + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-argocd", + packageName: "@backstage-community/plugin-argocd", + dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-argocd:bs_1.45.3__2.4.3!backstage-community-plugin-argocd", + appConfigExamples: { + dynamicPlugins: { + frontend: { + "backstage-community.plugin-argocd": { + mountPoints: [ + { + mountPoint: "entity.page.cd/cards", + importName: "EntityArgocdContent", + }, + ], + }, + }, + }, + }, + }, + { + name: "backstage-community-plugin-argocd-backend", + packageName: "@backstage-community/plugin-argocd-backend", + dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-argocd-backend:bs_1.45.3__1.0.2!backstage-community-plugin-argocd-backend", + }, + ]); + try { + const config = { + plugins: [ + { + package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-argocd:old__tag!backstage-community-plugin-argocd", + disabled: false, + pluginConfig: { + dynamicPlugins: { + frontend: { + "backstage-community.plugin-argocd": { + mountPoints: [ + { + mountPoint: "entity.page.cd/cards", + importName: "CustomArgoContent", + }, + ], + }, + }, + }, + }, + }, + { + package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-argocd-backend:old__tag!backstage-community-plugin-argocd-backend", + disabled: false, + }, + { + package: "./dynamic-plugins/dist/backstage-plugin-kubernetes-backend-dynamic", + disabled: false, + }, + { + package: "./dynamic-plugins/dist/backstage-plugin-kubernetes", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + const plugins = result.plugins; + assert.strictEqual(plugins.length, 4, "must preserve all 4 plugins"); + // OCI argocd frontend β†’ metadata ref + assert.ok(plugins[0].package.includes("bs_1.45.3__2.4.3"), "argocd frontend OCI must resolve to metadata version"); + // User pluginConfig overrides metadata + const frontendConfig = plugins[0].pluginConfig?.dynamicPlugins; + const frontend = frontendConfig?.frontend; + const argoMount = frontend?.["backstage-community.plugin-argocd"]; + const mounts = argoMount?.mountPoints; + assert.strictEqual(mounts?.[0]?.importName, "CustomArgoContent", "user pluginConfig must override metadata mountPoints"); + // OCI argocd backend β†’ metadata ref + assert.ok(plugins[1].package.includes("bs_1.45.3__1.0.2"), "argocd backend OCI must resolve to metadata version"); + // Cross-workspace kubernetes plugins (no metadata) β†’ unchanged + assert.strictEqual(plugins[2].package, "./dynamic-plugins/dist/backstage-plugin-kubernetes-backend-dynamic", "cross-workspace local plugin must stay unchanged"); + assert.strictEqual(plugins[3].package, "./dynamic-plugins/dist/backstage-plugin-kubernetes", "cross-workspace local plugin must stay unchanged"); + } + finally { + await fs.remove(metadataDir); + } + }); + }); + // ── scorecard-like ────────────────────────────────────────────────────── + describe("scorecard-like workspace (disabled plugin + cross-workspace OCI)", () => { + it("preserves disabled flag and handles cross-workspace OCI plugin", async () => { + delete process.env.GIT_PR_NUMBER; + delete process.env.E2E_NIGHTLY_MODE; + const metadataDir = await createMetadataFixture([ + { + name: "rhdh-backstage-plugin-scorecard", + packageName: "@red-hat-developer-hub/backstage-plugin-scorecard", + dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/red-hat-developer-hub-backstage-plugin-scorecard:bs_1.45.3__2.3.5!red-hat-developer-hub-backstage-plugin-scorecard", + }, + ]); + try { + const config = { + plugins: [ + { + package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/red-hat-developer-hub-backstage-plugin-scorecard:old_tag!red-hat-developer-hub-backstage-plugin-scorecard", + disabled: false, + pluginConfig: { dynamicPlugins: { frontend: {} } }, + }, + { + package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/red-hat-developer-hub-backstage-plugin-dynamic-home-page:bs_1.45.3__1.10.3!red-hat-developer-hub-backstage-plugin-dynamic-home-page", + disabled: false, + }, + { + package: "./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-dynamic-home-page", + disabled: true, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + const plugins = result.plugins; + assert.strictEqual(plugins.length, 3, "must preserve all 3 plugins"); + assert.ok(plugins[0].package.includes("bs_1.45.3__2.3.5"), "scorecard must resolve to metadata ref"); + assert.ok(plugins[1].package.includes("bs_1.45.3__1.10.3"), "cross-workspace OCI plugin must keep original tag"); + assert.strictEqual(plugins[2].disabled, true, "disabled flag must be preserved"); + assert.strictEqual(plugins[2].package, "./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-dynamic-home-page", "disabled local path must stay unchanged"); + } + finally { + await fs.remove(metadataDir); + } + }); + }); + // ── github-events-like ────────────────────────────────────────────────── + describe("github-events-like workspace (OCI without aliases + different registries)", () => { + it("resolves each OCI to correct metadata registry in nightly", async () => { + delete process.env.GIT_PR_NUMBER; + process.env.E2E_NIGHTLY_MODE = "true"; + const metadataDir = await createMetadataFixture([ + { + name: "backstage-plugin-events-backend-module-github", + packageName: "@backstage/plugin-events-backend-module-github", + dynamicArtifact: "oci://quay.io/rhdh/backstage-plugin-events-backend-module-github@sha256:c1d17d47aaa", + }, + { + name: "backstage-plugin-catalog-backend-module-github-dynamic", + packageName: "@backstage/plugin-catalog-backend-module-github", + dynamicArtifact: "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-dynamic", + }, + ]); + try { + const config = { + plugins: [ + { + package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-events-backend-module-github:bs_1.45.3__0.4.6", + disabled: false, + pluginConfig: { + events: { http: { topics: ["github"] } }, + }, + }, + { + package: "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-dynamic", + disabled: false, + pluginConfig: { + catalog: { providers: { github: { org: "janus-qe" } } }, + }, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + const plugins = result.plugins; + assert.ok(plugins[0].package.startsWith("oci://quay.io/rhdh/"), "must use quay.io registry from metadata, not the ghcr.io from user config"); + assert.ok(plugins[0].package.includes("@sha256:c1d17d47aaa"), "must preserve digest from metadata"); + assert.deepStrictEqual(plugins[0].pluginConfig, { events: { http: { topics: ["github"] } } }, "nightly must preserve user pluginConfig without metadata injection"); + assert.strictEqual(plugins[1].package, "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-dynamic", "local path from metadata must stay unchanged"); + assert.deepStrictEqual(plugins[1].pluginConfig, { catalog: { providers: { github: { org: "janus-qe" } } } }, "nightly must preserve user pluginConfig for local path plugin"); + } + finally { + await fs.remove(metadataDir); + } + }); + }); + // ── topology-like ─────────────────────────────────────────────────────── + describe("topology-like workspace (all local paths, no OCI)", () => { + it("keeps all local plugins unchanged in both PR and nightly modes", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-topology", + packageName: "@backstage-community/plugin-topology", + dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-topology", + appConfigExamples: { + dynamicPlugins: { + frontend: { + "backstage-community.plugin-topology": { + mountPoints: [{ mountPoint: "entity.page.topology/cards" }], + }, + }, + }, + }, + }, + ]); + try { + const config = { + plugins: [ + { + package: "./dynamic-plugins/dist/backstage-community-plugin-topology", + disabled: false, + }, + { + package: "./dynamic-plugins/dist/backstage-plugin-kubernetes-backend-dynamic", + disabled: false, + }, + { + package: "./dynamic-plugins/dist/backstage-plugin-kubernetes", + disabled: false, + }, + ], + }; + // PR mode + delete process.env.GIT_PR_NUMBER; + delete process.env.E2E_NIGHTLY_MODE; + const prResult = await processPluginsForDeployment(config, metadataDir); + assert.ok(prResult.plugins[0].pluginConfig, "PR mode must inject pluginConfig for topology"); + assert.strictEqual(prResult.plugins[0].package, "./dynamic-plugins/dist/backstage-community-plugin-topology", "local path must stay unchanged in PR mode"); + // Nightly mode + process.env.E2E_NIGHTLY_MODE = "true"; + const nightlyResult = await processPluginsForDeployment({ ...config, plugins: config.plugins.map((p) => ({ ...p })) }, metadataDir); + assert.strictEqual(nightlyResult.plugins[0].pluginConfig, undefined, "nightly must not inject pluginConfig"); + for (const result of [prResult, nightlyResult]) { + for (const plugin of result.plugins) { + assert.ok(plugin.package.startsWith("./dynamic-plugins/dist/"), `all plugins must stay as local paths, got: ${plugin.package}`); + } + } + } + finally { + await fs.remove(metadataDir); + } + }); + }); + // ── global-header-like ────────────────────────────────────────────────── + describe("global-header-like workspace (npm package passthrough)", () => { + it("keeps npm package references with integrity unchanged", async () => { + delete process.env.GIT_PR_NUMBER; + delete process.env.E2E_NIGHTLY_MODE; + const metadataDir = await createMetadataFixture([]); + try { + const npmPackage = "@red-hat-developer-hub/backstage-plugin-global-header-test@0.0.2"; + const config = { + plugins: [ + { + package: npmPackage, + disabled: false, + integrity: "sha512-ABC123...", + pluginConfig: { dynamicPlugins: { frontend: {} } }, + }, + { + package: "./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-global-header", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.strictEqual(result.plugins[0].package, npmPackage, "npm package reference must pass through unchanged"); + assert.strictEqual(result.plugins[0].integrity, "sha512-ABC123...", "integrity hash must be preserved"); + } + finally { + await fs.remove(metadataDir); + } + }); + }); + // ── tech-radar-like (auto-generate) ───────────────────────────────────── + describe("auto-generate from metadata (tech-radar-like, no user config)", () => { + it("generates correct entries from metadata with mixed artifact types", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-tech-radar", + packageName: "@backstage-community/plugin-tech-radar", + dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + appConfigExamples: { + techRadar: { url: "http://example.com/tech-radar" }, + }, + }, + { + name: "backstage-community-plugin-tech-radar-backend-dynamic", + packageName: "@backstage-community/plugin-tech-radar-backend", + dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar-backend-dynamic", + }, + ]); + try { + const generated = await generatePluginsFromMetadata(metadataDir); + assert.strictEqual(generated.plugins.length, 2, "must generate 2 entries"); + for (const plugin of generated.plugins) { + assert.strictEqual(plugin.disabled, false, "generated plugins must be enabled"); + assert.strictEqual(plugin.pluginConfig, undefined, "generated plugins must NOT include pluginConfig"); + } + const packages = generated.plugins.map((p) => p.package).sort(); + assert.ok(packages.includes("./dynamic-plugins/dist/backstage-community-plugin-tech-radar"), "must include tech-radar frontend"); + assert.ok(packages.includes("./dynamic-plugins/dist/backstage-community-plugin-tech-radar-backend-dynamic"), "must include tech-radar backend"); + } + finally { + await fs.remove(metadataDir); + } + }); + }); + // ── orchestrator-like ─────────────────────────────────────────────────── + describe("registry.access.redhat.com plugins (orchestrator-like)", () => { + it("resolves to registry.access.redhat.com from metadata", async () => { + delete process.env.GIT_PR_NUMBER; + delete process.env.E2E_NIGHTLY_MODE; + const metadataDir = await createMetadataFixture([ + { + name: "redhat-backstage-plugin-orchestrator", + packageName: "@redhat/backstage-plugin-orchestrator", + dynamicArtifact: "oci://registry.access.redhat.com/rhdh/red-hat-developer-hub-backstage-plugin-orchestrator@sha256:f40d39fb7599", + }, + ]); + try { + const config = { + plugins: [ + { + package: "oci://ghcr.io/some/other/red-hat-developer-hub-backstage-plugin-orchestrator:some_tag", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.ok(result.plugins[0].package.startsWith("oci://registry.access.redhat.com/rhdh/"), "must use registry.access.redhat.com from metadata"); + assert.ok(result.plugins[0].package.includes("@sha256:f40d39fb7599"), "must preserve digest from metadata"); + } + finally { + await fs.remove(metadataDir); + } + }); + }); + // ── Edge cases ────────────────────────────────────────────────────────── + describe("edge cases", () => { + it("returns config as-is when plugins array is undefined", async () => { + const config = { includes: ["dynamic-plugins.default.yaml"] }; + const result = await processPluginsForDeployment(config); + assert.deepStrictEqual(result, config); + }); + it("handles empty plugins array", async () => { + const metadataDir = await createMetadataFixture([]); + try { + const config = { plugins: [] }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.strictEqual(result.plugins.length, 0); + } + finally { + await fs.remove(metadataDir); + } + }); + it("preserves includes and other top-level fields", async () => { + const metadataDir = await createMetadataFixture([]); + try { + const config = { + includes: ["dynamic-plugins.default.yaml"], + plugins: [], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.deepStrictEqual(result.includes, [ + "dynamic-plugins.default.yaml", + ]); + } + finally { + await fs.remove(metadataDir); + } + }); + it("preserves extra fields on plugin entries (integrity, custom keys)", async () => { + const metadataDir = await createMetadataFixture([]); + try { + const config = { + plugins: [ + { + package: "./dynamic-plugins/dist/some-plugin", + disabled: false, + integrity: "sha512-hash", + customField: "value", + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.strictEqual(result.plugins[0].integrity, "sha512-hash"); + assert.strictEqual(result.plugins[0].customField, "value"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("does not inject pluginConfig for plugins with no appConfigExamples", async () => { + delete process.env.GIT_PR_NUMBER; + delete process.env.E2E_NIGHTLY_MODE; + const metadataDir = await createMetadataFixture([ + { + name: "backstage-plugin-kubernetes-backend-dynamic", + packageName: "@backstage/plugin-kubernetes-backend", + dynamicArtifact: "./dynamic-plugins/dist/backstage-plugin-kubernetes-backend-dynamic", + }, + ]); + try { + const config = { + plugins: [ + { + package: "./dynamic-plugins/dist/backstage-plugin-kubernetes-backend-dynamic", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + const pc = result.plugins[0].pluginConfig; + if (pc) { + assert.deepStrictEqual(pc, {}, "plugins without appConfigExamples must get empty pluginConfig or undefined"); + } + } + finally { + await fs.remove(metadataDir); + } + }); + it("deep merges nested pluginConfig (metadata base + user partial override)", async () => { + delete process.env.GIT_PR_NUMBER; + delete process.env.E2E_NIGHTLY_MODE; + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-argocd", + packageName: "@backstage-community/plugin-argocd", + dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-argocd:bs_1.45.3__2.4.3!backstage-community-plugin-argocd", + appConfigExamples: { + dynamicPlugins: { + frontend: { + "backstage-community.plugin-argocd": { + mountPoints: [ + { + mountPoint: "entity.page.cd/cards", + importName: "ArgoContent", + }, + ], + entityTabs: [{ path: "/cd", title: "CD" }], + }, + }, + }, + }, + }, + ]); + try { + const config = { + plugins: [ + { + package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-argocd:old!backstage-community-plugin-argocd", + disabled: false, + pluginConfig: { + dynamicPlugins: { + frontend: { + "backstage-community.plugin-argocd": { + mountPoints: [ + { + mountPoint: "entity.page.cd/cards", + importName: "CustomArgo", + }, + ], + }, + }, + }, + }, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + const pc = result.plugins[0].pluginConfig; + const dp = pc?.dynamicPlugins; + const fe = dp?.frontend; + const argoConfig = fe?.["backstage-community.plugin-argocd"]; + const mounts = argoConfig?.mountPoints; + assert.strictEqual(mounts?.[0]?.importName, "CustomArgo", "user mountPoints must override metadata mountPoints"); + const tabs = argoConfig?.entityTabs; + assert.ok(tabs, "entityTabs from metadata must be preserved when user doesn't override"); + assert.strictEqual(tabs?.[0]?.path, "/cd", "entityTabs must come from metadata base"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("handles non-existent metadata directory gracefully", async () => { + delete process.env.GIT_PR_NUMBER; + delete process.env.E2E_NIGHTLY_MODE; + const config = { + plugins: [ + { + package: "./dynamic-plugins/dist/some-plugin", + disabled: false, + pluginConfig: { key: "value" }, + }, + ], + }; + const result = await processPluginsForDeployment(config, "/tmp/nonexistent-metadata-dir-12345"); + assert.strictEqual(result.plugins[0].package, "./dynamic-plugins/dist/some-plugin", "plugin must pass through when metadata dir doesn't exist"); + assert.deepStrictEqual(result.plugins[0].pluginConfig, { key: "value" }, "pluginConfig must be preserved when metadata dir doesn't exist"); + }); + it("config has local path but metadata has OCI artifact for same plugin", async () => { + delete process.env.GIT_PR_NUMBER; + delete process.env.E2E_NIGHTLY_MODE; + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-tekton", + packageName: "@backstage-community/plugin-tekton", + dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:bs_1.45.3__3.33.3!backstage-community-plugin-tekton", + }, + ]); + try { + const config = { + plugins: [ + { + package: "./dynamic-plugins/dist/backstage-community-plugin-tekton", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.ok(result.plugins[0].package.startsWith("oci://"), "local path in config must be resolved to OCI when metadata has OCI dynamicArtifact"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("shared OCI image with alias (redhat-resource-optimization pattern)", async () => { + delete process.env.GIT_PR_NUMBER; + delete process.env.E2E_NIGHTLY_MODE; + const metadataDir = await createMetadataFixture([ + { + name: "redhat-resource-optimization", + packageName: "@red-hat-developer-hub/plugin-redhat-resource-optimization", + dynamicArtifact: "oci://quay.io/redhat-resource-optimization/dynamic-plugins:1.3.2!red-hat-developer-hub-plugin-redhat-resource-optimization", + }, + ]); + try { + const config = { + plugins: [ + { + package: "oci://quay.io/redhat-resource-optimization/dynamic-plugins:old_tag!red-hat-developer-hub-plugin-redhat-resource-optimization", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.strictEqual(result.plugins[0].package, "oci://quay.io/redhat-resource-optimization/dynamic-plugins:1.3.2!red-hat-developer-hub-plugin-redhat-resource-optimization", "shared OCI image must resolve to metadata version with alias preserved"); + } + finally { + await fs.remove(metadataDir); + } + }); + }); +}); diff --git a/dist/utils/tests/plugin-metadata.nightly.test.d.ts b/dist/utils/tests/plugin-metadata.nightly.test.d.ts new file mode 100644 index 0000000..48822d4 --- /dev/null +++ b/dist/utils/tests/plugin-metadata.nightly.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=plugin-metadata.nightly.test.d.ts.map \ No newline at end of file diff --git a/dist/utils/tests/plugin-metadata.nightly.test.d.ts.map b/dist/utils/tests/plugin-metadata.nightly.test.d.ts.map new file mode 100644 index 0000000..0b9a1cd --- /dev/null +++ b/dist/utils/tests/plugin-metadata.nightly.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"plugin-metadata.nightly.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/plugin-metadata.nightly.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/utils/tests/plugin-metadata.nightly.test.js b/dist/utils/tests/plugin-metadata.nightly.test.js new file mode 100644 index 0000000..c92a6ae --- /dev/null +++ b/dist/utils/tests/plugin-metadata.nightly.test.js @@ -0,0 +1,206 @@ +/** + * Nightly mode tests β€” isNightlyJob detection and nightly plugin resolution. + */ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import fs from "fs-extra"; +import { isNightlyJob, processPluginsForDeployment, } from "../plugin-metadata.js"; +import { withCleanEnv, createMetadataFixture } from "./helpers.js"; +// ── isNightlyJob ───────────────────────────────────────────────────────────── +describe("isNightlyJob", () => { + const env = withCleanEnv(); + beforeEach(() => env.save()); + afterEach(() => env.restore()); + it("returns false with no env vars set", () => { + delete process.env.E2E_NIGHTLY_MODE; + delete process.env.JOB_NAME; + delete process.env.GIT_PR_NUMBER; + assert.strictEqual(isNightlyJob(), false); + }); + it("returns true when E2E_NIGHTLY_MODE is 'true'", () => { + delete process.env.GIT_PR_NUMBER; + process.env.E2E_NIGHTLY_MODE = "true"; + assert.strictEqual(isNightlyJob(), true); + }); + it("returns true when E2E_NIGHTLY_MODE is '1'", () => { + delete process.env.GIT_PR_NUMBER; + process.env.E2E_NIGHTLY_MODE = "1"; + assert.strictEqual(isNightlyJob(), true); + }); + it("returns false when E2E_NIGHTLY_MODE is 'false' (strict check)", () => { + delete process.env.GIT_PR_NUMBER; + process.env.E2E_NIGHTLY_MODE = "false"; + assert.strictEqual(isNightlyJob(), false, "'false' string must not trigger nightly mode"); + }); + it("returns false when E2E_NIGHTLY_MODE is empty string", () => { + delete process.env.GIT_PR_NUMBER; + process.env.E2E_NIGHTLY_MODE = ""; + assert.strictEqual(isNightlyJob(), false, "empty string must not trigger nightly mode"); + }); + it("returns true when JOB_NAME contains 'periodic-'", () => { + delete process.env.GIT_PR_NUMBER; + delete process.env.E2E_NIGHTLY_MODE; + process.env.JOB_NAME = "periodic-ci-overlay-e2e-nightly"; + assert.strictEqual(isNightlyJob(), true); + }); + it("returns false when JOB_NAME contains 'periodic' without trailing dash", () => { + delete process.env.GIT_PR_NUMBER; + delete process.env.E2E_NIGHTLY_MODE; + process.env.JOB_NAME = "run-periodically"; + assert.strictEqual(isNightlyJob(), false, "'periodic' without dash must not trigger nightly mode"); + }); + it("returns false when GIT_PR_NUMBER is set (PR takes precedence)", () => { + process.env.GIT_PR_NUMBER = "42"; + process.env.E2E_NIGHTLY_MODE = "true"; + assert.strictEqual(isNightlyJob(), false, "GIT_PR_NUMBER must take precedence over nightly mode"); + }); + it("returns false when GIT_PR_NUMBER is set even with periodic JOB_NAME", () => { + process.env.GIT_PR_NUMBER = "42"; + process.env.JOB_NAME = "periodic-ci-overlay-e2e-nightly"; + assert.strictEqual(isNightlyJob(), false, "GIT_PR_NUMBER must take precedence over periodic job detection"); + }); +}); +// ── Nightly resolution scenarios ───────────────────────────────────────────── +describe("processPluginsForDeployment β€” nightly mode", () => { + const env = withCleanEnv(); + beforeEach(() => { + env.save(); + delete process.env.GIT_PR_NUMBER; + process.env.E2E_NIGHTLY_MODE = "true"; + }); + afterEach(() => env.restore()); + it("skips metadata injection in nightly mode", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-tech-radar", + packageName: "@backstage-community/plugin-tech-radar", + dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + appConfigExamples: { + techRadar: { url: "http://default.example.com" }, + }, + }, + ]); + try { + const config = { + plugins: [ + { + package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.strictEqual(result.plugins[0].pluginConfig, undefined, "nightly mode must NOT inject metadata pluginConfig"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("preserves user-provided pluginConfig in nightly mode", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-tech-radar", + packageName: "@backstage-community/plugin-tech-radar", + dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + appConfigExamples: { + techRadar: { url: "http://metadata.example.com" }, + }, + }, + ]); + try { + const userPluginConfig = { + techRadar: { url: "http://user.example.com" }, + }; + const config = { + plugins: [ + { + package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + disabled: false, + pluginConfig: userPluginConfig, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.deepStrictEqual(result.plugins[0].pluginConfig, userPluginConfig, "nightly mode must preserve user pluginConfig exactly as-is"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("resolves OCI plugin to metadata dynamicArtifact in nightly", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-tekton", + packageName: "@backstage-community/plugin-tekton", + dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:bs_1.45.3__3.33.3!backstage-community-plugin-tekton", + }, + ]); + try { + const config = { + plugins: [ + { + package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:old_stale_tag!backstage-community-plugin-tekton", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.ok(result.plugins[0].package.includes("bs_1.45.3__3.33.3"), "nightly must resolve to metadata dynamicArtifact (latest published version)"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("resolves wrapper plugin to wrapper path when user config has stale OCI ref", async () => { + // Reproduces: metadata says plugin is a wrapper (local path), but user's + // dynamic-plugins.yaml has a hardcoded OCI ref from a previous version. + // In nightly mode, the plugin should resolve to the wrapper path from + // metadata, not pass through the stale OCI ref unchanged. + const metadataDir = await createMetadataFixture([ + { + name: "backstage-plugin-catalog-backend-module-github-org", + packageName: "@backstage/plugin-catalog-backend-module-github-org", + dynamicArtifact: "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-org-dynamic", + }, + ]); + try { + const config = { + plugins: [ + { + package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-catalog-backend-module-github-org:bs_1.45.3__0.3.16", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.strictEqual(result.plugins[0].package, "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-org-dynamic", "when metadata has a wrapper path, nightly must resolve to wrapper β€” not pass through stale OCI ref from user config"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("keeps local path plugins unchanged in nightly", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "red-hat-developer-hub-backstage-plugin-quickstart", + packageName: "@red-hat-developer-hub/backstage-plugin-quickstart", + dynamicArtifact: "./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-quickstart", + }, + ]); + try { + const config = { + plugins: [ + { + package: "./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-quickstart", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.strictEqual(result.plugins[0].package, "./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-quickstart", "local path plugins must not be converted to OCI in nightly"); + } + finally { + await fs.remove(metadataDir); + } + }); +}); diff --git a/dist/utils/tests/plugin-metadata.pr.test.d.ts b/dist/utils/tests/plugin-metadata.pr.test.d.ts new file mode 100644 index 0000000..5560052 --- /dev/null +++ b/dist/utils/tests/plugin-metadata.pr.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=plugin-metadata.pr.test.d.ts.map \ No newline at end of file diff --git a/dist/utils/tests/plugin-metadata.pr.test.d.ts.map b/dist/utils/tests/plugin-metadata.pr.test.d.ts.map new file mode 100644 index 0000000..cbd9066 --- /dev/null +++ b/dist/utils/tests/plugin-metadata.pr.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"plugin-metadata.pr.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/plugin-metadata.pr.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/utils/tests/plugin-metadata.pr.test.js b/dist/utils/tests/plugin-metadata.pr.test.js new file mode 100644 index 0000000..c6dbed8 --- /dev/null +++ b/dist/utils/tests/plugin-metadata.pr.test.js @@ -0,0 +1,351 @@ +/** + * PR mode tests β€” metadata injection, OCI resolution, skip injection, precedence. + */ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import fs from "fs-extra"; +import { isNightlyJob, processPluginsForDeployment, } from "../plugin-metadata.js"; +import { withCleanEnv, createMetadataFixture, createWorkspaceFixture, } from "./helpers.js"; +describe("processPluginsForDeployment β€” PR mode", () => { + const env = withCleanEnv(); + beforeEach(() => { + env.save(); + delete process.env.E2E_NIGHTLY_MODE; + delete process.env.JOB_NAME; + delete process.env.GIT_PR_NUMBER; + }); + afterEach(() => env.restore()); + it("injects appConfigExamples from metadata as base pluginConfig", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-tech-radar", + packageName: "@backstage-community/plugin-tech-radar", + dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + appConfigExamples: { + techRadar: { url: "http://default.example.com" }, + }, + }, + ]); + try { + const config = { + plugins: [ + { + package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.deepStrictEqual(result.plugins[0].pluginConfig, { techRadar: { url: "http://default.example.com" } }, "metadata appConfigExamples must be injected as pluginConfig in PR mode"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("user pluginConfig overrides metadata appConfigExamples", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-tech-radar", + packageName: "@backstage-community/plugin-tech-radar", + dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + appConfigExamples: { + techRadar: { url: "http://default.example.com" }, + }, + }, + ]); + try { + const config = { + plugins: [ + { + package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + disabled: false, + pluginConfig: { + techRadar: { url: "http://custom.example.com" }, + }, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.strictEqual(result.plugins[0].pluginConfig + .techRadar && + result.plugins[0].pluginConfig + .techRadar.url, "http://custom.example.com", "user pluginConfig must override metadata defaults"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("resolves OCI plugin to metadata dynamicArtifact when no PR number", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-tekton", + packageName: "@backstage-community/plugin-tekton", + dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:bs_1.45.3__3.33.3!backstage-community-plugin-tekton", + }, + ]); + try { + const config = { + plugins: [ + { + package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:old_tag!backstage-community-plugin-tekton", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.strictEqual(result.plugins[0].package, "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:bs_1.45.3__3.33.3!backstage-community-plugin-tekton", "OCI plugin must be resolved to metadata dynamicArtifact"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("keeps local path plugins unchanged", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-tech-radar", + packageName: "@backstage-community/plugin-tech-radar", + dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + }, + ]); + try { + const config = { + plugins: [ + { + package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.strictEqual(result.plugins[0].package, "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", "local path plugins must stay unchanged"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("keeps plugins without metadata unchanged", async () => { + const metadataDir = await createMetadataFixture([]); + try { + const keycloakPackage = "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic"; + const config = { + plugins: [{ package: keycloakPackage, disabled: false }], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.strictEqual(result.plugins[0].package, keycloakPackage, "plugins not in workspace metadata must stay unchanged"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("resolves OCI plugin from different registry using metadata ref", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "backstage-plugin-events-backend-module-github", + packageName: "@backstage/plugin-events-backend-module-github", + dynamicArtifact: "oci://quay.io/rhdh/backstage-plugin-events-backend-module-github@sha256:abc123", + }, + ]); + try { + const config = { + plugins: [ + { + package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-events-backend-module-github:bs_1.45.3__0.4.6", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.ok(result.plugins[0].package.startsWith("oci://quay.io/rhdh/"), "must use the actual registry from metadata, not hardcoded ghcr.io"); + assert.strictEqual(result.plugins[0].package, "oci://quay.io/rhdh/backstage-plugin-events-backend-module-github@sha256:abc123", "must use metadata dynamicArtifact exactly"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("skips injection when RHDH_SKIP_PLUGIN_METADATA_INJECTION is 'true'", async () => { + process.env.RHDH_SKIP_PLUGIN_METADATA_INJECTION = "true"; + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-tech-radar", + packageName: "@backstage-community/plugin-tech-radar", + dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + appConfigExamples: { + techRadar: { url: "http://default.example.com" }, + }, + }, + ]); + try { + const config = { + plugins: [ + { + package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.strictEqual(result.plugins[0].pluginConfig, undefined, "pluginConfig must not be injected when RHDH_SKIP_PLUGIN_METADATA_INJECTION=true"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("does not skip injection when RHDH_SKIP_PLUGIN_METADATA_INJECTION is 'false'", async () => { + process.env.RHDH_SKIP_PLUGIN_METADATA_INJECTION = "false"; + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-tech-radar", + packageName: "@backstage-community/plugin-tech-radar", + dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + appConfigExamples: { techRadar: { url: "http://example.com" } }, + }, + ]); + try { + const config = { + plugins: [ + { + package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.ok(result.plugins[0].pluginConfig, "pluginConfig must be injected when RHDH_SKIP_PLUGIN_METADATA_INJECTION='false' (strict check)"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("resolves mixed plugin types correctly in a single config", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-tekton", + packageName: "@backstage-community/plugin-tekton", + dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:bs_1.45.3__3.33.3!backstage-community-plugin-tekton", + }, + { + name: "backstage-community-plugin-tech-radar", + packageName: "@backstage-community/plugin-tech-radar", + dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + }, + ]); + try { + const config = { + plugins: [ + { + package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:old_tag!backstage-community-plugin-tekton", + disabled: false, + }, + { + package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + disabled: false, + }, + { + package: "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + const plugins = result.plugins; + assert.ok(plugins[0].package.includes("bs_1.45.3__3.33.3"), "OCI plugin with metadata must resolve to metadata dynamicArtifact"); + assert.strictEqual(plugins[1].package, "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", "local path plugin with metadata must stay unchanged"); + assert.strictEqual(plugins[2].package, "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic", "plugin without metadata must stay unchanged"); + } + finally { + await fs.remove(metadataDir); + } + }); + // ── -dynamic suffix normalization ──────────────────────────────────────── + describe("-dynamic suffix normalization", () => { + it("resolves OCI plugin to metadata when dynamicArtifact has -dynamic suffix", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "backstage-plugin-catalog-backend-module-github", + packageName: "@backstage/plugin-catalog-backend-module-github", + dynamicArtifact: "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-dynamic", + appConfigExamples: { + catalog: { providers: { github: { org: "test" } } }, + }, + }, + ]); + try { + const config = { + plugins: [ + { + package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-catalog-backend-module-github:bs_1.45.3__0.11.2", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.ok(result.plugins[0].pluginConfig, "metadata config must be injected even when dynamicArtifact has -dynamic suffix but OCI URL does not"); + assert.deepStrictEqual(result.plugins[0].pluginConfig, { catalog: { providers: { github: { org: "test" } } } }, "injected config must match metadata appConfigExamples"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("keeps local -dynamic path unchanged when metadata also has -dynamic", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "backstage-plugin-catalog-backend-module-github", + packageName: "@backstage/plugin-catalog-backend-module-github", + dynamicArtifact: "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-dynamic", + }, + ]); + try { + const config = { + plugins: [ + { + package: "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-dynamic", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.strictEqual(result.plugins[0].package, "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-dynamic", "local path with -dynamic must stay unchanged"); + } + finally { + await fs.remove(metadataDir); + } + }); + }); + // ── PR vs nightly precedence ──────────────────────────────────────────── + describe("PR vs nightly precedence", () => { + it("isNightlyJob returns false when both GIT_PR_NUMBER and E2E_NIGHTLY_MODE are set", () => { + process.env.GIT_PR_NUMBER = "42"; + process.env.E2E_NIGHTLY_MODE = "true"; + assert.strictEqual(isNightlyJob(), false, "GIT_PR_NUMBER must make isNightlyJob return false"); + }); + it("injects metadata config when GIT_PR_NUMBER is set (PR mode despite nightly env)", async () => { + process.env.GIT_PR_NUMBER = "42"; + process.env.E2E_NIGHTLY_MODE = "true"; + const { wsDir, metadataDir } = await createWorkspaceFixture([ + { + name: "backstage-community-plugin-tech-radar", + packageName: "@backstage-community/plugin-tech-radar", + dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + appConfigExamples: { + techRadar: { url: "http://default.example.com" }, + }, + }, + ]); + try { + const config = { + plugins: [ + { + package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + disabled: false, + }, + ], + }; + const result = await processPluginsForDeployment(config, metadataDir); + assert.ok(result.plugins[0].pluginConfig, "metadata injection must happen when GIT_PR_NUMBER is set (PR takes precedence over nightly)"); + } + finally { + await fs.remove(wsDir); + } + }); + }); +}); diff --git a/dist/utils/tests/plugin-metadata.test.d.ts b/dist/utils/tests/plugin-metadata.test.d.ts new file mode 100644 index 0000000..b92c62b --- /dev/null +++ b/dist/utils/tests/plugin-metadata.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=plugin-metadata.test.d.ts.map \ No newline at end of file diff --git a/dist/utils/tests/plugin-metadata.test.d.ts.map b/dist/utils/tests/plugin-metadata.test.d.ts.map new file mode 100644 index 0000000..237aa0f --- /dev/null +++ b/dist/utils/tests/plugin-metadata.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"plugin-metadata.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/plugin-metadata.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/utils/tests/plugin-metadata.test.js b/dist/utils/tests/plugin-metadata.test.js new file mode 100644 index 0000000..c5a9ade --- /dev/null +++ b/dist/utils/tests/plugin-metadata.test.js @@ -0,0 +1,156 @@ +/** + * Pure utility function tests β€” no env vars, no file system fixtures. + * Tests: extractPluginName, getNormalizedPluginMergeKey, disablePluginWrappers, generatePluginsFromMetadata + */ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import fs from "fs-extra"; +import { extractPluginName, getNormalizedPluginMergeKey, disablePluginWrappers, generatePluginsFromMetadata, } from "../plugin-metadata.js"; +import { createMetadataFixture } from "./helpers.js"; +// ── extractPluginName ──────────────────────────────────────────────────────── +describe("extractPluginName", () => { + it("extracts name from OCI URL with tag and alias", () => { + assert.strictEqual(extractPluginName("oci://ghcr.io/org/repo/backstage-community-plugin-keycloak:pr_1__1.0!alias"), "backstage-community-plugin-keycloak"); + }); + it("extracts name from OCI URL with digest and alias", () => { + assert.strictEqual(extractPluginName("oci://quay.io/rhdh/backstage-plugin-events-backend-module-github@sha256:abc123!backstage-plugin-events-backend-module-github"), "backstage-plugin-events-backend-module-github"); + }); + it("extracts name from OCI URL with tag only (no alias)", () => { + assert.strictEqual(extractPluginName("oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:bs_1.45.3__3.33.3"), "backstage-community-plugin-tekton"); + }); + it("extracts name from local path and strips -dynamic suffix", () => { + assert.strictEqual(extractPluginName("./dynamic-plugins/dist/backstage-community-plugin-keycloak-dynamic"), "backstage-community-plugin-keycloak"); + }); + it("extracts name from OCI URL with digest but no alias", () => { + assert.strictEqual(extractPluginName("oci://quay.io/rhdh/backstage-plugin-events-backend-module-github@sha256:c1d17d47aaa"), "backstage-plugin-events-backend-module-github"); + }); + it("uses alias when alias differs from image name (redhat-resource-optimization pattern)", () => { + assert.strictEqual(extractPluginName("oci://quay.io/redhat-resource-optimization/dynamic-plugins:1.3.2!red-hat-developer-hub-plugin-redhat-resource-optimization"), "dynamic-plugins", "when alias is present, extractPluginName strips alias first and extracts from OCI path"); + }); + it("extracts name from npm package reference", () => { + assert.strictEqual(extractPluginName("@red-hat-developer-hub/backstage-plugin-global-header-test@0.0.2"), "backstage-plugin-global-header-test", "must extract plugin name from npm @scope/name@version format"); + }); +}); +// ── getNormalizedPluginMergeKey ─────────────────────────────────────────────── +describe("getNormalizedPluginMergeKey", () => { + it("returns same key for OCI and local -dynamic variant of the same plugin", () => { + const oci = getNormalizedPluginMergeKey({ + package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-catalog-backend-module-keycloak:pr_1980__3.16.0!backstage-community-plugin-catalog-backend-module-keycloak", + }); + const local = getNormalizedPluginMergeKey({ + package: "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic", + }); + assert.strictEqual(oci, local, "same logical plugin has same merge key"); + assert.strictEqual(oci, "backstage-community-plugin-catalog-backend-module-keycloak"); + }); + it("returns different keys for different plugins", () => { + const keycloak = getNormalizedPluginMergeKey({ + package: "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic", + }); + const techRadar = getNormalizedPluginMergeKey({ + package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar-dynamic", + }); + assert.notStrictEqual(keycloak, techRadar); + }); + it("returns empty string for missing or empty package", () => { + assert.strictEqual(getNormalizedPluginMergeKey({}), ""); + assert.strictEqual(getNormalizedPluginMergeKey({ package: "" }), ""); + assert.strictEqual(getNormalizedPluginMergeKey({ package: undefined }), ""); + }); +}); +// ── disablePluginWrappers ──────────────────────────────────────────────────── +describe("disablePluginWrappers", () => { + it("returns empty plugins array for empty input", () => { + const result = disablePluginWrappers([]); + assert.deepStrictEqual(result, { plugins: [] }); + }); + it("creates disabled entries with correct local path format", () => { + const result = disablePluginWrappers([ + "backstage-community-plugin-tech-radar", + "backstage-plugin-kubernetes", + ]); + assert.strictEqual(result.plugins.length, 2); + assert.deepStrictEqual(result.plugins[0], { + package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + disabled: true, + }); + assert.deepStrictEqual(result.plugins[1], { + package: "./dynamic-plugins/dist/backstage-plugin-kubernetes", + disabled: true, + }); + }); +}); +// ── generatePluginsFromMetadata ────────────────────────────────────────────── +describe("generatePluginsFromMetadata", () => { + it("generates entries from metadata with package set to dynamicArtifact", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-tech-radar", + packageName: "@backstage-community/plugin-tech-radar", + dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + appConfigExamples: { techRadar: { url: "http://example.com" } }, + }, + ]); + try { + const result = await generatePluginsFromMetadata(metadataDir); + assert.strictEqual(result.plugins.length, 1); + assert.strictEqual(result.plugins[0].package, "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", "package must be the dynamicArtifact from metadata"); + assert.strictEqual(result.plugins[0].disabled, false); + assert.strictEqual(result.plugins[0].pluginConfig, undefined, "generatePluginsFromMetadata must NOT include pluginConfig"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("generates entries for OCI-referenced plugins", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-tekton", + packageName: "@backstage-community/plugin-tekton", + dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:bs_1.45.3__3.33.3!backstage-community-plugin-tekton", + }, + ]); + try { + const result = await generatePluginsFromMetadata(metadataDir); + assert.strictEqual(result.plugins.length, 1); + assert.ok(result.plugins[0].package.startsWith("oci://"), "OCI artifact must be preserved as package"); + } + finally { + await fs.remove(metadataDir); + } + }); + it("generates entries for mixed local and OCI artifacts", async () => { + const metadataDir = await createMetadataFixture([ + { + name: "backstage-community-plugin-tech-radar", + packageName: "@backstage-community/plugin-tech-radar", + dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", + }, + { + name: "backstage-community-plugin-tekton", + packageName: "@backstage-community/plugin-tekton", + dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:bs_1.45.3__3.33.3!backstage-community-plugin-tekton", + }, + { + name: "backstage-plugin-events-backend-module-github", + packageName: "@backstage/plugin-events-backend-module-github", + dynamicArtifact: "oci://quay.io/rhdh/backstage-plugin-events-backend-module-github@sha256:abc123", + }, + ]); + try { + const result = await generatePluginsFromMetadata(metadataDir); + assert.strictEqual(result.plugins.length, 3, "must generate 3 entries"); + const packages = result.plugins.map((p) => p.package).sort(); + assert.ok(packages.some((p) => p.startsWith("./")), "must include local path artifact"); + assert.ok(packages.some((p) => p.startsWith("oci://ghcr.io/")), "must include ghcr.io OCI"); + assert.ok(packages.some((p) => p.startsWith("oci://quay.io/")), "must include quay.io OCI"); + for (const plugin of result.plugins) { + assert.strictEqual(plugin.disabled, false); + assert.strictEqual(plugin.pluginConfig, undefined); + } + } + finally { + await fs.remove(metadataDir); + } + }); +}); diff --git a/dist/utils/vault.d.ts b/dist/utils/vault.d.ts new file mode 100644 index 0000000..69a5f52 --- /dev/null +++ b/dist/utils/vault.d.ts @@ -0,0 +1,16 @@ +/** + * Loads secrets from HashiCorp Vault into process.env. + * Only runs when `VAULT=1` or `VAULT=true` is set. Handles OIDC login automatically. + * + * Fetches secrets from: + * - Global path: `/global` + * - Per-workspace paths: `/workspaces/` + * + * Configure via env vars: + * - `VAULT_ADDR` β€” Vault server URL (default: https://vault.ci.openshift.org) + * - `VAULT_BASE_PATH` β€” Base path in Vault (default: selfservice/rhdh-plugin-export-overlays) + * + * Security: Only key names are logged, never secret values. + */ +export declare function loadLocalVaultSecrets(): Promise; +//# sourceMappingURL=vault.d.ts.map \ No newline at end of file diff --git a/dist/utils/vault.d.ts.map b/dist/utils/vault.d.ts.map new file mode 100644 index 0000000..e884a72 --- /dev/null +++ b/dist/utils/vault.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"vault.d.ts","sourceRoot":"","sources":["../../src/utils/vault.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;GAaG;AACH,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC,CAiE3D"} \ No newline at end of file diff --git a/dist/utils/vault.js b/dist/utils/vault.js new file mode 100644 index 0000000..8ba32dd --- /dev/null +++ b/dist/utils/vault.js @@ -0,0 +1,94 @@ +import { $ } from "./bash.js"; +const VAULT_ADDR_DEFAULT = "https://vault.ci.openshift.org"; +const VAULT_BASE_PATH_DEFAULT = "selfservice/rhdh-plugin-export-overlays"; +/** + * Loads secrets from HashiCorp Vault into process.env. + * Only runs when `VAULT=1` or `VAULT=true` is set. Handles OIDC login automatically. + * + * Fetches secrets from: + * - Global path: `/global` + * - Per-workspace paths: `/workspaces/` + * + * Configure via env vars: + * - `VAULT_ADDR` β€” Vault server URL (default: https://vault.ci.openshift.org) + * - `VAULT_BASE_PATH` β€” Base path in Vault (default: selfservice/rhdh-plugin-export-overlays) + * + * Security: Only key names are logged, never secret values. + */ +export async function loadLocalVaultSecrets() { + if (process.env.VAULT !== "1" && process.env.VAULT !== "true") + return; + const vaultAddr = process.env.VAULT_ADDR || VAULT_ADDR_DEFAULT; + const basePath = process.env.VAULT_BASE_PATH || VAULT_BASE_PATH_DEFAULT; + process.env.VAULT_ADDR = vaultAddr; + // Check vault CLI is installed + const whichResult = await vaultCmd `command -v vault`; + if (whichResult.exitCode !== 0) { + throw new Error("vault CLI not found. Install from https://developer.hashicorp.com/vault/downloads"); + } + // Check if already logged in + const tokenCheck = await vaultCmd `vault token lookup`; + if (tokenCheck.exitCode !== 0) { + console.log("Vault: not logged in, starting OIDC login..."); + // vault login needs inherited stdio for browser-based OIDC flow + await $ `vault login -no-print -method=oidc`; + const retryCheck = await vaultCmd `vault token lookup`; + if (retryCheck.exitCode !== 0) { + throw new Error("Vault login failed. Run manually:\n export VAULT_ADDR='" + + vaultAddr + + "'\n vault login -method=oidc"); + } + } + // Check access by fetching global secrets first + const globalResult = await vaultCmd `vault kv get -format=json -mount=kv ${basePath}/global`; + if (globalResult.stderr.includes("permission denied")) { + console.log("Vault: permission denied. Request access in Slack: #rhdh-e2e-tests"); + return; + } + console.log("Loading secrets from vault..."); + // Load global secrets + loadSecretsFromResult(globalResult, "global"); + // List and fetch per-workspace secrets + const listResult = await vaultCmd `vault kv list -format=json -mount=kv ${basePath}/workspaces`; + if (listResult.exitCode === 0) { + const workspaces = JSON.parse(listResult.stdout); + await Promise.all(workspaces.map((ws) => { + const name = ws.replace(/\/$/, ""); + return exportSecretsFromPath(`${basePath}/workspaces/${name}`, name); + })); + } + else { + console.log(" No workspace-specific secrets found"); + } + console.log("Vault secrets loaded successfully."); +} +/** Runs a shell command with piped stdio and nothrow, for capturing vault CLI output. */ +const vaultCmd = $({ + stdio: ["pipe", "pipe", "pipe"], + nothrow: true, +}); +async function exportSecretsFromPath(vaultPath, label) { + const result = await vaultCmd `vault kv get -format=json -mount=kv ${vaultPath}`; + loadSecretsFromResult(result, label); +} +function loadSecretsFromResult(result, label) { + if (result.exitCode !== 0) { + console.log(` No secrets at: ${label}`); + return; + } + const json = JSON.parse(result.stdout); + const secrets = json?.data?.data; + if (!secrets) { + console.log(` No secrets at: ${label}`); + return; + } + console.log(` From: ${label}`); + for (const [key, value] of Object.entries(secrets)) { + if (key.startsWith("secretsync/")) + continue; + if (!key.startsWith("VAULT_")) + continue; + const safeKey = key.replace(/[.\-/]/g, "_"); + process.env[safeKey] = value; + } +} diff --git a/dist/utils/workspace-paths.d.ts b/dist/utils/workspace-paths.d.ts new file mode 100644 index 0000000..2725477 --- /dev/null +++ b/dist/utils/workspace-paths.d.ts @@ -0,0 +1,43 @@ +/** + * Static utility for resolving paths relative to a workspace's e2e-tests directory. + * Uses `test.info().project.testDir` to determine the workspace location β€” + * works correctly whether Playwright runs from the workspace or from the repo root. + * + * @example + * ```typescript + * import { WorkspacePaths } from '@red-hat-developer-hub/e2e-test-utils/utils'; + * + * // One-liner to resolve a config file path + * const configPath = WorkspacePaths.resolve("tests/config/rbac-configmap.yaml"); + * + * // Access well-known directories + * WorkspacePaths.e2eRoot; // /abs/path/workspaces/acr/e2e-tests + * WorkspacePaths.workspaceRoot; // /abs/path/workspaces/acr + * WorkspacePaths.metadataDir; // /abs/path/workspaces/acr/metadata + * WorkspacePaths.configDir; // /abs/path/workspaces/acr/e2e-tests/tests/config + * ``` + */ +export declare class WorkspacePaths { + private constructor(); + /** The workspace's e2e-tests directory, derived from the current test's project testDir. */ + static get e2eRoot(): string; + /** Resolve a relative path from the e2e-tests directory. */ + static resolve(relativePath: string): string; + /** The workspace root directory (parent of e2e-tests). */ + static get workspaceRoot(): string; + /** The metadata directory. e.g., `workspaces/acr/metadata` */ + static get metadataDir(): string; + /** The tests/config directory. e.g., `workspaces/acr/e2e-tests/tests/config` */ + static get configDir(): string; + /** Default app-config path: `tests/config/app-config-rhdh.yaml` */ + static get appConfig(): string; + /** Default secrets path: `tests/config/rhdh-secrets.yaml` */ + static get secrets(): string; + /** Default dynamic plugins path: `tests/config/dynamic-plugins.yaml` */ + static get dynamicPlugins(): string; + /** Default Helm value file path: `tests/config/value_file.yaml` */ + static get valueFile(): string; + /** Default operator subscription path: `tests/config/subscription.yaml` */ + static get subscription(): string; +} +//# sourceMappingURL=workspace-paths.d.ts.map \ No newline at end of file diff --git a/dist/utils/workspace-paths.d.ts.map b/dist/utils/workspace-paths.d.ts.map new file mode 100644 index 0000000..93c1d67 --- /dev/null +++ b/dist/utils/workspace-paths.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"workspace-paths.d.ts","sourceRoot":"","sources":["../../src/utils/workspace-paths.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,cAAc;IACzB,OAAO;IAEP,4FAA4F;IAC5F,MAAM,KAAK,OAAO,IAAI,MAAM,CAE3B;IAED,4DAA4D;IAC5D,MAAM,CAAC,OAAO,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM;IAI5C,0DAA0D;IAC1D,MAAM,KAAK,aAAa,IAAI,MAAM,CAEjC;IAED,8DAA8D;IAC9D,MAAM,KAAK,WAAW,IAAI,MAAM,CAE/B;IAED,gFAAgF;IAChF,MAAM,KAAK,SAAS,IAAI,MAAM,CAE7B;IAID,mEAAmE;IACnE,MAAM,KAAK,SAAS,IAAI,MAAM,CAE7B;IAED,6DAA6D;IAC7D,MAAM,KAAK,OAAO,IAAI,MAAM,CAE3B;IAED,wEAAwE;IACxE,MAAM,KAAK,cAAc,IAAI,MAAM,CAElC;IAED,mEAAmE;IACnE,MAAM,KAAK,SAAS,IAAI,MAAM,CAE7B;IAED,2EAA2E;IAC3E,MAAM,KAAK,YAAY,IAAI,MAAM,CAEhC;CACF"} \ No newline at end of file diff --git a/dist/utils/workspace-paths.js b/dist/utils/workspace-paths.js new file mode 100644 index 0000000..d966c18 --- /dev/null +++ b/dist/utils/workspace-paths.js @@ -0,0 +1,65 @@ +import path from "path"; +import { test } from "@playwright/test"; +/** + * Static utility for resolving paths relative to a workspace's e2e-tests directory. + * Uses `test.info().project.testDir` to determine the workspace location β€” + * works correctly whether Playwright runs from the workspace or from the repo root. + * + * @example + * ```typescript + * import { WorkspacePaths } from '@red-hat-developer-hub/e2e-test-utils/utils'; + * + * // One-liner to resolve a config file path + * const configPath = WorkspacePaths.resolve("tests/config/rbac-configmap.yaml"); + * + * // Access well-known directories + * WorkspacePaths.e2eRoot; // /abs/path/workspaces/acr/e2e-tests + * WorkspacePaths.workspaceRoot; // /abs/path/workspaces/acr + * WorkspacePaths.metadataDir; // /abs/path/workspaces/acr/metadata + * WorkspacePaths.configDir; // /abs/path/workspaces/acr/e2e-tests/tests/config + * ``` + */ +export class WorkspacePaths { + constructor() { } // Static-only class + /** The workspace's e2e-tests directory, derived from the current test's project testDir. */ + static get e2eRoot() { + return path.resolve(test.info().project.testDir, ".."); + } + /** Resolve a relative path from the e2e-tests directory. */ + static resolve(relativePath) { + return path.resolve(this.e2eRoot, relativePath); + } + /** The workspace root directory (parent of e2e-tests). */ + static get workspaceRoot() { + return path.resolve(this.e2eRoot, ".."); + } + /** The metadata directory. e.g., `workspaces/acr/metadata` */ + static get metadataDir() { + return path.resolve(this.e2eRoot, "../metadata"); + } + /** The tests/config directory. e.g., `workspaces/acr/e2e-tests/tests/config` */ + static get configDir() { + return path.resolve(this.e2eRoot, "tests/config"); + } + // ── Default config file paths ──────────────────────────────────────────── + /** Default app-config path: `tests/config/app-config-rhdh.yaml` */ + static get appConfig() { + return path.resolve(this.e2eRoot, "tests/config/app-config-rhdh.yaml"); + } + /** Default secrets path: `tests/config/rhdh-secrets.yaml` */ + static get secrets() { + return path.resolve(this.e2eRoot, "tests/config/rhdh-secrets.yaml"); + } + /** Default dynamic plugins path: `tests/config/dynamic-plugins.yaml` */ + static get dynamicPlugins() { + return path.resolve(this.e2eRoot, "tests/config/dynamic-plugins.yaml"); + } + /** Default Helm value file path: `tests/config/value_file.yaml` */ + static get valueFile() { + return path.resolve(this.e2eRoot, "tests/config/value_file.yaml"); + } + /** Default operator subscription path: `tests/config/subscription.yaml` */ + static get subscription() { + return path.resolve(this.e2eRoot, "tests/config/subscription.yaml"); + } +} From 10831e8fa590b5b2b84cb06a297f260957ba49c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20August=C3=ADn?= Date: Wed, 13 May 2026 12:59:51 +0200 Subject: [PATCH 3/8] fix: increase GitHub login timeout from 3s to 30s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dominik AugustΓ­n --- dist/playwright/helpers/common.js | 2 +- src/playwright/helpers/common.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dist/playwright/helpers/common.js b/dist/playwright/helpers/common.js index 2636538..9d9f888 100644 --- a/dist/playwright/helpers/common.js +++ b/dist/playwright/helpers/common.js @@ -52,7 +52,7 @@ export class LoginHelper { await this.page.waitForTimeout(60000); await this.page.fill("#app_totp", this.getGitHub2FAOTP(userid)); } - await this.page.waitForTimeout(3_000); + await this.page.waitForTimeout(30_000); } async logintoKeycloak(popup, userid, password) { await popup.waitForLoadState(); diff --git a/src/playwright/helpers/common.ts b/src/playwright/helpers/common.ts index 4913641..bef46a7 100644 --- a/src/playwright/helpers/common.ts +++ b/src/playwright/helpers/common.ts @@ -75,7 +75,7 @@ export class LoginHelper { await this.page.fill("#app_totp", this.getGitHub2FAOTP(userid)); } - await this.page.waitForTimeout(3_000); + await this.page.waitForTimeout(30_000); } async logintoKeycloak(popup: Page, userid: string, password: string) { From ccaa9e301cb9709687f1eab8d7e99ac469f9961b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20August=C3=ADn?= Date: Wed, 13 May 2026 13:21:40 +0200 Subject: [PATCH 4/8] fix: loginAsGithubUser case when user might already be logged in before MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dominik AugustΓ­n --- dist/playwright/helpers/common.d.ts.map | 2 +- dist/playwright/helpers/common.js | 8 ++++-- src/playwright/helpers/common.ts | 36 ++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/dist/playwright/helpers/common.d.ts.map b/dist/playwright/helpers/common.d.ts.map index e22dffa..8375503 100644 --- a/dist/playwright/helpers/common.d.ts.map +++ b/dist/playwright/helpers/common.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/common.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAG1C,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAOhE,qBAAa,WAAW;IACtB,IAAI,EAAE,IAAI,CAAC;IACX,QAAQ,EAAE,QAAQ,CAAC;gBAEP,IAAI,EAAE,IAAI;IAKhB,YAAY;IAcZ,OAAO;YAMC,aAAa;IAyCrB,eAAe,CAAC,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;IAO7D,mBAAmB,CACvB,MAAM,GAAE,MAAkC,EAC1C,QAAQ,GAAE,MAAkC;IAWxC,iBAAiB,CACrB,MAAM,GAAE,MAA+C;IAwDnD,4BAA4B;IAqB5B,YAAY,CAAC,KAAK,EAAE,MAAM;IA2B1B,2BAA2B,CAAC,KAAK,UAAQ;IAU/C,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM;IAIxC,mBAAmB,IAAI,MAAM;IAIvB,mBAAmB;IAgBzB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM;IAevC,eAAe,IAAI,MAAM;IAKnB,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;YAqCxC,sBAAsB;IAoD9B,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM;IAYjE,2BAA2B,CAC/B,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM;IAYb,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;CA2C7D;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ;;;GAWtE"} \ No newline at end of file +{"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/common.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAG1C,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAOhE,qBAAa,WAAW;IACtB,IAAI,EAAE,IAAI,CAAC;IACX,QAAQ,EAAE,QAAQ,CAAC;gBAEP,IAAI,EAAE,IAAI;IAKhB,YAAY;IAcZ,OAAO;YAMC,aAAa;IAyCrB,eAAe,CAAC,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;IAO7D,mBAAmB,CACvB,MAAM,GAAE,MAAkC,EAC1C,QAAQ,GAAE,MAAkC;IAWxC,iBAAiB,CACrB,MAAM,GAAE,MAA+C;IA+DnD,4BAA4B;IAqB5B,YAAY,CAAC,KAAK,EAAE,MAAM;IA2B1B,2BAA2B,CAAC,KAAK,UAAQ;IAU/C,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM;IAIxC,mBAAmB,IAAI,MAAM;IAIvB,mBAAmB;IAgBzB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM;IAevC,eAAe,IAAI,MAAM;IAKnB,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;YAqCxC,sBAAsB;IAoD9B,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM;IAYjE,2BAA2B,CAC/B,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM;IAYb,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;CA2C7D;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ;;;GAWtE"} \ No newline at end of file diff --git a/dist/playwright/helpers/common.js b/dist/playwright/helpers/common.js index 9d9f888..c75d754 100644 --- a/dist/playwright/helpers/common.js +++ b/dist/playwright/helpers/common.js @@ -90,8 +90,12 @@ export class LoginHelper { .then((popup) => ({ popup })) .catch(() => null); const result = await Promise.race([navPromise, popupPromise]); - if (result && typeof result === "object" && "popup" in result) { + if (result === null) { + throw new Error("GitHub login failed: neither sidebar nor popup appeared after Sign In β€” session file may be stale"); + } + if (typeof result === "object" && "popup" in result) { // Popup opened β€” handle reauthorization + // TODO this is the same code as checkAndReauthorizeGithubApp's promise body const popup = result.popup; await popup.waitForLoadState(); for (let attempts = 0; attempts < 10 && !popup.isClosed(); attempts++) { @@ -181,7 +185,7 @@ export class LoginHelper { const isLoginRequiredVisible = await this.uiHelper.isTextVisible("Sign in"); if (isLoginRequiredVisible) { await this.uiHelper.clickButton("Sign in"); - // await this.uiHelper.clickButton("Log in"); + await this.uiHelper.clickButton("Log in"); await this.checkAndReauthorizeGithubApp(); await this.page.waitForSelector(this.getLoginBtnSelector(), { state: "detached", diff --git a/src/playwright/helpers/common.ts b/src/playwright/helpers/common.ts index bef46a7..ae30cd1 100644 --- a/src/playwright/helpers/common.ts +++ b/src/playwright/helpers/common.ts @@ -114,7 +114,41 @@ export class LoginHelper { await this.page.goto("/"); await this.uiHelper.waitForLoad(12000); await this.uiHelper.clickButton("Sign In"); - await this.checkAndReauthorizeGithubApp(); + + // Wait for either: sidebar appears (auto-login) or popup opens (needs auth) + const navPromise = this.page + .waitForSelector("nav a", { timeout: 15_000 }) + .then(() => "nav" as const) + .catch(() => null); + + const popupPromise = this.page + .waitForEvent("popup", { timeout: 15_000 }) + .then((popup) => ({ popup })) + .catch(() => null); + + const result = await Promise.race([navPromise, popupPromise]); + + if (result === null) { + throw new Error( + "GitHub login failed: neither sidebar nor popup appeared after Sign In β€” session file may be stale", + ); + } + + if (typeof result === "object" && "popup" in result) { + // Popup opened β€” handle reauthorization + // TODO this is the same code as checkAndReauthorizeGithubApp's promise body + const popup = result.popup; + await popup.waitForLoadState(); + for (let attempts = 0; attempts < 10 && !popup.isClosed(); attempts++) { + await this.page.waitForTimeout(1000); + } + const locator = popup.locator("button.js-oauth-authorize-btn"); + if (!popup.isClosed() && (await locator.isVisible())) { + await popup.locator("body").click(); + await locator.waitFor(); + await locator.click(); + } + } } else { // Perform login if no session file exists, then save the state await this.logintoGithub(userid); From ad6d127fe13db2b3c29a27c123d25ec431edc06d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20August=C3=ADn?= Date: Thu, 14 May 2026 10:26:25 +0200 Subject: [PATCH 5/8] chore: removed built dist files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dominik AugustΓ­n --- .../keycloak/config/keycloak-values.yaml | 96 --- dist/deployment/keycloak/constants.d.ts | 29 - dist/deployment/keycloak/constants.d.ts.map | 1 - dist/deployment/keycloak/constants.js | 75 --- dist/deployment/keycloak/deployment.d.ts | 107 --- dist/deployment/keycloak/deployment.d.ts.map | 1 - dist/deployment/keycloak/deployment.js | 485 -------------- dist/deployment/keycloak/index.d.ts | 3 - dist/deployment/keycloak/index.d.ts.map | 1 - dist/deployment/keycloak/index.js | 1 - dist/deployment/keycloak/types.d.ts | 59 -- dist/deployment/keycloak/types.d.ts.map | 1 - dist/deployment/keycloak/types.js | 1 - dist/deployment/orchestrator/index.d.ts | 3 - dist/deployment/orchestrator/index.d.ts.map | 1 - dist/deployment/orchestrator/index.js | 7 - .../orchestrator/install-orchestrator.sh | 486 -------------- .../rhdh/config/auth/github/app-config.yaml | 17 - .../rhdh/config/auth/github/secrets.yaml | 12 - .../rhdh/config/auth/guest/app-config.yaml | 5 - .../rhdh/config/auth/keycloak/app-config.yaml | 19 - .../config/auth/keycloak/dynamic-plugins.yaml | 3 - .../rhdh/config/auth/keycloak/secrets.yaml | 12 - .../rhdh/config/common/app-config-rhdh.yaml | 6 - .../rhdh/config/common/dynamic-plugins.yaml | 5 - .../rhdh/config/common/rhdh-secrets.yaml | 7 - .../rhdh/config/helm/value_file.yaml | 7 - .../rhdh/config/operator/subscription.yaml | 21 - dist/deployment/rhdh/constants.d.ts | 21 - dist/deployment/rhdh/constants.d.ts.map | 1 - dist/deployment/rhdh/constants.js | 33 - dist/deployment/rhdh/deployment.d.ts | 59 -- dist/deployment/rhdh/deployment.d.ts.map | 1 - dist/deployment/rhdh/deployment.js | 412 ------------ dist/deployment/rhdh/deployment.test.d.ts | 2 - dist/deployment/rhdh/deployment.test.d.ts.map | 1 - dist/deployment/rhdh/deployment.test.js | 41 -- dist/deployment/rhdh/index.d.ts | 2 - dist/deployment/rhdh/index.d.ts.map | 1 - dist/deployment/rhdh/index.js | 1 - dist/deployment/rhdh/types.d.ts | 33 - dist/deployment/rhdh/types.d.ts.map | 1 - dist/deployment/rhdh/types.js | 1 - dist/eslint/base.config.d.ts | 10 - dist/eslint/base.config.d.ts.map | 1 - dist/eslint/base.config.js | 220 ------- dist/playwright/base-config.d.ts | 14 - dist/playwright/base-config.d.ts.map | 1 - dist/playwright/base-config.js | 49 -- dist/playwright/fixtures/test.d.ts | 17 - dist/playwright/fixtures/test.d.ts.map | 1 - dist/playwright/fixtures/test.js | 63 -- dist/playwright/global-setup.d.ts | 7 - dist/playwright/global-setup.d.ts.map | 1 - dist/playwright/global-setup.js | 87 --- dist/playwright/helpers/accessibility.d.ts | 13 - .../playwright/helpers/accessibility.d.ts.map | 1 - dist/playwright/helpers/accessibility.js | 24 - dist/playwright/helpers/api-endpoints.d.ts | 13 - .../playwright/helpers/api-endpoints.d.ts.map | 1 - dist/playwright/helpers/api-endpoints.js | 17 - dist/playwright/helpers/api-helper.d.ts | 77 --- dist/playwright/helpers/api-helper.d.ts.map | 1 - dist/playwright/helpers/api-helper.js | 295 --------- dist/playwright/helpers/auth-api-helper.d.ts | 7 - .../helpers/auth-api-helper.d.ts.map | 1 - dist/playwright/helpers/auth-api-helper.js | 31 - dist/playwright/helpers/common.d.ts | 31 - dist/playwright/helpers/common.d.ts.map | 1 - dist/playwright/helpers/common.js | 366 ---------- dist/playwright/helpers/index.d.ts | 7 - dist/playwright/helpers/index.d.ts.map | 1 - dist/playwright/helpers/index.js | 6 - dist/playwright/helpers/navbar.d.ts | 2 - dist/playwright/helpers/navbar.d.ts.map | 1 - dist/playwright/helpers/navbar.js | 1 - dist/playwright/helpers/rbac-api-helper.d.ts | 43 -- .../helpers/rbac-api-helper.d.ts.map | 1 - dist/playwright/helpers/rbac-api-helper.js | 80 --- dist/playwright/helpers/ui-helper.d.ts | 113 ---- dist/playwright/helpers/ui-helper.d.ts.map | 1 - dist/playwright/helpers/ui-helper.js | 455 ------------- dist/playwright/page-objects/global-obj.d.ts | 25 - .../page-objects/global-obj.d.ts.map | 1 - dist/playwright/page-objects/global-obj.js | 24 - dist/playwright/page-objects/page-obj.d.ts | 41 -- .../playwright/page-objects/page-obj.d.ts.map | 1 - dist/playwright/page-objects/page-obj.js | 40 -- dist/playwright/pages/catalog-import.d.ts | 31 - dist/playwright/pages/catalog-import.d.ts.map | 1 - dist/playwright/pages/catalog-import.js | 65 -- dist/playwright/pages/catalog.d.ts | 14 - dist/playwright/pages/catalog.d.ts.map | 1 - dist/playwright/pages/catalog.js | 37 -- dist/playwright/pages/extensions.d.ts | 38 -- dist/playwright/pages/extensions.d.ts.map | 1 - dist/playwright/pages/extensions.js | 110 ---- dist/playwright/pages/home-page.d.ts | 10 - dist/playwright/pages/home-page.d.ts.map | 1 - dist/playwright/pages/home-page.js | 46 -- dist/playwright/pages/index.d.ts | 7 - dist/playwright/pages/index.d.ts.map | 1 - dist/playwright/pages/index.js | 6 - dist/playwright/pages/notifications.d.ts | 24 - dist/playwright/pages/notifications.d.ts.map | 1 - dist/playwright/pages/notifications.js | 112 ---- dist/playwright/pages/orchestrator.d.ts | 23 - dist/playwright/pages/orchestrator.d.ts.map | 1 - dist/playwright/pages/orchestrator.js | 248 ------- dist/playwright/pages/workflows.d.ts | 3 - dist/playwright/pages/workflows.d.ts.map | 1 - dist/playwright/pages/workflows.js | 1 - dist/playwright/run-once.d.ts | 11 - dist/playwright/run-once.d.ts.map | 1 - dist/playwright/run-once.js | 40 -- dist/playwright/teardown-namespaces.d.ts | 11 - dist/playwright/teardown-namespaces.d.ts.map | 1 - dist/playwright/teardown-namespaces.js | 34 - dist/playwright/teardown-reporter.d.ts | 29 - dist/playwright/teardown-reporter.d.ts.map | 1 - dist/playwright/teardown-reporter.js | 105 --- dist/utils/bash.d.ts | 9 - dist/utils/bash.d.ts.map | 1 - dist/utils/bash.js | 25 - dist/utils/common.d.ts | 4 - dist/utils/common.d.ts.map | 1 - dist/utils/common.js | 16 - dist/utils/index.d.ts | 6 - dist/utils/index.d.ts.map | 1 - dist/utils/index.js | 5 - dist/utils/kubernetes-client.d.ts | 106 --- dist/utils/kubernetes-client.d.ts.map | 1 - dist/utils/kubernetes-client.js | 623 ------------------ dist/utils/merge-yamls.d.ts | 53 -- dist/utils/merge-yamls.d.ts.map | 1 - dist/utils/merge-yamls.js | 107 --- dist/utils/merge-yamls.test.d.ts | 2 - dist/utils/merge-yamls.test.d.ts.map | 1 - dist/utils/merge-yamls.test.js | 54 -- dist/utils/plugin-metadata.d.ts | 96 --- dist/utils/plugin-metadata.d.ts.map | 1 - dist/utils/plugin-metadata.js | 364 ---------- dist/utils/tests/helpers.d.ts | 26 - dist/utils/tests/helpers.d.ts.map | 1 - dist/utils/tests/helpers.js | 84 --- .../tests/plugin-metadata.fixtures.test.d.ts | 2 - .../plugin-metadata.fixtures.test.d.ts.map | 1 - .../tests/plugin-metadata.fixtures.test.js | 563 ---------------- .../tests/plugin-metadata.nightly.test.d.ts | 2 - .../plugin-metadata.nightly.test.d.ts.map | 1 - .../tests/plugin-metadata.nightly.test.js | 206 ------ dist/utils/tests/plugin-metadata.pr.test.d.ts | 2 - .../tests/plugin-metadata.pr.test.d.ts.map | 1 - dist/utils/tests/plugin-metadata.pr.test.js | 351 ---------- dist/utils/tests/plugin-metadata.test.d.ts | 2 - .../utils/tests/plugin-metadata.test.d.ts.map | 1 - dist/utils/tests/plugin-metadata.test.js | 156 ----- dist/utils/vault.d.ts | 16 - dist/utils/vault.d.ts.map | 1 - dist/utils/vault.js | 94 --- dist/utils/workspace-paths.d.ts | 43 -- dist/utils/workspace-paths.d.ts.map | 1 - dist/utils/workspace-paths.js | 65 -- 163 files changed, 8386 deletions(-) delete mode 100644 dist/deployment/keycloak/config/keycloak-values.yaml delete mode 100644 dist/deployment/keycloak/constants.d.ts delete mode 100644 dist/deployment/keycloak/constants.d.ts.map delete mode 100644 dist/deployment/keycloak/constants.js delete mode 100644 dist/deployment/keycloak/deployment.d.ts delete mode 100644 dist/deployment/keycloak/deployment.d.ts.map delete mode 100644 dist/deployment/keycloak/deployment.js delete mode 100644 dist/deployment/keycloak/index.d.ts delete mode 100644 dist/deployment/keycloak/index.d.ts.map delete mode 100644 dist/deployment/keycloak/index.js delete mode 100644 dist/deployment/keycloak/types.d.ts delete mode 100644 dist/deployment/keycloak/types.d.ts.map delete mode 100644 dist/deployment/keycloak/types.js delete mode 100644 dist/deployment/orchestrator/index.d.ts delete mode 100644 dist/deployment/orchestrator/index.d.ts.map delete mode 100644 dist/deployment/orchestrator/index.js delete mode 100755 dist/deployment/orchestrator/install-orchestrator.sh delete mode 100644 dist/deployment/rhdh/config/auth/github/app-config.yaml delete mode 100644 dist/deployment/rhdh/config/auth/github/secrets.yaml delete mode 100644 dist/deployment/rhdh/config/auth/guest/app-config.yaml delete mode 100644 dist/deployment/rhdh/config/auth/keycloak/app-config.yaml delete mode 100644 dist/deployment/rhdh/config/auth/keycloak/dynamic-plugins.yaml delete mode 100644 dist/deployment/rhdh/config/auth/keycloak/secrets.yaml delete mode 100644 dist/deployment/rhdh/config/common/app-config-rhdh.yaml delete mode 100644 dist/deployment/rhdh/config/common/dynamic-plugins.yaml delete mode 100644 dist/deployment/rhdh/config/common/rhdh-secrets.yaml delete mode 100644 dist/deployment/rhdh/config/helm/value_file.yaml delete mode 100644 dist/deployment/rhdh/config/operator/subscription.yaml delete mode 100644 dist/deployment/rhdh/constants.d.ts delete mode 100644 dist/deployment/rhdh/constants.d.ts.map delete mode 100644 dist/deployment/rhdh/constants.js delete mode 100644 dist/deployment/rhdh/deployment.d.ts delete mode 100644 dist/deployment/rhdh/deployment.d.ts.map delete mode 100644 dist/deployment/rhdh/deployment.js delete mode 100644 dist/deployment/rhdh/deployment.test.d.ts delete mode 100644 dist/deployment/rhdh/deployment.test.d.ts.map delete mode 100644 dist/deployment/rhdh/deployment.test.js delete mode 100644 dist/deployment/rhdh/index.d.ts delete mode 100644 dist/deployment/rhdh/index.d.ts.map delete mode 100644 dist/deployment/rhdh/index.js delete mode 100644 dist/deployment/rhdh/types.d.ts delete mode 100644 dist/deployment/rhdh/types.d.ts.map delete mode 100644 dist/deployment/rhdh/types.js delete mode 100644 dist/eslint/base.config.d.ts delete mode 100644 dist/eslint/base.config.d.ts.map delete mode 100644 dist/eslint/base.config.js delete mode 100644 dist/playwright/base-config.d.ts delete mode 100644 dist/playwright/base-config.d.ts.map delete mode 100644 dist/playwright/base-config.js delete mode 100644 dist/playwright/fixtures/test.d.ts delete mode 100644 dist/playwright/fixtures/test.d.ts.map delete mode 100644 dist/playwright/fixtures/test.js delete mode 100644 dist/playwright/global-setup.d.ts delete mode 100644 dist/playwright/global-setup.d.ts.map delete mode 100644 dist/playwright/global-setup.js delete mode 100644 dist/playwright/helpers/accessibility.d.ts delete mode 100644 dist/playwright/helpers/accessibility.d.ts.map delete mode 100644 dist/playwright/helpers/accessibility.js delete mode 100644 dist/playwright/helpers/api-endpoints.d.ts delete mode 100644 dist/playwright/helpers/api-endpoints.d.ts.map delete mode 100644 dist/playwright/helpers/api-endpoints.js delete mode 100644 dist/playwright/helpers/api-helper.d.ts delete mode 100644 dist/playwright/helpers/api-helper.d.ts.map delete mode 100644 dist/playwright/helpers/api-helper.js delete mode 100644 dist/playwright/helpers/auth-api-helper.d.ts delete mode 100644 dist/playwright/helpers/auth-api-helper.d.ts.map delete mode 100644 dist/playwright/helpers/auth-api-helper.js delete mode 100644 dist/playwright/helpers/common.d.ts delete mode 100644 dist/playwright/helpers/common.d.ts.map delete mode 100644 dist/playwright/helpers/common.js delete mode 100644 dist/playwright/helpers/index.d.ts delete mode 100644 dist/playwright/helpers/index.d.ts.map delete mode 100644 dist/playwright/helpers/index.js delete mode 100644 dist/playwright/helpers/navbar.d.ts delete mode 100644 dist/playwright/helpers/navbar.d.ts.map delete mode 100644 dist/playwright/helpers/navbar.js delete mode 100644 dist/playwright/helpers/rbac-api-helper.d.ts delete mode 100644 dist/playwright/helpers/rbac-api-helper.d.ts.map delete mode 100644 dist/playwright/helpers/rbac-api-helper.js delete mode 100644 dist/playwright/helpers/ui-helper.d.ts delete mode 100644 dist/playwright/helpers/ui-helper.d.ts.map delete mode 100644 dist/playwright/helpers/ui-helper.js delete mode 100644 dist/playwright/page-objects/global-obj.d.ts delete mode 100644 dist/playwright/page-objects/global-obj.d.ts.map delete mode 100644 dist/playwright/page-objects/global-obj.js delete mode 100644 dist/playwright/page-objects/page-obj.d.ts delete mode 100644 dist/playwright/page-objects/page-obj.d.ts.map delete mode 100644 dist/playwright/page-objects/page-obj.js delete mode 100644 dist/playwright/pages/catalog-import.d.ts delete mode 100644 dist/playwright/pages/catalog-import.d.ts.map delete mode 100644 dist/playwright/pages/catalog-import.js delete mode 100644 dist/playwright/pages/catalog.d.ts delete mode 100644 dist/playwright/pages/catalog.d.ts.map delete mode 100644 dist/playwright/pages/catalog.js delete mode 100644 dist/playwright/pages/extensions.d.ts delete mode 100644 dist/playwright/pages/extensions.d.ts.map delete mode 100644 dist/playwright/pages/extensions.js delete mode 100644 dist/playwright/pages/home-page.d.ts delete mode 100644 dist/playwright/pages/home-page.d.ts.map delete mode 100644 dist/playwright/pages/home-page.js delete mode 100644 dist/playwright/pages/index.d.ts delete mode 100644 dist/playwright/pages/index.d.ts.map delete mode 100644 dist/playwright/pages/index.js delete mode 100644 dist/playwright/pages/notifications.d.ts delete mode 100644 dist/playwright/pages/notifications.d.ts.map delete mode 100644 dist/playwright/pages/notifications.js delete mode 100644 dist/playwright/pages/orchestrator.d.ts delete mode 100644 dist/playwright/pages/orchestrator.d.ts.map delete mode 100644 dist/playwright/pages/orchestrator.js delete mode 100644 dist/playwright/pages/workflows.d.ts delete mode 100644 dist/playwright/pages/workflows.d.ts.map delete mode 100644 dist/playwright/pages/workflows.js delete mode 100644 dist/playwright/run-once.d.ts delete mode 100644 dist/playwright/run-once.d.ts.map delete mode 100644 dist/playwright/run-once.js delete mode 100644 dist/playwright/teardown-namespaces.d.ts delete mode 100644 dist/playwright/teardown-namespaces.d.ts.map delete mode 100644 dist/playwright/teardown-namespaces.js delete mode 100644 dist/playwright/teardown-reporter.d.ts delete mode 100644 dist/playwright/teardown-reporter.d.ts.map delete mode 100644 dist/playwright/teardown-reporter.js delete mode 100644 dist/utils/bash.d.ts delete mode 100644 dist/utils/bash.d.ts.map delete mode 100644 dist/utils/bash.js delete mode 100644 dist/utils/common.d.ts delete mode 100644 dist/utils/common.d.ts.map delete mode 100644 dist/utils/common.js delete mode 100644 dist/utils/index.d.ts delete mode 100644 dist/utils/index.d.ts.map delete mode 100644 dist/utils/index.js delete mode 100644 dist/utils/kubernetes-client.d.ts delete mode 100644 dist/utils/kubernetes-client.d.ts.map delete mode 100644 dist/utils/kubernetes-client.js delete mode 100644 dist/utils/merge-yamls.d.ts delete mode 100644 dist/utils/merge-yamls.d.ts.map delete mode 100644 dist/utils/merge-yamls.js delete mode 100644 dist/utils/merge-yamls.test.d.ts delete mode 100644 dist/utils/merge-yamls.test.d.ts.map delete mode 100644 dist/utils/merge-yamls.test.js delete mode 100644 dist/utils/plugin-metadata.d.ts delete mode 100644 dist/utils/plugin-metadata.d.ts.map delete mode 100644 dist/utils/plugin-metadata.js delete mode 100644 dist/utils/tests/helpers.d.ts delete mode 100644 dist/utils/tests/helpers.d.ts.map delete mode 100644 dist/utils/tests/helpers.js delete mode 100644 dist/utils/tests/plugin-metadata.fixtures.test.d.ts delete mode 100644 dist/utils/tests/plugin-metadata.fixtures.test.d.ts.map delete mode 100644 dist/utils/tests/plugin-metadata.fixtures.test.js delete mode 100644 dist/utils/tests/plugin-metadata.nightly.test.d.ts delete mode 100644 dist/utils/tests/plugin-metadata.nightly.test.d.ts.map delete mode 100644 dist/utils/tests/plugin-metadata.nightly.test.js delete mode 100644 dist/utils/tests/plugin-metadata.pr.test.d.ts delete mode 100644 dist/utils/tests/plugin-metadata.pr.test.d.ts.map delete mode 100644 dist/utils/tests/plugin-metadata.pr.test.js delete mode 100644 dist/utils/tests/plugin-metadata.test.d.ts delete mode 100644 dist/utils/tests/plugin-metadata.test.d.ts.map delete mode 100644 dist/utils/tests/plugin-metadata.test.js delete mode 100644 dist/utils/vault.d.ts delete mode 100644 dist/utils/vault.d.ts.map delete mode 100644 dist/utils/vault.js delete mode 100644 dist/utils/workspace-paths.d.ts delete mode 100644 dist/utils/workspace-paths.d.ts.map delete mode 100644 dist/utils/workspace-paths.js diff --git a/dist/deployment/keycloak/config/keycloak-values.yaml b/dist/deployment/keycloak/config/keycloak-values.yaml deleted file mode 100644 index 36229a7..0000000 --- a/dist/deployment/keycloak/config/keycloak-values.yaml +++ /dev/null @@ -1,96 +0,0 @@ -global: - security: - allowInsecureImages: true - -replicaCount: 1 - -# Use Bitnami legacy repository (Bitnami images moved to bitnamilegacy as of Aug 2025) -# Note: Legacy images are not updated/maintained. Consider migrating to official Keycloak image for long-term. -image: - registry: docker.io - repository: bitnamilegacy/keycloak - tag: "26.3.3-debian-12-r0" - pullPolicy: IfNotPresent - -auth: - adminUser: admin - adminPassword: admin123 - -service: - type: ClusterIP - port: 8080 - -# OpenShift Route configuration -route: - enabled: true - host: "" # Will be auto-generated by OpenShift - tls: - enabled: false - -ingress: - enabled: false - -postgresql: - enabled: true - image: - registry: docker.io - repository: bitnamilegacy/postgresql - tag: "17.6.0-debian-12-r4" - pullPolicy: IfNotPresent - auth: - postgresPassword: postgres123 - username: keycloak - password: keycloak123 - database: keycloak - primary: - resources: - limits: - cpu: 1000m - memory: 1Gi - requests: - cpu: 100m - memory: 256Mi - persistence: - enabled: true - size: 1Gi - -resources: - limits: - cpu: 1000m - memory: 1Gi - requests: - cpu: 100m - memory: 256Mi - -extraEnvVars: - - name: KEYCLOAK_ADMIN - value: admin - - name: KEYCLOAK_ADMIN_PASSWORD - value: admin123 - - name: KC_HOSTNAME_STRICT - value: "false" - - name: KC_HOSTNAME_STRICT_HTTPS - value: "false" - - name: KC_HTTP_ENABLED - value: "true" - - name: KC_PROXY - value: "edge" - - name: JAVA_OPTS_APPEND - value: "-Djava.net.preferIPv4Stack=true -Xms256m -Xmx512m" - -# Increase probe timeouts for slower startup on resource-constrained clusters -livenessProbe: - enabled: true - initialDelaySeconds: 120 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 6 - successThreshold: 1 - -readinessProbe: - enabled: true - initialDelaySeconds: 60 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 6 - successThreshold: 1 diff --git a/dist/deployment/keycloak/constants.d.ts b/dist/deployment/keycloak/constants.d.ts deleted file mode 100644 index 3a27dbe..0000000 --- a/dist/deployment/keycloak/constants.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { KeycloakClientConfig } from "./types.js"; -export declare const DEFAULT_KEYCLOAK_CONFIG: { - namespace: string; - releaseName: string; - adminUser: string; - adminPassword: string; - realm: string; -}; -export declare const DEFAULT_CONFIG_PATHS: { - valuesFile: string; -}; -export declare const BITNAMI_CHART_REPO = "https://charts.bitnami.com/bitnami"; -export declare const BITNAMI_CHART_NAME = "bitnami/keycloak"; -export declare const DEFAULT_RHDH_CLIENT: KeycloakClientConfig; -export declare const DEFAULT_GROUPS: { - name: string; -}[]; -export declare const DEFAULT_USERS: { - username: string; - email: string; - firstName: string; - lastName: string; - enabled: boolean; - emailVerified: boolean; - password: string; - groups: string[]; -}[]; -export declare const SERVICE_ACCOUNT_ROLES: string[]; -//# sourceMappingURL=constants.d.ts.map \ No newline at end of file diff --git a/dist/deployment/keycloak/constants.d.ts.map b/dist/deployment/keycloak/constants.d.ts.map deleted file mode 100644 index 8fb628d..0000000 --- a/dist/deployment/keycloak/constants.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../src/deployment/keycloak/constants.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAKvD,eAAO,MAAM,uBAAuB;;;;;;CAMnC,CAAC;AAEF,eAAO,MAAM,oBAAoB;;CAKhC,CAAC;AAEF,eAAO,MAAM,kBAAkB,uCAAuC,CAAC;AACvE,eAAO,MAAM,kBAAkB,qBAAqB,CAAC;AAErD,eAAO,MAAM,mBAAmB,EAAE,oBA0BjC,CAAC;AAEF,eAAO,MAAM,cAAc;;GAI1B,CAAC;AAEF,eAAO,MAAM,aAAa;;;;;;;;;GAqBzB,CAAC;AAGF,eAAO,MAAM,qBAAqB,UAIjC,CAAC"} \ No newline at end of file diff --git a/dist/deployment/keycloak/constants.js b/dist/deployment/keycloak/constants.js deleted file mode 100644 index 5621a75..0000000 --- a/dist/deployment/keycloak/constants.js +++ /dev/null @@ -1,75 +0,0 @@ -import path from "path"; -// Navigate from dist/deployment/keycloak/ to package root -const PACKAGE_ROOT = path.resolve(import.meta.dirname, "../../.."); -export const DEFAULT_KEYCLOAK_CONFIG = { - namespace: "rhdh-keycloak", - releaseName: "keycloak", - adminUser: "admin", - adminPassword: "admin123", - realm: "rhdh", -}; -export const DEFAULT_CONFIG_PATHS = { - valuesFile: path.join(PACKAGE_ROOT, "dist/deployment/keycloak/config/keycloak-values.yaml"), -}; -export const BITNAMI_CHART_REPO = "https://charts.bitnami.com/bitnami"; -export const BITNAMI_CHART_NAME = "bitnami/keycloak"; -export const DEFAULT_RHDH_CLIENT = { - clientId: "rhdh-client", - clientSecret: "rhdh-client-secret", - name: "RHDH Client", - redirectUris: ["*"], - webOrigins: ["*"], - standardFlowEnabled: true, - implicitFlowEnabled: true, - directAccessGrantsEnabled: true, - serviceAccountsEnabled: true, - authorizationServicesEnabled: true, - publicClient: false, - defaultClientScopes: [ - "service_account", - "web-origins", - "roles", - "profile", - "basic", - "email", - ], - optionalClientScopes: [ - "address", - "phone", - "offline_access", - "microprofile-jwt", - ], -}; -export const DEFAULT_GROUPS = [ - { name: "developers" }, - { name: "admins" }, - { name: "viewers" }, -]; -export const DEFAULT_USERS = [ - { - username: "test1", - email: "test1@example.com", - firstName: "Test", - lastName: "User1", - enabled: true, - emailVerified: true, - password: "test1@123", - groups: ["developers"], - }, - { - username: "test2", - email: "test2@example.com", - firstName: "Test", - lastName: "User2", - enabled: true, - emailVerified: true, - password: "test2@123", - groups: ["developers"], - }, -]; -// Service account roles required for RHDH integration -export const SERVICE_ACCOUNT_ROLES = [ - "view-authorization", - "manage-authorization", - "view-users", -]; diff --git a/dist/deployment/keycloak/deployment.d.ts b/dist/deployment/keycloak/deployment.d.ts deleted file mode 100644 index a8f0b76..0000000 --- a/dist/deployment/keycloak/deployment.d.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { KubernetesClientHelper } from "../../utils/kubernetes-client.js"; -import type { KeycloakDeploymentOptions, KeycloakDeploymentConfig, KeycloakClientConfig, KeycloakUserConfig, KeycloakGroupConfig, KeycloakRealmConfig, KeycloakConnectionConfig } from "./types.js"; -export declare class KeycloakHelper { - k8sClient: KubernetesClientHelper; - deploymentConfig: KeycloakDeploymentConfig; - keycloakUrl: string; - realm: string; - clientId: string; - clientSecret: string; - private _adminClient; - constructor(options?: KeycloakDeploymentOptions); - /** - * Deploy Keycloak using Helm and configure it for RHDH. - */ - deploy(): Promise; - /** - * Check if Keycloak is already running - */ - isRunning(): Promise; - /** - * Configure Keycloak with realm, client, groups, and users for RHDH - */ - configureForRHDH(options?: { - realm?: string; - client?: Partial; - groups?: KeycloakGroupConfig[]; - users?: KeycloakUserConfig[]; - }): Promise; - /** - * Connect to an existing Keycloak instance - */ - connect(config: KeycloakConnectionConfig): Promise; - /** - * Create a new realm - */ - createRealm(config: KeycloakRealmConfig): Promise; - /** - * Create a new client in a realm - */ - createClient(realm: string, config: KeycloakClientConfig): Promise; - /** - * Create a group in a realm - */ - createGroup(realm: string, config: KeycloakGroupConfig): Promise; - /** - * Create a user in a realm with optional group membership - */ - createUser(realm: string, config: KeycloakUserConfig): Promise; - /** - * Create users and groups in a realm. - */ - createUsersAndGroups(realm: string, options: { - users?: KeycloakUserConfig[]; - groups?: KeycloakGroupConfig[]; - }): Promise; - /** - * Get all users in a realm - */ - getUsers(realm: string): Promise; - /** - * Get all groups in a realm - */ - getGroups(realm: string): Promise; - /** - * Get groups for a user in a realm (user resolved by username). - */ - getGroupsOfUser(realm: string, username: string): Promise; - /** - * Delete a user from a realm - */ - deleteUser(realm: string, username: string): Promise; - /** - * Delete a group from a realm - */ - deleteGroup(realm: string, groupName: string): Promise; - /** - * Delete users and groups from a realm. - */ - deleteUsersAndGroups(realm: string, options: { - users?: Array; - groups?: Array; - }): Promise; - /** - * Delete a realm - */ - deleteRealm(realm: string): Promise; - /** - * Teardown Keycloak deployment - */ - teardown(): Promise; - /** - * Wait for Keycloak to be ready - */ - waitUntilReady(timeout?: number): Promise; - private _buildDeploymentConfig; - private _deployWithHelm; - private _createRoute; - getRouteLocation(): Promise; - private _waitForKeycloak; - private _initializeAdminClient; - private _ensureAdminClient; - private _assignServiceAccountRoles; - private _addUserToGroup; - private _isConflictError; - private _log; -} -//# sourceMappingURL=deployment.d.ts.map \ No newline at end of file diff --git a/dist/deployment/keycloak/deployment.d.ts.map b/dist/deployment/keycloak/deployment.d.ts.map deleted file mode 100644 index 1330d19..0000000 --- a/dist/deployment/keycloak/deployment.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"deployment.d.ts","sourceRoot":"","sources":["../../../src/deployment/keycloak/deployment.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,sBAAsB,EAAE,MAAM,kCAAkC,CAAC;AAY1E,OAAO,KAAK,EACV,yBAAyB,EACzB,wBAAwB,EACxB,oBAAoB,EACpB,kBAAkB,EAClB,mBAAmB,EACnB,mBAAmB,EACnB,wBAAwB,EACzB,MAAM,YAAY,CAAC;AAEpB,qBAAa,cAAc;IAClB,SAAS,yBAAgC;IACzC,gBAAgB,EAAE,wBAAwB,CAAC;IAC3C,WAAW,EAAE,MAAM,CAAM;IACzB,KAAK,EAAE,MAAM,CAAM;IACnB,QAAQ,EAAE,MAAM,CAAM;IACtB,YAAY,EAAE,MAAM,CAAM;IACjC,OAAO,CAAC,YAAY,CAAoC;gBAE5C,OAAO,GAAE,yBAA8B;IAInD;;OAEG;IACG,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAa7B;;OAEG;IACG,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;IAUnC;;OAEG;IACG,gBAAgB,CAAC,OAAO,CAAC,EAAE;QAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,OAAO,CAAC,oBAAoB,CAAC,CAAC;QACvC,MAAM,CAAC,EAAE,mBAAmB,EAAE,CAAC;QAC/B,KAAK,CAAC,EAAE,kBAAkB,EAAE,CAAC;KAC9B,GAAG,OAAO,CAAC,IAAI,CAAC;IAsCjB;;OAEG;IACG,OAAO,CAAC,MAAM,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC;IAuB9D;;OAEG;IACG,WAAW,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAmB7D;;OAEG;IACG,YAAY,CAChB,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,oBAAoB,GAC3B,OAAO,CAAC,IAAI,CAAC;IAqChB;;OAEG;IACG,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAkB5E;;OAEG;IACG,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC;IA8C1E;;OAEG;IACG,oBAAoB,CACxB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE;QACP,KAAK,CAAC,EAAE,kBAAkB,EAAE,CAAC;QAC7B,MAAM,CAAC,EAAE,mBAAmB,EAAE,CAAC;KAChC,GACA,OAAO,CAAC,IAAI,CAAC;IAahB;;OAEG;IACG,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC;IAe5D;;OAEG;IACG,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAQ9D;;OAEG;IACG,eAAe,CACnB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAejC;;OAEG;IACG,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBhE;;OAEG;IACG,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBlE;;OAEG;IACG,oBAAoB,CACxB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE;QACP,KAAK,CAAC,EAAE,KAAK,CAAC,kBAAkB,GAAG,MAAM,CAAC,CAAC;QAC3C,MAAM,CAAC,EAAE,KAAK,CAAC,mBAAmB,GAAG,MAAM,CAAC,CAAC;KAC9C,GACA,OAAO,CAAC,IAAI,CAAC;IAkBhB;;OAEG;IACG,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAW/C;;OAEG;IACG,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAK/B;;OAEG;IACG,cAAc,CAAC,OAAO,GAAE,MAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAY1D,OAAO,CAAC,sBAAsB;YAahB,eAAe;YAWf,YAAY;IAwBpB,gBAAgB,IAAI,OAAO,CAAC,MAAM,CAAC;YAO3B,gBAAgB;YAoBhB,sBAAsB;YActB,kBAAkB;YAQlB,0BAA0B;YAmD1B,eAAe;IAqB7B,OAAO,CAAC,gBAAgB;IAKxB,OAAO,CAAC,IAAI;CAGb"} \ No newline at end of file diff --git a/dist/deployment/keycloak/deployment.js b/dist/deployment/keycloak/deployment.js deleted file mode 100644 index 70b6bb2..0000000 --- a/dist/deployment/keycloak/deployment.js +++ /dev/null @@ -1,485 +0,0 @@ -import KeycloakAdminClient from "@keycloak/keycloak-admin-client"; -import { KubernetesClientHelper } from "../../utils/kubernetes-client.js"; -import { $, runQuietUnlessFailure } from "../../utils/bash.js"; -import { DEFAULT_KEYCLOAK_CONFIG, BITNAMI_CHART_REPO, BITNAMI_CHART_NAME, DEFAULT_CONFIG_PATHS, DEFAULT_RHDH_CLIENT, SERVICE_ACCOUNT_ROLES, DEFAULT_USERS, DEFAULT_GROUPS, } from "./constants.js"; -export class KeycloakHelper { - k8sClient = new KubernetesClientHelper(); - deploymentConfig; - keycloakUrl = ""; - realm = ""; - clientId = ""; - clientSecret = ""; - _adminClient = null; - constructor(options = {}) { - this.deploymentConfig = this._buildDeploymentConfig(options); - } - /** - * Deploy Keycloak using Helm and configure it for RHDH. - */ - async deploy() { - this._log("Starting Keycloak deployment..."); - await this.k8sClient.createNamespaceIfNotExists(this.deploymentConfig.namespace); - await this._deployWithHelm(); - await this._createRoute(); - await this._waitForKeycloak(); - await this._initializeAdminClient(); - } - /** - * Check if Keycloak is already running - */ - async isRunning() { - try { - this.keycloakUrl = await this.getRouteLocation(); - const response = await fetch(`${this.keycloakUrl}/realms/master`); - return response.ok; - } - catch { - return false; - } - } - /** - * Configure Keycloak with realm, client, groups, and users for RHDH - */ - async configureForRHDH(options) { - this._log("Configuring Keycloak for RHDH..."); - await this._ensureAdminClient(); - const realmName = options?.realm ?? DEFAULT_KEYCLOAK_CONFIG.realm; - // Create realm - await this.createRealm({ realm: realmName, enabled: true }); - // Create client - const clientConfig = { - ...DEFAULT_RHDH_CLIENT, - ...options?.client, - }; - await this.createClient(realmName, clientConfig); - // Store realm and client info for external access - this.realm = realmName; - this.clientId = clientConfig.clientId; - this.clientSecret = clientConfig.clientSecret; - // Assign service account roles - await this._assignServiceAccountRoles(realmName, clientConfig.clientId); - // Create groups - const groups = options?.groups ?? DEFAULT_GROUPS; - for (const group of groups) { - await this.createGroup(realmName, group); - } - // Create users - const users = options?.users ?? DEFAULT_USERS; - for (const user of users) { - await this.createUser(realmName, user); - } - } - /** - * Connect to an existing Keycloak instance - */ - async connect(config) { - this.keycloakUrl = config.baseUrl; - this._adminClient = new KeycloakAdminClient({ - baseUrl: config.baseUrl, - realmName: config.realm ?? "master", - }); - if (config.username && config.password) { - await this._adminClient.auth({ - username: config.username, - password: config.password, - grantType: "password", - clientId: config.clientId ?? "admin-cli", - }); - } - else if (config.clientId && config.clientSecret) { - await this._adminClient.auth({ - grantType: "client_credentials", - clientId: config.clientId, - clientSecret: config.clientSecret, - }); - } - } - /** - * Create a new realm - */ - async createRealm(config) { - await this._ensureAdminClient(); - try { - await this._adminClient.realms.create({ - realm: config.realm, - displayName: config.displayName ?? config.realm, - enabled: config.enabled ?? true, - }); - this._log(`Created realm: ${config.realm}`); - } - catch (error) { - if (this._isConflictError(error)) { - this._log(`Realm ${config.realm} already exists`); - } - else { - throw error; - } - } - } - /** - * Create a new client in a realm - */ - async createClient(realm, config) { - await this._ensureAdminClient(); - try { - this._adminClient.setConfig({ realmName: realm }); - await this._adminClient.clients.create({ - clientId: config.clientId, - secret: config.clientSecret, - name: config.name ?? config.clientId, - description: config.description ?? "", - redirectUris: config.redirectUris ?? ["*"], - webOrigins: config.webOrigins ?? ["*"], - standardFlowEnabled: config.standardFlowEnabled ?? true, - implicitFlowEnabled: config.implicitFlowEnabled ?? true, - directAccessGrantsEnabled: config.directAccessGrantsEnabled ?? true, - serviceAccountsEnabled: config.serviceAccountsEnabled ?? true, - authorizationServicesEnabled: config.authorizationServicesEnabled ?? true, - publicClient: config.publicClient ?? false, - enabled: true, - protocol: "openid-connect", - fullScopeAllowed: true, - attributes: config.attributes, - defaultClientScopes: config.defaultClientScopes, - optionalClientScopes: config.optionalClientScopes, - }); - this._log(`Created client: ${config.clientId}`); - } - catch (error) { - if (this._isConflictError(error)) { - this._log(`Client ${config.clientId} already exists`); - } - else { - throw error; - } - } - } - /** - * Create a group in a realm - */ - async createGroup(realm, config) { - await this._ensureAdminClient(); - try { - this._adminClient.setConfig({ realmName: realm }); - await this._adminClient.groups.create({ - name: config.name, - }); - this._log(`Created group: ${config.name}`); - } - catch (error) { - if (this._isConflictError(error)) { - this._log(`Group ${config.name} already exists`); - } - else { - throw error; - } - } - } - /** - * Create a user in a realm with optional group membership - */ - async createUser(realm, config) { - await this._ensureAdminClient(); - try { - this._adminClient.setConfig({ realmName: realm }); - // Create user - const createResponse = await this._adminClient.users.create({ - username: config.username, - email: config.email, - firstName: config.firstName, - lastName: config.lastName, - enabled: config.enabled ?? true, - emailVerified: config.emailVerified ?? true, - }); - this._log(`Created user: ${config.username}`); - const userId = createResponse.id; - // Set password if provided - if (config.password) { - await this._adminClient.users.resetPassword({ - id: userId, - credential: { - type: "password", - value: config.password, - temporary: config.temporary ?? false, - }, - }); - } - // Add to groups if specified - if (config.groups?.length) { - for (const groupName of config.groups) { - await this._addUserToGroup(realm, userId, groupName); - } - } - } - catch (error) { - if (this._isConflictError(error)) { - this._log(`User ${config.username} already exists`); - } - else { - throw error; - } - } - } - /** - * Create users and groups in a realm. - */ - async createUsersAndGroups(realm, options) { - await this._ensureAdminClient(); - const { groups = [], users = [] } = options; - for (const group of groups) { - await this.createGroup(realm, group); - } - for (const user of users) { - await this.createUser(realm, user); - } - } - /** - * Get all users in a realm - */ - async getUsers(realm) { - await this._ensureAdminClient(); - this._adminClient.setConfig({ realmName: realm }); - const users = await this._adminClient.users.find(); - return users.map((u) => ({ - username: u.username, - email: u.email, - firstName: u.firstName, - lastName: u.lastName, - enabled: u.enabled, - emailVerified: u.emailVerified, - })); - } - /** - * Get all groups in a realm - */ - async getGroups(realm) { - await this._ensureAdminClient(); - this._adminClient.setConfig({ realmName: realm }); - const groups = await this._adminClient.groups.find(); - return groups.map((g) => ({ name: g.name })); - } - /** - * Get groups for a user in a realm (user resolved by username). - */ - async getGroupsOfUser(realm, username) { - await this._ensureAdminClient(); - this._adminClient.setConfig({ realmName: realm }); - const users = await this._adminClient.users.find({ username }); - if (users.length === 0) { - return []; - } - const user = users[0]; - const groups = await this._adminClient.users.listGroups({ - id: user.id, - }); - return groups.map((g) => ({ name: g.name })); - } - /** - * Delete a user from a realm - */ - async deleteUser(realm, username) { - if (DEFAULT_USERS.some((u) => u.username === username)) { - throw new Error(`Deleting default Keycloak user "${username}" is not permitted.`); - } - await this._ensureAdminClient(); - this._adminClient.setConfig({ realmName: realm }); - const users = await this._adminClient.users.find({ username }); - if (users.length > 0) { - await this._adminClient.users.del({ id: users[0].id }); - this._log(`Deleted user: ${username}`); - } - } - /** - * Delete a group from a realm - */ - async deleteGroup(realm, groupName) { - if (DEFAULT_GROUPS.some((g) => g.name === groupName)) { - throw new Error(`Deleting default Keycloak group "${groupName}" is not permitted.`); - } - await this._ensureAdminClient(); - this._adminClient.setConfig({ realmName: realm }); - const groups = await this._adminClient.groups.find({ search: groupName }); - const group = groups.find((g) => g.name === groupName); - if (group) { - await this._adminClient.groups.del({ id: group.id }); - this._log(`Deleted group: ${groupName}`); - } - } - /** - * Delete users and groups from a realm. - */ - async deleteUsersAndGroups(realm, options) { - await this._ensureAdminClient(); - const { groups = [], users = [] } = options; - const usernames = users.map((u) => typeof u === "string" ? u : u.username); - const groupNames = groups.map((g) => (typeof g === "string" ? g : g.name)); - for (const username of usernames) { - await this.deleteUser(realm, username); - } - for (const groupName of groupNames) { - await this.deleteGroup(realm, groupName); - } - } - /** - * Delete a realm - */ - async deleteRealm(realm) { - await this._ensureAdminClient(); - try { - await this._adminClient.realms.del({ realm }); - this._log(`Deleted realm: ${realm}`); - } - catch (error) { - this._log(`Failed to delete realm ${realm}: ${error}`); - } - } - /** - * Teardown Keycloak deployment - */ - async teardown() { - await this.k8sClient.deleteNamespace(this.deploymentConfig.namespace); - this._log(`Keycloak deployment torn down`); - } - /** - * Wait for Keycloak to be ready - */ - async waitUntilReady(timeout = 500) { - this._log(`Waiting for Keycloak to be ready...`); - const labelSelector = `app.kubernetes.io/instance=${this.deploymentConfig.releaseName}`; - await this.k8sClient.waitForPodsWithFailureDetection(this.deploymentConfig.namespace, labelSelector, timeout); - } - // Private methods - _buildDeploymentConfig(options) { - return { - namespace: options.namespace ?? DEFAULT_KEYCLOAK_CONFIG.namespace, - releaseName: options.releaseName ?? DEFAULT_KEYCLOAK_CONFIG.releaseName, - valuesFile: options.valuesFile ?? DEFAULT_CONFIG_PATHS.valuesFile, - adminUser: options.adminUser ?? DEFAULT_KEYCLOAK_CONFIG.adminUser, - adminPassword: options.adminPassword ?? DEFAULT_KEYCLOAK_CONFIG.adminPassword, - }; - } - async _deployWithHelm() { - await $ `helm repo add bitnami ${BITNAMI_CHART_REPO} || true`; - await runQuietUnlessFailure `helm repo update`; - await runQuietUnlessFailure `helm upgrade --install ${this.deploymentConfig.releaseName} ${BITNAMI_CHART_NAME} \ - --namespace ${this.deploymentConfig.namespace} \ - --values ${this.deploymentConfig.valuesFile}`; - await this.waitUntilReady(); - } - async _createRoute() { - // Use plain HTTP route (no TLS) for test environments to avoid self-signed certificate issues - const routeManifest = ` -apiVersion: route.openshift.io/v1 -kind: Route -metadata: - name: ${this.deploymentConfig.releaseName} - namespace: ${this.deploymentConfig.namespace} - labels: - app.kubernetes.io/name: keycloak - app.kubernetes.io/instance: ${this.deploymentConfig.releaseName} -spec: - to: - kind: Service - name: ${this.deploymentConfig.releaseName} - weight: 100 - port: - targetPort: http - wildcardPolicy: None -`; - await $ `echo ${routeManifest} | kubectl apply -f -`; - } - async getRouteLocation() { - return await this.k8sClient.getRouteLocation(this.deploymentConfig.namespace, this.deploymentConfig.releaseName); - } - async _waitForKeycloak() { - this._log("Waiting for Keycloak API to be ready..."); - const timeout = 500; - const startTime = Date.now(); - while (true) { - if (await this.isRunning()) { - break; - } - if ((Date.now() - startTime) / 1000 >= timeout) { - throw new Error(`Keycloak API not ready after ${timeout} seconds`); - } - await new Promise((resolve) => setTimeout(resolve, 5000)); - this._log(" Waiting for Keycloak API to be ready..."); - } - } - async _initializeAdminClient() { - this._adminClient = new KeycloakAdminClient({ - baseUrl: this.keycloakUrl, - realmName: "master", - }); - await this._adminClient.auth({ - username: this.deploymentConfig.adminUser, - password: this.deploymentConfig.adminPassword, - grantType: "password", - clientId: "admin-cli", - }); - } - async _ensureAdminClient() { - if (!this._adminClient) { - throw new Error("Admin client not initialized. Call deploy() or connect() first."); - } - } - async _assignServiceAccountRoles(realm, clientId) { - await this._ensureAdminClient(); - this._adminClient.setConfig({ realmName: realm }); - // Get service account user - const clients = await this._adminClient.clients.find({ clientId }); - if (clients.length === 0) { - throw new Error(`Client ${clientId} not found`); - } - const client = clients[0]; - const serviceAccountUser = await this._adminClient.clients.getServiceAccountUser({ - id: client.id, - }); - // Get realm-management client - const realmMgmtClients = await this._adminClient.clients.find({ - clientId: "realm-management", - }); - if (realmMgmtClients.length === 0) { - throw new Error("realm-management client not found"); - } - const realmMgmtClient = realmMgmtClients[0]; - // Get roles - const allRoles = await this._adminClient.clients.listRoles({ - id: realmMgmtClient.id, - }); - const rolesToAssign = allRoles.filter((r) => SERVICE_ACCOUNT_ROLES.includes(r.name)); - if (rolesToAssign.length > 0) { - await this._adminClient.users.addClientRoleMappings({ - id: serviceAccountUser.id, - clientUniqueId: realmMgmtClient.id, - roles: rolesToAssign.map((r) => ({ - id: r.id, - name: r.name, - })), - }); - this._log(`Assigned service account roles: ${rolesToAssign.map((r) => r.name).join(", ")}`); - } - } - async _addUserToGroup(realm, userId, groupName) { - this._adminClient.setConfig({ realmName: realm }); - const groups = await this._adminClient.groups.find({ search: groupName }); - const group = groups.find((g) => g.name === groupName); - if (group) { - await this._adminClient.users.addToGroup({ - id: userId, - groupId: group.id, - }); - this._log(` Added user to group: ${groupName}`); - } - else { - this._log(` Warning: Group ${groupName} not found`); - } - } - _isConflictError(error) { - const err = error; - return err.response?.status === 409 || err.status === 409; - } - _log(...args) { - console.log("[Keycloak]", ...args); - } -} diff --git a/dist/deployment/keycloak/index.d.ts b/dist/deployment/keycloak/index.d.ts deleted file mode 100644 index f3018ab..0000000 --- a/dist/deployment/keycloak/index.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { KeycloakHelper } from "./deployment.js"; -export type { KeycloakUserConfig, KeycloakGroupConfig } from "./types.js"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/deployment/keycloak/index.d.ts.map b/dist/deployment/keycloak/index.d.ts.map deleted file mode 100644 index 42c2c71..0000000 --- a/dist/deployment/keycloak/index.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/deployment/keycloak/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,YAAY,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC"} \ No newline at end of file diff --git a/dist/deployment/keycloak/index.js b/dist/deployment/keycloak/index.js deleted file mode 100644 index 93ede1f..0000000 --- a/dist/deployment/keycloak/index.js +++ /dev/null @@ -1 +0,0 @@ -export { KeycloakHelper } from "./deployment.js"; diff --git a/dist/deployment/keycloak/types.d.ts b/dist/deployment/keycloak/types.d.ts deleted file mode 100644 index 59d5946..0000000 --- a/dist/deployment/keycloak/types.d.ts +++ /dev/null @@ -1,59 +0,0 @@ -export type KeycloakDeploymentOptions = { - namespace?: string; - releaseName?: string; - valuesFile?: string; - adminUser?: string; - adminPassword?: string; -}; -export type KeycloakDeploymentConfig = { - namespace: string; - releaseName: string; - valuesFile: string; - adminUser: string; - adminPassword: string; -}; -export type KeycloakClientConfig = { - clientId: string; - clientSecret: string; - name?: string; - description?: string; - redirectUris?: string[]; - webOrigins?: string[]; - standardFlowEnabled?: boolean; - implicitFlowEnabled?: boolean; - directAccessGrantsEnabled?: boolean; - serviceAccountsEnabled?: boolean; - authorizationServicesEnabled?: boolean; - publicClient?: boolean; - attributes?: Record; - defaultClientScopes?: string[]; - optionalClientScopes?: string[]; -}; -export type KeycloakUserConfig = { - username: string; - email?: string; - firstName?: string; - lastName?: string; - enabled?: boolean; - emailVerified?: boolean; - password?: string; - temporary?: boolean; - groups?: string[]; -}; -export type KeycloakGroupConfig = { - name: string; -}; -export type KeycloakRealmConfig = { - realm: string; - displayName?: string; - enabled?: boolean; -}; -export type KeycloakConnectionConfig = { - baseUrl: string; - realm?: string; - clientId?: string; - clientSecret?: string; - username?: string; - password?: string; -}; -//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/dist/deployment/keycloak/types.d.ts.map b/dist/deployment/keycloak/types.d.ts.map deleted file mode 100644 index a9f5b57..0000000 --- a/dist/deployment/keycloak/types.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/deployment/keycloak/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,yBAAyB,GAAG;IACtC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,yBAAyB,CAAC,EAAE,OAAO,CAAC;IACpC,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,4BAA4B,CAAC,EAAE,OAAO,CAAC;IACvC,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC"} \ No newline at end of file diff --git a/dist/deployment/keycloak/types.js b/dist/deployment/keycloak/types.js deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/deployment/keycloak/types.js +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/deployment/orchestrator/index.d.ts b/dist/deployment/orchestrator/index.d.ts deleted file mode 100644 index 4a9e4c1..0000000 --- a/dist/deployment/orchestrator/index.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export declare function installOrchestrator(namespace?: string): Promise; -export default installOrchestrator; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/deployment/orchestrator/index.d.ts.map b/dist/deployment/orchestrator/index.d.ts.map deleted file mode 100644 index ceafb13..0000000 --- a/dist/deployment/orchestrator/index.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/deployment/orchestrator/index.ts"],"names":[],"mappings":"AAKA,wBAAsB,mBAAmB,CAAC,SAAS,SAAiB,iBAEnE;AAED,eAAe,mBAAmB,CAAC"} \ No newline at end of file diff --git a/dist/deployment/orchestrator/index.js b/dist/deployment/orchestrator/index.js deleted file mode 100644 index c35150a..0000000 --- a/dist/deployment/orchestrator/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import { resolve } from "path"; -import { $ } from "../../utils/index.js"; -const scriptPath = resolve(import.meta.dirname, "install-orchestrator.sh"); -export async function installOrchestrator(namespace = "orchestrator") { - await $ `bash ${scriptPath} ${namespace}`; -} -export default installOrchestrator; diff --git a/dist/deployment/orchestrator/install-orchestrator.sh b/dist/deployment/orchestrator/install-orchestrator.sh deleted file mode 100755 index ebf2b57..0000000 --- a/dist/deployment/orchestrator/install-orchestrator.sh +++ /dev/null @@ -1,486 +0,0 @@ -#!/bin/bash -# -# Standalone script to install the orchestrator (Serverless Logic / SonataFlow) -# on OpenShift. -# -# Usage: ./install-orchestrator.sh [namespace] -# Default namespace: orchestrator -# - -set -e - -export NAME_SPACE="${1:-${NAME_SPACE:-orchestrator}}" - -LOWER_CASE_CLASS='[:lower:]' -UPPER_CASE_CLASS='[:upper:]' - -# --------------------------------------------------------------------------- -# Logging -# --------------------------------------------------------------------------- -if [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]]; then - : "${LOG_NO_COLOR:=false}" -else - : "${LOG_NO_COLOR:=true}" -fi -: "${LOG_LEVEL:=INFO}" - -log::timestamp() { - echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" - return 0 -} -log::level_value() { - local input="$1" - local level - level="$(echo "$input" | tr "$LOWER_CASE_CLASS" "$UPPER_CASE_CLASS")" - case "${level}" in DEBUG) echo 0 ;; INFO) echo 1 ;; WARN|WARNING) echo 2 ;; ERROR|ERR) echo 3 ;; *) echo 1 ;; esac - return 0; -} -log::should_log() { - local input requested_level config_level - input="$1" - requested_level="$(echo "$input" | tr "$LOWER_CASE_CLASS" "$UPPER_CASE_CLASS")" - config_level="$(echo "${LOG_LEVEL}" | tr "$LOWER_CASE_CLASS" "$UPPER_CASE_CLASS")" - - [[ "$(log::level_value "${requested_level}")" -ge "$(log::level_value "${config_level}")" ]] - return $? -} -log::reset_code() { - [[ "${LOG_NO_COLOR}" == "true" ]] && printf '' || printf '\033[0m' - return 0; -} -log::color_for_level() { - [[ "${LOG_NO_COLOR}" == "true" ]] && { printf ''; return 0; } - local level input - input="$1" - level="$(echo "$input" | tr "$LOWER_CASE_CLASS" "$UPPER_CASE_CLASS")" - case "${level}" in - DEBUG) printf '\033[36m' ;; INFO) printf '\033[34m' ;; WARN|WARNING) printf '\033[33m' ;; - ERROR|ERR) printf '\033[31m' ;; SUCCESS) printf '\033[32m' ;; SECTION) printf '\033[35m\033[1m' ;; - *) printf '\033[37m' ;; - esac -} -log::icon_for_level() { - local level input - input="$1" - level="$(echo "$input" | tr "$LOWER_CASE_CLASS" "$UPPER_CASE_CLASS")" - case "${level}" in DEBUG) printf '🐞' ;; INFO) printf 'β„Ή' ;; WARN|WARNING) printf '⚠' ;; ERROR|ERR) printf '❌' ;; SUCCESS) printf 'βœ“' ;; *) printf '-' ;; esac - return 0 -} -log::emit_line() { - local level="$1" icon="$2" line="$3" color reset timestamp - log::should_log "${level}" || return 0 - timestamp="$(log::timestamp)" - color="$(log::color_for_level "${level}")" - reset="$(log::reset_code)" - printf '%s[%s] %s %s%s\n' "${color}" "${timestamp}" "${icon}" "${line}" "${reset}" >&2 -} -log::emit() { - local level="$1"; shift - local icon message; icon="$(log::icon_for_level "${level}")"; message="${*:-}" - [[ -z "${message}" ]] && return 0 - while IFS= read -r line; do log::emit_line "${level}" "${icon}" "${line}"; done <<< "${message}" -} -log::debug() { - log::emit "DEBUG" "$@" - return 0 -} -log::info() { - log::emit "INFO" "$@" - return 0 -} -log::warn() { - log::emit "WARN" "$@" - return 0 -} -log::error() { - log::emit "ERROR" "$@" - return 0 -} -log::success() { - log::emit "SUCCESS" "$@" - return 0 -} - -# --------------------------------------------------------------------------- -# Operator subscription and status -# --------------------------------------------------------------------------- -install_subscription() { - local name=$1 namespace=$2 channel=$3 package=$4 source_name=$5 source_namespace=$6 starting_csv=${7:-} - local yaml - yaml="apiVersion: operators.coreos.com/v1alpha1 -kind: Subscription -metadata: - name: $name - namespace: $namespace -spec: - channel: $channel - installPlanApproval: Automatic - name: $package - source: $source_name - sourceNamespace: $source_namespace" - if [[ -n "$starting_csv" ]]; then - yaml+=" - startingCSV: $starting_csv" - fi - echo "$yaml" | oc apply -f - - return 0 -} - -# Wait for an operator CSV to reach a status phase. -# Uses OLM label selector (operators.coreos.com/.) which is -# deterministic, unlike spec.displayName which varies across channels/versions. -wait_for_operator() { - local timeout=${1:-300} namespace=$2 package=$3 expected_status=${4:-Succeeded} - local label="operators.coreos.com/${package}.${namespace}" - log::info "Waiting for operator '${package}' in '${namespace}' (label=${label}, timeout ${timeout}s, expected: ${expected_status})" - timeout "${timeout}" bash -c " - while true; do - CURRENT_PHASE=\$(oc get csv -n '${namespace}' -l '${label}' -o jsonpath='{.items[0].status.phase}' 2>/dev/null) - echo \"[wait_for_operator] Phase: \${CURRENT_PHASE}\" >&2 - [[ \"\${CURRENT_PHASE}\" == \"${expected_status}\" ]] && echo \"[wait_for_operator] Operator reached ${expected_status}\" >&2 && break - sleep 10 - done - " || { log::error "Operator '${package}' did not reach ${expected_status} in time."; return 1; } -} - -install_serverless_logic_ocp_operator() { - install_subscription logic-operator openshift-operators stable logic-operator redhat-operators openshift-marketplace logic-operator.v1.37.2 - return 0 -} -waitfor_serverless_logic_ocp_operator() { - wait_for_operator 500 openshift-operators logic-operator Succeeded - return 0 -} - -install_serverless_ocp_operator() { - install_subscription serverless-operator openshift-operators stable serverless-operator redhat-operators openshift-marketplace - return 0 -} -waitfor_serverless_ocp_operator() { - wait_for_operator 300 openshift-operators serverless-operator Succeeded - return 0 -} - -# --------------------------------------------------------------------------- -# Namespace -# --------------------------------------------------------------------------- -force_delete_namespace() { - local project=$1 timeout_seconds=${2:-120} elapsed=0 sleep_interval=2 - log::warn "Force deleting namespace ${project}" - oc get namespace "$project" -o json | jq '.spec = {"finalizers":[]}' | oc replace --raw "/api/v1/namespaces/$project/finalize" -f - - while oc get namespace "$project" &>/dev/null; do - [[ $elapsed -ge $timeout_seconds ]] && { log::warn "Timeout deleting ${project}"; return 1; } - sleep $sleep_interval - elapsed=$((elapsed + sleep_interval)) - done - log::success "Namespace '${project}' deleted." -} - -delete_namespace() { - local project=$1 - if oc get namespace "$project" &>/dev/null; then - log::warn "Deleting namespace ${project}..." - oc delete namespace "$project" --grace-period=0 --force || true - if oc get namespace "$project" -o jsonpath='{.status.phase}' 2>/dev/null | grep -q Terminating; then - force_delete_namespace "$project" - fi - fi - return 0 -} - -configure_namespace() { - local project=$1 - if oc get namespace "$project" &>/dev/null; then - log::info "Namespace ${project} already exists, reusing it." - else - log::info "Creating namespace: ${project}" - oc create namespace "${project}" || { log::error "Failed to create namespace ${project}"; exit 1; } - fi - oc config set-context --current --namespace="${project}" || { log::error "Failed to set context"; exit 1; } - log::info "Namespace ${project} is ready." - return 0 -} - -# --------------------------------------------------------------------------- -# Deployment wait -# --------------------------------------------------------------------------- -wait_for_deployment() { - local namespace=$1 resource_name=$2 timeout_minutes=${3:-5} check_interval=${4:-10} - [[ -z "$namespace" || -z "$resource_name" ]] && { log::error "wait_for_deployment: namespace and resource_name required"; return 1; } - local max_attempts=$((timeout_minutes * 60 / check_interval)) - log::info "Waiting for '$resource_name' in '$namespace' (timeout ${timeout_minutes}m)..." - for ((i = 1; i <= max_attempts; i++)); do - local pod_name - pod_name=$(oc get pods -n "$namespace" 2>/dev/null | grep "$resource_name" | awk '{print $1}' | head -n 1) - if [[ -n "$pod_name" ]]; then - local is_ready - is_ready=$(oc get pod "$pod_name" -n "$namespace" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null) - if [[ "$is_ready" == "True" ]] && oc get pod "$pod_name" -n "$namespace" 2>/dev/null | grep -q Running; then - log::success "Pod '$pod_name' is ready" - return 0 - fi - fi - sleep "$check_interval" - done - log::error "Timeout waiting for $resource_name" - return 1 -} - -# --------------------------------------------------------------------------- -# PostgreSQL (simple deployment for orchestrator) -# --------------------------------------------------------------------------- -create_simple_postgres_deployment() { - local namespace=$1 postgres_name="backstage-psql" - if oc get deployment "$postgres_name" -n "$namespace" &>/dev/null; then - log::info "PostgreSQL '$postgres_name' already exists" - return 0 - fi - log::info "Creating PostgreSQL '$postgres_name' in '$namespace'" - oc create secret generic "${postgres_name}-secret" -n "$namespace" \ - --from-literal=POSTGRESQL_USER=postgres \ - --from-literal=POSTGRESQL_PASSWORD=postgres \ - --from-literal=POSTGRESQL_DATABASE=postgres \ - --from-literal=POSTGRES_USER=postgres \ - --from-literal=POSTGRES_PASSWORD=postgres \ - --from-literal=POSTGRES_DB=postgres \ - --dry-run=client -o yaml | oc apply -f - -n "$namespace" || true - - oc apply -f - -n "$namespace" << EOF -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: ${postgres_name}-pvc - namespace: ${namespace} -spec: - accessModes: [ReadWriteOnce] - resources: { requests: { storage: 1Gi } } -EOF - - oc apply -f - -n "$namespace" << EOF -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: ${postgres_name} - namespace: ${namespace} -spec: - serviceName: ${postgres_name} - replicas: 1 - selector: { matchLabels: { app: ${postgres_name} } } - template: - metadata: { labels: { app: ${postgres_name} } } - spec: - containers: - - name: postgres - image: registry.redhat.io/rhel9/postgresql-15:latest - env: - - name: POSTGRESQL_USER - valueFrom: { secretKeyRef: { name: ${postgres_name}-secret, key: POSTGRESQL_USER } } - - name: POSTGRESQL_PASSWORD - valueFrom: { secretKeyRef: { name: ${postgres_name}-secret, key: POSTGRESQL_PASSWORD } } - - name: POSTGRESQL_DATABASE - valueFrom: { secretKeyRef: { name: ${postgres_name}-secret, key: POSTGRESQL_DATABASE } } - ports: [ { containerPort: 5432, name: postgres } ] - volumeMounts: [ { name: postgres-data, mountPath: /var/lib/pgsql/data } ] - livenessProbe: - exec: { command: [ /usr/libexec/check-container, --live ] } - initialDelaySeconds: 120 - periodSeconds: 10 - readinessProbe: - exec: { command: [ /usr/libexec/check-container ] } - initialDelaySeconds: 5 - periodSeconds: 10 - volumes: [ { name: postgres-data, persistentVolumeClaim: { claimName: ${postgres_name}-pvc } } ] -EOF - - oc apply -f - -n "$namespace" << EOF -apiVersion: v1 -kind: Service -metadata: - name: ${postgres_name} - namespace: ${namespace} -spec: - selector: { app: ${postgres_name} } - ports: [ { name: postgres, port: 5432, targetPort: 5432 } ] - type: ClusterIP -EOF - - log::info "Waiting for PostgreSQL StatefulSet..." - oc wait statefulset "$postgres_name" -n "$namespace" --for=jsonpath='{.status.readyReplicas}'=1 --timeout=300s || true - sleep 5 - oc exec -n "$namespace" statefulset/"$postgres_name" -- psql -U postgres -c "CREATE DATABASE backstage_plugin_orchestrator;" 2>/dev/null || log::warn "Orchestrator DB may already exist" - log::success "PostgreSQL deployment created." -} - -# --------------------------------------------------------------------------- -# SonataFlow platform -# --------------------------------------------------------------------------- -create_sonataflow_platform() { - local namespace=$1 postgres_secret_name=$2 postgres_service_name=$3 - if ! oc get crd sonataflowplatforms.sonataflow.org &>/dev/null && ! oc get crd sonataflowplatform.sonataflow.org &>/dev/null; then - log::error "SonataFlowPlatform CRD not found. Install Serverless Logic Operator first." - return 1 - fi - if oc get sonataflowplatform sonataflow-platform -n "$namespace" &>/dev/null || oc get sfp sonataflow-platform -n "$namespace" &>/dev/null; then - log::info "SonataFlowPlatform already exists" - return 0 - fi - log::info "Creating SonataFlowPlatform in '$namespace'" - oc apply -f - -n "$namespace" << EOF -apiVersion: sonataflow.org/v1alpha08 -kind: SonataFlowPlatform -metadata: - name: sonataflow-platform - namespace: ${namespace} -spec: - services: - dataIndex: - persistence: - postgresql: - secretRef: { name: ${postgres_secret_name}, userKey: POSTGRES_USER, passwordKey: POSTGRES_PASSWORD } - serviceRef: { name: ${postgres_service_name}, namespace: ${namespace}, port: 5432, databaseName: backstage_plugin_orchestrator } - jobService: - persistence: - postgresql: - secretRef: { name: ${postgres_secret_name}, userKey: POSTGRES_USER, passwordKey: POSTGRES_PASSWORD } - serviceRef: { name: ${postgres_service_name}, namespace: ${namespace}, port: 5432, databaseName: backstage_plugin_orchestrator } -EOF - local attempt=0 max_attempts=60 - while [[ $attempt -lt $max_attempts ]]; do - if oc get deployment sonataflow-platform-data-index-service -n "$namespace" &>/dev/null && \ - oc get deployment sonataflow-platform-jobs-service -n "$namespace" &>/dev/null; then - log::success "SonataFlowPlatform services created" - wait_for_deployment "$namespace" sonataflow-platform-data-index-service 20 || true - wait_for_deployment "$namespace" sonataflow-platform-jobs-service 20 || true - log::success "SonataFlowPlatform ready." - return 0 - fi - attempt=$((attempt + 1)) - [[ $((attempt % 10)) -eq 0 ]] && log::info "Waiting for SonataFlowPlatform... ($attempt/$max_attempts)" - sleep 5 - done - log::warn "SonataFlowPlatform services did not appear in time." -} - -# --------------------------------------------------------------------------- -# Orchestrator connection info -# --------------------------------------------------------------------------- -print_orchestrator_connection_info() { - local namespace=$1 - local data_index_service="sonataflow-platform-data-index-service" - local service_url="http://${data_index_service}.${namespace}.svc.cluster.local" - log::info "==========================================" - log::info "Orchestrator Plugin Connection Information" - log::info "==========================================" - log::info "Namespace: ${namespace}" - log::info "Internal URL for Orchestrator Backend Plugin: ${service_url}" - log::info "dynamic-plugins.yaml: pluginConfig.orchestrator.dataIndexService.url: ${service_url}" - if oc get svc "${data_index_service}" -n "${namespace}" &>/dev/null; then - local port; port=$(oc get svc "${data_index_service}" -n "${namespace}" -o jsonpath='{.spec.ports[0].port}' 2>/dev/null || echo "8080") - log::info "Service: ${data_index_service}, port: ${port}" - else - log::warn "Service '${data_index_service}' not found yet." - fi - log::info "==========================================" - return 0 -} - -# --------------------------------------------------------------------------- -# Wait for SonataFlow CRDs -# --------------------------------------------------------------------------- -wait_for_sonataflow_crds() { - log::info "Waiting for SonataFlow CRDs..." - local attempt=0 max_attempts=60 - while [[ $attempt -lt $max_attempts ]]; do - if oc get crd sonataflows.sonataflow.org &>/dev/null; then - log::success "SonataFlow CRD is available." - return 0 - fi - attempt=$((attempt + 1)) - [[ $((attempt % 6)) -eq 0 ]] && log::info "Waiting for sonataflows.sonataflow.org... ($attempt/$max_attempts)" - sleep 5 - done - log::error "Timed out waiting for SonataFlow CRD." - return 1 -} - -# --------------------------------------------------------------------------- -# Deploy orchestrator workflows (operator path: git clone + helm greeting) -# Uses local yaml/ if present, otherwise clones repo. -# --------------------------------------------------------------------------- -deploy_orchestrator_workflows_operator() { - local namespace=$1 - - # PostgreSQL - if ! oc get statefulset backstage-psql -n "$namespace" &>/dev/null && ! oc get deployment backstage-psql -n "$namespace" &>/dev/null; then - log::info "Creating simple PostgreSQL deployment..." - create_simple_postgres_deployment "$namespace" - else - log::info "PostgreSQL found, waiting for ready..." - if oc get statefulset backstage-psql -n "$namespace" &>/dev/null; then - oc wait statefulset backstage-psql -n "$namespace" --for=jsonpath='{.status.readyReplicas}'=1 --timeout=300s || true - else - wait_for_deployment "$namespace" backstage-psql 15 || true - fi - fi - - local psql_secret_name psql_svc_name - psql_secret_name=$(oc get secrets -n "$namespace" -o name 2>/dev/null | grep "backstage-psql" | grep "secret" | head -1 | sed 's|secret\/||') - psql_svc_name='backstage-psql' - - log::info "PostgreSQL secret: $psql_secret_name, service: $psql_svc_name" - - if ! oc get sonataflowplatform sonataflow-platform -n "$namespace" &>/dev/null && ! oc get sfp sonataflow-platform -n "$namespace" &>/dev/null; then - create_sonataflow_platform "$namespace" "$psql_secret_name" "$psql_svc_name" - else - log::info "SonataFlowPlatform already exists" - wait_for_deployment "$namespace" sonataflow-platform-data-index-service 20 || true - wait_for_deployment "$namespace" sonataflow-platform-jobs-service 20 || true - fi - - if ! oc get crd sonataflows.sonataflow.org &>/dev/null; then - log::error "SonataFlow CRD not found." - return 1 - fi -} - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- -main() { - log::info "Starting orchestrator deployment for namespace: ${NAME_SPACE}" - - if ! oc whoami &>/dev/null && ! kubectl cluster-info &>/dev/null; then - log::error "Not logged into OpenShift/Kubernetes cluster" - return 1 - fi - - log::info "Checking Serverless operators..." - if ! oc get subscription serverless-operator -n openshift-operators &>/dev/null; then - log::info "Installing OpenShift Serverless Operator..." - install_serverless_ocp_operator - else - log::info "OpenShift Serverless Operator already installed" - fi - - if oc get subscription logic-operator -n openshift-operators &>/dev/null || \ - oc get subscription logic-operator-rhel8 -n openshift-operators &>/dev/null; then - log::info "OpenShift Serverless Logic Operator already installed" - else - log::info "Installing OpenShift Serverless Logic Operator..." - install_serverless_logic_ocp_operator - fi - - log::info "Waiting for operators to be ready..." - waitfor_serverless_ocp_operator - waitfor_serverless_logic_ocp_operator - wait_for_sonataflow_crds - - configure_namespace "${NAME_SPACE}" - log::info "Deploying orchestrator workflows..." - deploy_orchestrator_workflows_operator "${NAME_SPACE}" - print_orchestrator_connection_info "${NAME_SPACE}" - - log::success "Orchestrator deployment completed successfully!" -} - -main "$@" diff --git a/dist/deployment/rhdh/config/auth/github/app-config.yaml b/dist/deployment/rhdh/config/auth/github/app-config.yaml deleted file mode 100644 index 552584c..0000000 --- a/dist/deployment/rhdh/config/auth/github/app-config.yaml +++ /dev/null @@ -1,17 +0,0 @@ -auth: - environment: production - session: - secret: superSecretSecret - providers: - github: - production: - clientSecret: ${GITHUB_OAUTH_APP_SECRET} - clientId: ${GITHUB_OAUTH_APP_ID} - callbackUrl: ${RHDH_BASE_URL}/api/auth/github/handler/frame -signInPage: github -catalog: - locations: - - type: url - target: https://github.com/janus-qe/test-user-entity/blob/main/user.yaml - rules: - - allow: [User] diff --git a/dist/deployment/rhdh/config/auth/github/secrets.yaml b/dist/deployment/rhdh/config/auth/github/secrets.yaml deleted file mode 100644 index 30cc619..0000000 --- a/dist/deployment/rhdh/config/auth/github/secrets.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: rhdh-secrets -type: Opaque -stringData: - GITHUB_OAUTH_APP_ID: $VAULT_GITHUB_OAUTH_OVERLAYS_APP_ID - GITHUB_OAUTH_APP_SECRET: $VAULT_GITHUB_OAUTH_OVERLAYS_APP_SECRET - GH_USER_ID: $VAULT_GH_USER_ID - GH_USER_PASS: $VAULT_GH_USER_PASS - GH_2FA_SECRET: $VAULT_GH_2FA_SECRET - GH_RHDH_QE_USER_TOKEN: $VAULT_GITHUB_USER_TOKEN diff --git a/dist/deployment/rhdh/config/auth/guest/app-config.yaml b/dist/deployment/rhdh/config/auth/guest/app-config.yaml deleted file mode 100644 index a867aee..0000000 --- a/dist/deployment/rhdh/config/auth/guest/app-config.yaml +++ /dev/null @@ -1,5 +0,0 @@ -auth: - environment: development - providers: - guest: - dangerouslyAllowOutsideDevelopment: true diff --git a/dist/deployment/rhdh/config/auth/keycloak/app-config.yaml b/dist/deployment/rhdh/config/auth/keycloak/app-config.yaml deleted file mode 100644 index 71b2705..0000000 --- a/dist/deployment/rhdh/config/auth/keycloak/app-config.yaml +++ /dev/null @@ -1,19 +0,0 @@ -auth: - environment: production - session: - secret: superSecretSecret - providers: - oidc: - production: - metadataUrl: "${KEYCLOAK_METADATA_URL}" - clientId: "${KEYCLOAK_CLIENT_ID}" - clientSecret: "${KEYCLOAK_CLIENT_SECRET}" - prompt: auto - callbackUrl: "${RHDH_BASE_URL}/api/auth/oidc/handler/frame" - signIn: - resolvers: - - resolver: emailLocalPartMatchingUserEntityName -signInPage: oidc -catalog: - rules: - - allow: [User, Group] diff --git a/dist/deployment/rhdh/config/auth/keycloak/dynamic-plugins.yaml b/dist/deployment/rhdh/config/auth/keycloak/dynamic-plugins.yaml deleted file mode 100644 index 51bc2ad..0000000 --- a/dist/deployment/rhdh/config/auth/keycloak/dynamic-plugins.yaml +++ /dev/null @@ -1,3 +0,0 @@ -plugins: - - package: ./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic - disabled: false diff --git a/dist/deployment/rhdh/config/auth/keycloak/secrets.yaml b/dist/deployment/rhdh/config/auth/keycloak/secrets.yaml deleted file mode 100644 index 7d2bc18..0000000 --- a/dist/deployment/rhdh/config/auth/keycloak/secrets.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: rhdh-secrets -type: Opaque -stringData: - KEYCLOAK_BASE_URL: $KEYCLOAK_BASE_URL - KEYCLOAK_METADATA_URL: $KEYCLOAK_METADATA_URL - KEYCLOAK_CLIENT_ID: $KEYCLOAK_CLIENT_ID - KEYCLOAK_CLIENT_SECRET: $KEYCLOAK_CLIENT_SECRET - KEYCLOAK_REALM: $KEYCLOAK_REALM - KEYCLOAK_LOGIN_REALM: $KEYCLOAK_LOGIN_REALM diff --git a/dist/deployment/rhdh/config/common/app-config-rhdh.yaml b/dist/deployment/rhdh/config/common/app-config-rhdh.yaml deleted file mode 100644 index 0271f15..0000000 --- a/dist/deployment/rhdh/config/common/app-config-rhdh.yaml +++ /dev/null @@ -1,6 +0,0 @@ -app: - baseUrl: "${RHDH_BASE_URL}" -backend: - baseUrl: "${RHDH_BASE_URL}" - cors: - origin: "${RHDH_BASE_URL}" diff --git a/dist/deployment/rhdh/config/common/dynamic-plugins.yaml b/dist/deployment/rhdh/config/common/dynamic-plugins.yaml deleted file mode 100644 index 392431d..0000000 --- a/dist/deployment/rhdh/config/common/dynamic-plugins.yaml +++ /dev/null @@ -1,5 +0,0 @@ -includes: - - dynamic-plugins.default.yaml -plugins: - - package: ./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-quickstart - disabled: true diff --git a/dist/deployment/rhdh/config/common/rhdh-secrets.yaml b/dist/deployment/rhdh/config/common/rhdh-secrets.yaml deleted file mode 100644 index 274f623..0000000 --- a/dist/deployment/rhdh/config/common/rhdh-secrets.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: rhdh-secrets -type: Opaque -stringData: - RHDH_BASE_URL: $RHDH_BASE_URL diff --git a/dist/deployment/rhdh/config/helm/value_file.yaml b/dist/deployment/rhdh/config/helm/value_file.yaml deleted file mode 100644 index 3fb9a14..0000000 --- a/dist/deployment/rhdh/config/helm/value_file.yaml +++ /dev/null @@ -1,7 +0,0 @@ -upstream: - backstage: - extraAppConfig: - - configMapRef: app-config-rhdh - filename: app-config-rhdh.yaml - extraEnvVarsSecrets: - - rhdh-secrets diff --git a/dist/deployment/rhdh/config/operator/subscription.yaml b/dist/deployment/rhdh/config/operator/subscription.yaml deleted file mode 100644 index 554be2d..0000000 --- a/dist/deployment/rhdh/config/operator/subscription.yaml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: rhdh.redhat.com/v1alpha3 -kind: Backstage -metadata: - name: developer-hub -spec: - application: - appConfig: - configMaps: - - name: app-config-rhdh - mountPath: /opt/app-root/src - extraFiles: - mountPath: /opt/app-root/src - replicas: 1 - route: - enabled: true - dynamicPluginsConfigMapName: dynamic-plugins - extraEnvs: - secrets: - - name: rhdh-secrets - database: - enableLocalDb: true diff --git a/dist/deployment/rhdh/constants.d.ts b/dist/deployment/rhdh/constants.d.ts deleted file mode 100644 index 871966f..0000000 --- a/dist/deployment/rhdh/constants.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { AuthProvider } from "./types.js"; -import { MergeOptions } from "../../utils/merge-yamls.js"; -export declare const DEFAULT_CONFIG_PATHS: { - appConfig: string; - secrets: string; - dynamicPlugins: string; - helm: { - valueFile: string; - }; - operator: { - subscription: string; - }; -}; -export declare const AUTH_CONFIG_PATHS: Record; -export declare const CHART_URL = "oci://quay.io/rhdh/chart"; -//# sourceMappingURL=constants.d.ts.map \ No newline at end of file diff --git a/dist/deployment/rhdh/constants.d.ts.map b/dist/deployment/rhdh/constants.d.ts.map deleted file mode 100644 index e94a26f..0000000 --- a/dist/deployment/rhdh/constants.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../src/deployment/rhdh/constants.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAK1D,eAAO,MAAM,oBAAoB;;;;;;;;;;CAyBhC,CAAC;AAEF,eAAO,MAAM,iBAAiB,EAAE,MAAM,CACpC,YAAY,EACZ;IACE,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,CAAC,EAAE,YAAY,CAAC;CAC9B,CAoCF,CAAC;AAEF,eAAO,MAAM,SAAS,6BAA6B,CAAC"} \ No newline at end of file diff --git a/dist/deployment/rhdh/constants.js b/dist/deployment/rhdh/constants.js deleted file mode 100644 index 06b515c..0000000 --- a/dist/deployment/rhdh/constants.js +++ /dev/null @@ -1,33 +0,0 @@ -import path from "path"; -// Navigate from dist/deployment/rhdh/ to package root -const PACKAGE_ROOT = path.resolve(import.meta.dirname, "../../.."); -export const DEFAULT_CONFIG_PATHS = { - appConfig: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/common/app-config-rhdh.yaml"), - secrets: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/common/rhdh-secrets.yaml"), - dynamicPlugins: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/common/dynamic-plugins.yaml"), - helm: { - valueFile: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/helm/value_file.yaml"), - }, - operator: { - subscription: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/operator/subscription.yaml"), - }, -}; -export const AUTH_CONFIG_PATHS = { - guest: { - appConfig: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/auth/guest/app-config.yaml"), - secrets: "", - dynamicPlugins: "", - }, - keycloak: { - appConfig: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/auth/keycloak/app-config.yaml"), - secrets: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/auth/keycloak/secrets.yaml"), - dynamicPlugins: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/auth/keycloak/dynamic-plugins.yaml"), - }, - github: { - appConfig: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/auth/github/app-config.yaml"), - secrets: path.join(PACKAGE_ROOT, "dist/deployment/rhdh/config/auth/github/secrets.yaml"), - dynamicPlugins: "", - mergeStrategy: { arrayMergeStrategy: { byKey: "target" } }, - }, -}; -export const CHART_URL = "oci://quay.io/rhdh/chart"; diff --git a/dist/deployment/rhdh/deployment.d.ts b/dist/deployment/rhdh/deployment.d.ts deleted file mode 100644 index 7bc4300..0000000 --- a/dist/deployment/rhdh/deployment.d.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { KubernetesClientHelper } from "../../utils/kubernetes-client.js"; -import type { DeploymentOptions, DeploymentConfig } from "./types.js"; -export declare class RHDHDeployment { - k8sClient: KubernetesClientHelper; - rhdhUrl: string; - deploymentConfig: DeploymentConfig; - constructor(namespace: string); - deploy(options?: { - timeout?: number | null; - }): Promise; - private _applyAppConfig; - private _applySecrets; - /** Shared merge strategy for dynamic plugin arrays. */ - private static readonly pluginMergeOpts; - /** - * Merges package defaults + auth config (+ optional user config) into a - * single dynamic plugins configuration. - */ - private _mergeBaseConfigs; - /** - * Merges a generated plugin config with the base (defaults + auth) config. - */ - private _mergeGeneratedWithBase; - /** - * Builds the merged dynamic plugins configuration. - * - * 1. Assembles raw config: user-provided OR auto-generated from metadata - * 2. Processes for deployment: injects metadata (PR) + resolves all packages to OCI - * - * The processing step is shared β€” processPluginsForDeployment handles - * both PR and nightly via isNightlyJob() and GIT_PR_NUMBER detection. - */ - private _buildDynamicPluginsConfig; - private _applyDynamicPlugins; - private _deployWithHelm; - private _deployWithOperator; - rolloutRestart(): Promise; - /** - * Performs a clean restart by scaling down to 0 first, waiting for pods to terminate, - * then scaling back up. This prevents MigrationLocked errors by ensuring no pods - * hold database locks when new pods start. - */ - scaleDownAndRestart(): Promise; - waitUntilReady(timeout?: number): Promise; - teardown(): Promise; - private _deploymentExists; - private _resolveChartVersion; - /** - * Resolve the semantic version from the "next" tag by looking up the - * downstream image (rhdh-hub-rhel9) and finding tags with the same digest. - */ - private _resolveVersionFromNextTag; - private _buildDeploymentConfig; - configure(deploymentOptions?: DeploymentOptions): Promise; - private _buildBaseUrl; - private _log; - private _logBoxen; -} -//# sourceMappingURL=deployment.d.ts.map \ No newline at end of file diff --git a/dist/deployment/rhdh/deployment.d.ts.map b/dist/deployment/rhdh/deployment.d.ts.map deleted file mode 100644 index 160f016..0000000 --- a/dist/deployment/rhdh/deployment.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"deployment.d.ts","sourceRoot":"","sources":["../../../src/deployment/rhdh/deployment.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,kCAAkC,CAAC;AAwB1E,OAAO,KAAK,EACV,iBAAiB,EACjB,gBAAgB,EAGjB,MAAM,YAAY,CAAC;AAEpB,qBAAa,cAAc;IAClB,SAAS,yBAAgC;IACzC,OAAO,EAAE,MAAM,CAAC;IAChB,gBAAgB,EAAE,gBAAgB,CAAC;gBAE9B,SAAS,EAAE,MAAM;IAKvB,MAAM,CAAC,OAAO,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;YA0CpD,eAAe;YAmBf,aAAa;IAqB3B,uDAAuD;IACvD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,eAAe,CAE5B;IAEX;;;OAGG;YACW,iBAAiB;IAY/B;;OAEG;YACW,uBAAuB;IAgBrC;;;;;;;;OAQG;YACW,0BAA0B;YAsC1B,oBAAoB;YAWpB,eAAe;YA6Df,mBAAmB;IAoE3B,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAWrC;;;;OAIG;IACG,mBAAmB,IAAI,OAAO,CAAC,IAAI,CAAC;IAOpC,cAAc,CAAC,OAAO,GAAE,MAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IA8BpD,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;YAIjB,iBAAiB;YASjB,oBAAoB;IAsClC;;;OAGG;YACW,0BAA0B;IAwCxC,OAAO,CAAC,sBAAsB;IAoCxB,SAAS,CAAC,iBAAiB,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAUrE,OAAO,CAAC,aAAa;IAUrB,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,SAAS;CAMlB"} \ No newline at end of file diff --git a/dist/deployment/rhdh/deployment.js b/dist/deployment/rhdh/deployment.js deleted file mode 100644 index 15f4c91..0000000 --- a/dist/deployment/rhdh/deployment.js +++ /dev/null @@ -1,412 +0,0 @@ -import { KubernetesClientHelper } from "../../utils/kubernetes-client.js"; -import { WorkspacePaths } from "../../utils/workspace-paths.js"; -import { $ } from "../../utils/bash.js"; -import yaml from "js-yaml"; -import os from "os"; -import path from "path"; -import { test, request, expect } from "@playwright/test"; -import { mergeYamlFilesIfExists, deepMerge } from "../../utils/merge-yamls.js"; -import { generatePluginsFromMetadata, processPluginsForDeployment, getNormalizedPluginMergeKey, disablePluginWrappers, } from "../../utils/plugin-metadata.js"; -import { envsubst } from "../../utils/common.js"; -import { runOnce } from "../../playwright/run-once.js"; -import cloneDeepWith from "lodash.clonedeepwith"; -import fs from "fs-extra"; -import { DEFAULT_CONFIG_PATHS, AUTH_CONFIG_PATHS, CHART_URL, } from "./constants.js"; -export class RHDHDeployment { - k8sClient = new KubernetesClientHelper(); - rhdhUrl; - deploymentConfig; - constructor(namespace) { - this.deploymentConfig = this._buildDeploymentConfig({ namespace }); - this.rhdhUrl = this._buildBaseUrl(); - } - async deploy(options) { - // Default 600s, custom number to override, null to skip and let consumer control the timeout - const timeout = options?.timeout === undefined ? 600_000 : options.timeout; - if (timeout !== null) { - test.setTimeout(timeout); - } - const executed = await runOnce(`deploy-${this.deploymentConfig.namespace}`, async () => { - this._log("Starting RHDH deployment..."); - this._log("RHDH Base URL: " + this.rhdhUrl); - console.table(this.deploymentConfig); - await this.k8sClient.createNamespaceIfNotExists(this.deploymentConfig.namespace); - await this._applyAppConfig(); - await this._applySecrets(); - if (this.deploymentConfig.method === "helm") { - const isUpgrade = await this._deploymentExists(); - await this._deployWithHelm(this.deploymentConfig.valueFile); - if (isUpgrade) { - await this.scaleDownAndRestart(); // Restart as helm does not monitor config changes - } - } - else { - await this._applyDynamicPlugins(); - await this._deployWithOperator(this.deploymentConfig.subscription); - } - await this.waitUntilReady(); - }); - if (!executed) { - this._log(`Deployment already completed for namespace "${this.deploymentConfig.namespace}", skipping`); - } - } - async _applyAppConfig() { - const authConfig = AUTH_CONFIG_PATHS[this.deploymentConfig.auth]; - const appConfigYaml = await mergeYamlFilesIfExists([ - DEFAULT_CONFIG_PATHS.appConfig, - authConfig.appConfig, - this.deploymentConfig.appConfig, - ], authConfig.mergeStrategy); - this._logBoxen("App Config", appConfigYaml); - await this.k8sClient.applyConfigMapFromObject("app-config-rhdh", appConfigYaml, this.deploymentConfig.namespace); - } - async _applySecrets() { - const authConfig = AUTH_CONFIG_PATHS[this.deploymentConfig.auth]; - const secretsYaml = await mergeYamlFilesIfExists([ - DEFAULT_CONFIG_PATHS.secrets, - authConfig.secrets, - this.deploymentConfig.secrets, - ]); - // Use cloneDeepWith to substitute env vars in-place, avoiding JSON.parse issues - // with control characters in secrets (e.g., private keys with newlines) - const substituted = cloneDeepWith(secretsYaml, (value) => { - if (typeof value === "string") - return envsubst(value); - }); - await this.k8sClient.applySecretFromObject("rhdh-secrets", substituted, this.deploymentConfig.namespace); - } - /** Shared merge strategy for dynamic plugin arrays. */ - static pluginMergeOpts = { - arrayMergeStrategy: { byKey: "package" }, - }; - /** - * Merges package defaults + auth config (+ optional user config) into a - * single dynamic plugins configuration. - */ - async _mergeBaseConfigs(userConfigPath) { - const authConfig = AUTH_CONFIG_PATHS[this.deploymentConfig.auth]; - const paths = [ - DEFAULT_CONFIG_PATHS.dynamicPlugins, - authConfig.dynamicPlugins, - ...(userConfigPath ? [userConfigPath] : []), - ]; - return await mergeYamlFilesIfExists(paths, RHDHDeployment.pluginMergeOpts); - } - /** - * Merges a generated plugin config with the base (defaults + auth) config. - */ - async _mergeGeneratedWithBase(generatedConfig) { - const baseConfig = await this._mergeBaseConfigs(); - // Use normalizeKey so OCI and local path for the same logical plugin - // (e.g., keycloak from metadata OCI + auth local path with -dynamic suffix) - // are deduplicated; generated (metadata) wins so OCI URL is kept. - return deepMerge(baseConfig, generatedConfig, { - arrayMergeStrategy: { - byKey: "package", - normalizeKey: (item) => getNormalizedPluginMergeKey(item), - }, - }); - } - /** - * Builds the merged dynamic plugins configuration. - * - * 1. Assembles raw config: user-provided OR auto-generated from metadata - * 2. Processes for deployment: injects metadata (PR) + resolves all packages to OCI - * - * The processing step is shared β€” processPluginsForDeployment handles - * both PR and nightly via isNightlyJob() and GIT_PR_NUMBER detection. - */ - async _buildDynamicPluginsConfig() { - const userConfigPath = this.deploymentConfig.dynamicPlugins; - const userConfigExists = userConfigPath && fs.existsSync(userConfigPath); - const wrapperPlugins = disablePluginWrappers(this.deploymentConfig.disableWrappers); - let config; - if (userConfigExists) { - this._log(`Using user config: ${userConfigPath}`); - config = await this._mergeBaseConfigs(userConfigPath); - } - else { - this._log(`No user config at '${userConfigPath}', auto-generating from metadata...`); - const generated = await generatePluginsFromMetadata(WorkspacePaths.metadataDir); - config = await this._mergeGeneratedWithBase(generated); - } - // Process for deployment: inject metadata (PR only) + resolve all packages to OCI - let result = await processPluginsForDeployment(config, WorkspacePaths.metadataDir); - // Disable wrapper plugins (PR builds only) - if (process.env.GIT_PR_NUMBER) { - result = deepMerge(result, wrapperPlugins, { - arrayMergeStrategy: "concat", - }); - } - return result; - } - async _applyDynamicPlugins() { - const dynamicPluginsYaml = await this._buildDynamicPluginsConfig(); - this._logBoxen("Dynamic Plugins", dynamicPluginsYaml); - await this.k8sClient.applyConfigMapFromObject("dynamic-plugins", dynamicPluginsYaml, this.deploymentConfig.namespace); - } - async _deployWithHelm(valueFile) { - const chartVersion = await this._resolveChartVersion(this.deploymentConfig.version); - this._log(`Helm chart version resolved to: ${chartVersion}`); - const valueFileObject = (await mergeYamlFilesIfExists([ - DEFAULT_CONFIG_PATHS.helm.valueFile, - valueFile, - ])); - this._logBoxen("Value File", valueFileObject); - // Merge dynamic plugins into the values file (including auth-specific plugins) - if (!valueFileObject.global) { - valueFileObject.global = {}; - } - valueFileObject.global.dynamic = await this._buildDynamicPluginsConfig(); - // Set catalog index image if CATALOG_INDEX_IMAGE env var is provided. - // The catalog index provides dynamic-plugins.default.yaml with default plugin - // configurations and versions for the RHDH release. - const catalogIndexImage = process.env.CATALOG_INDEX_IMAGE; - if (catalogIndexImage) { - const [imageRef, tag] = catalogIndexImage.split(":"); - const firstSlash = imageRef.indexOf("/"); - valueFileObject.global.catalogIndex = { - image: { - registry: imageRef.substring(0, firstSlash), - repository: imageRef.substring(firstSlash + 1), - tag: tag || "latest", - }, - }; - this._log(`Catalog index image: ${catalogIndexImage}`); - } - this._logBoxen("Dynamic Plugins", valueFileObject.global.dynamic); - // Escape {{inherit}} for Helm's Go template engine. - // The RHDH chart uses `tpl` on dynamic plugin values, so {{inherit}} would be - // interpreted as a Go template action. Escaping to {{ "{{inherit}}" }} produces - // the literal string {{inherit}} after template rendering. - const valuesYaml = yaml - .dump(valueFileObject) - .replace(/\{\{inherit\}\}/g, '{{ "{{inherit}}" }}'); - const valueFilePath = path.join(os.tmpdir(), `${this.deploymentConfig.namespace}-value-file.yaml`); - fs.writeFileSync(valueFilePath, valuesYaml); - await $ ` - helm upgrade redhat-developer-hub -i "${process.env.CHART_URL || CHART_URL}" --version "${chartVersion}" \ - -f "${valueFilePath}" \ - --set global.clusterRouterBase="${process.env.K8S_CLUSTER_ROUTER_BASE}" \ - --namespace="${this.deploymentConfig.namespace}" - `; - this._log(`Helm deployment completed successfully`); - } - async _deployWithOperator(subscription) { - const subscriptionObject = (await mergeYamlFilesIfExists([ - DEFAULT_CONFIG_PATHS.operator.subscription, - subscription, - ])); - // Set catalog index image if CATALOG_INDEX_IMAGE env var is provided. - const catalogIndexImage = process.env.CATALOG_INDEX_IMAGE; - if (catalogIndexImage) { - const spec = (subscriptionObject.spec ??= {}); - const app = (spec.application ??= {}); - const extraEnvs = (app.extraEnvs ??= - {}); - const envs = (extraEnvs.envs ??= - []); - envs.push({ - name: "CATALOG_INDEX_IMAGE", - value: catalogIndexImage, - containers: ["install-dynamic-plugins"], - }); - this._log(`Catalog index image: ${catalogIndexImage}`); - } - this._logBoxen("Subscription", subscriptionObject); - const subscriptionFilePath = path.join(os.tmpdir(), `${this.deploymentConfig.namespace}-subscription.yaml`); - fs.writeFileSync(subscriptionFilePath, yaml.dump(subscriptionObject)); - const version = this.deploymentConfig.version; - const isSemanticVersion = /^\d+(\.\d+)?$/.test(version); - // Use main branch for non-semantic versions (e.g., "next", "latest") - const branch = isSemanticVersion ? `release-${version}` : "main"; - // Build version argument based on version type - let versionArg; - if (isSemanticVersion) { - versionArg = `-v ${version}`; - } - else if (version === "next") { - versionArg = "--next"; - } - else { - throw new Error(`Invalid RHDH version "${version}". Use semantic version (e.g., "1.5") or "next".`); - } - this._log(`Using operator branch: ${branch}, version arg: ${versionArg}`); - await $ ` - set -e; - curl -sf https://raw.githubusercontent.com/redhat-developer/rhdh-operator/refs/heads/${branch}/.rhdh/scripts/install-rhdh-catalog-source.sh | bash -s -- ${versionArg} --install-operator rhdh - - timeout 300 bash -c ' - while ! oc get crd/backstages.rhdh.redhat.com -n "${this.deploymentConfig.namespace}" >/dev/null 2>&1; do - echo "Waiting for Backstage CRD to be created..." - sleep 20 - done - echo "Backstage CRD is created." - ' || echo "Error: Timed out waiting for Backstage CRD creation." - - oc apply -f "${subscriptionFilePath}" -n "${this.deploymentConfig.namespace}" - `; - this._log("Operator deployment executed successfully."); - } - async rolloutRestart() { - this._log(`Restarting RHDH deployment in namespace ${this.deploymentConfig.namespace}...`); - await $ `oc rollout restart deployment -l 'app.kubernetes.io/instance in (redhat-developer-hub,developer-hub)' -n ${this.deploymentConfig.namespace}`; - this._log(`RHDH deployment restarted successfully in namespace ${this.deploymentConfig.namespace}`); - await this.waitUntilReady(); - } - /** - * Performs a clean restart by scaling down to 0 first, waiting for pods to terminate, - * then scaling back up. This prevents MigrationLocked errors by ensuring no pods - * hold database locks when new pods start. - */ - async scaleDownAndRestart() { - const namespace = this.deploymentConfig.namespace; - await $ `oc scale deployment -l 'app.kubernetes.io/instance in (redhat-developer-hub,developer-hub)' --replicas=0 -n ${namespace}`; - await $ `oc wait --for=delete pod -l 'app.kubernetes.io/instance in (redhat-developer-hub,developer-hub),app.kubernetes.io/name!=postgresql' -n ${namespace} --timeout=120s || true`; - await $ `oc scale deployment -l 'app.kubernetes.io/instance in (redhat-developer-hub,developer-hub)' --replicas=1 -n ${namespace}`; - } - async waitUntilReady(timeout = 500) { - const namespace = this.deploymentConfig.namespace; - const labelSelector = "app.kubernetes.io/instance in (redhat-developer-hub,developer-hub)"; - const startTime = Date.now(); - try { - await this.k8sClient.waitForPodsWithFailureDetection(namespace, labelSelector, timeout); - } - catch (error) { - throw new Error(`RHDH deployment failed in ${namespace}: ${error instanceof Error ? error.message : error}`, { cause: error }); - } - // Use remaining timeout for route readiness check - const remaining = timeout * 1000 - (Date.now() - startTime); - await expect(async () => { - const context = await request.newContext({ ignoreHTTPSErrors: true }); - const response = await context.get(this.rhdhUrl); - await context.dispose(); - expect(response.ok()).toBeTruthy(); - }).toPass({ timeout: Math.max(remaining, 30_000), intervals: [5_000] }); - this._log(`RHDH is ready in ${namespace}`); - } - async teardown() { - await this.k8sClient.deleteNamespace(this.deploymentConfig.namespace); - } - async _deploymentExists() { - try { - await $ `oc get deployment redhat-developer-hub -n ${this.deploymentConfig.namespace} --no-headers 2>/dev/null`; - return true; - } - catch { - return false; - } - } - async _resolveChartVersion(version) { - let resolvedVersion = version; - // Handle "next" tag by looking up the corresponding version from downstream image - if (version === "next") { - resolvedVersion = await this._resolveVersionFromNextTag(); - this._log(`Resolved "next" tag to version: ${resolvedVersion}`); - } - // Semantic versions (e.g., 1.2, 1.10) - if (/^(\d+(\.\d+)?)$/.test(resolvedVersion)) { - const response = await fetch("https://quay.io/api/v1/repository/rhdh/chart/tag/?onlyActiveTags=true&limit=600"); - if (!response.ok) - throw new Error(`Failed to fetch chart versions: ${response.statusText}`); - const data = (await response.json()); - const matching = data.tags - .map((t) => t.name) - .filter((name) => name.startsWith(`${resolvedVersion}-`)) - .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); - const latest = matching.at(-1); - if (!latest) - throw new Error(`No chart version found for ${resolvedVersion}`); - return latest; - } - // CI build versions (e.g., 1.2.3-CI) - if (resolvedVersion.endsWith("CI")) - return resolvedVersion; - throw new Error(`Invalid Helm chart version format: "${version}"`); - } - /** - * Resolve the semantic version from the "next" tag by looking up the - * downstream image (rhdh-hub-rhel9) and finding tags with the same digest. - */ - async _resolveVersionFromNextTag() { - // Fetch all active tags in a single API call - const response = await fetch("https://quay.io/api/v1/repository/rhdh/rhdh-hub-rhel9/tag/?onlyActiveTags=true&limit=75"); - if (!response.ok) { - throw new Error(`Failed to fetch image tags: ${response.statusText}`); - } - // Use Record to avoid snake_case linting issues with Quay API response - const data = (await response.json()); - // Find the "next" tag and get its digest - const nextTag = data.tags.find((t) => t["name"] === "next"); - if (!nextTag) { - throw new Error('No "next" tag found in rhdh-hub-rhel9 repository'); - } - const digest = nextTag["manifest_digest"]; - this._log(`"next" tag digest: ${digest}`); - // Find semantic version tag (e.g., "1.10") with the same digest - const semanticVersionTag = data.tags.find((t) => t["manifest_digest"] === digest && - /^\d+\.\d+$/.test(t["name"])); - if (!semanticVersionTag) { - throw new Error(`Could not find semantic version tag for "next" (digest: ${digest})`); - } - return semanticVersionTag["name"]; - } - _buildDeploymentConfig(input) { - // Default to "next" if RHDH_VERSION not set - const version = input.version ?? process.env.RHDH_VERSION ?? "next"; - // Default to "helm" if INSTALLATION_METHOD not set - const method = input.method ?? - process.env.INSTALLATION_METHOD ?? - "helm"; - const base = { - version, - namespace: input.namespace ?? this.deploymentConfig.namespace, - auth: input.auth ?? "keycloak", - appConfig: input.appConfig ?? WorkspacePaths.appConfig, - secrets: input.secrets ?? WorkspacePaths.secrets, - dynamicPlugins: input.dynamicPlugins ?? WorkspacePaths.dynamicPlugins, - disableWrappers: input.disableWrappers ?? [], - }; - if (method === "helm") { - return { - ...base, - method, - valueFile: input.valueFile ?? WorkspacePaths.valueFile, - }; - } - else if (method === "operator") { - return { - ...base, - method, - subscription: input.subscription ?? WorkspacePaths.subscription, - }; - } - else { - throw new Error(`Invalid RHDH installation method: ${method}`); - } - } - async configure(deploymentOptions) { - if (deploymentOptions) { - this.deploymentConfig = this._buildDeploymentConfig(deploymentOptions); - this.rhdhUrl = this._buildBaseUrl(); - } - await this.k8sClient.createNamespaceIfNotExists(this.deploymentConfig.namespace); - } - _buildBaseUrl() { - const prefix = this.deploymentConfig.method === "helm" - ? "redhat-developer-hub" - : "backstage-developer-hub"; - const baseUrl = `https://${prefix}-${this.deploymentConfig.namespace}.${process.env.K8S_CLUSTER_ROUTER_BASE}`; - process.env.RHDH_BASE_URL = baseUrl; - return baseUrl; - } - _log(...args) { - console.log("[RHDHDeployment]", ...args); - } - _logBoxen(title, data) { - const content = yaml.dump(data, { lineWidth: -1 }); - console.log(`\nβ”Œβ”€ ${title} ${"─".repeat(60)}`); - console.log(content); - console.log(`β””${"─".repeat(60 + title.length + 3)}\n`); - } -} diff --git a/dist/deployment/rhdh/deployment.test.d.ts b/dist/deployment/rhdh/deployment.test.d.ts deleted file mode 100644 index 6eba03c..0000000 --- a/dist/deployment/rhdh/deployment.test.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {}; -//# sourceMappingURL=deployment.test.d.ts.map \ No newline at end of file diff --git a/dist/deployment/rhdh/deployment.test.d.ts.map b/dist/deployment/rhdh/deployment.test.d.ts.map deleted file mode 100644 index 6f5ccdd..0000000 --- a/dist/deployment/rhdh/deployment.test.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"deployment.test.d.ts","sourceRoot":"","sources":["../../../src/deployment/rhdh/deployment.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/deployment/rhdh/deployment.test.js b/dist/deployment/rhdh/deployment.test.js deleted file mode 100644 index fbb200f..0000000 --- a/dist/deployment/rhdh/deployment.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert"; -import { deepMerge } from "../../utils/merge-yamls.js"; -import { getNormalizedPluginMergeKey } from "../../utils/plugin-metadata.js"; -/** - * Tests the merge behavior used when user dynamic-plugins config does not exist: - * auth config (e.g. keycloak) is merged with metadata config using normalized plugin key. - * Result must have exactly one entry per logical plugin; metadata (source) wins so OCI URL is kept. - */ -describe("dynamic-plugins merge (no user config path)", () => { - it("yields one keycloak plugin with OCI package when auth has local path and metadata has OCI", () => { - const authPlugins = { - plugins: [ - { - package: "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic", - disabled: false, - pluginConfig: {}, - }, - ], - includes: ["dynamic-plugins.default.yaml"], - }; - const metadataConfig = { - plugins: [ - { - package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-catalog-backend-module-keycloak:pr_1980__3.16.0!backstage-community-plugin-catalog-backend-module-keycloak", - disabled: false, - pluginConfig: { catalog: { providers: { keycloakOrg: {} } } }, - }, - ], - }; - const merged = deepMerge(authPlugins, metadataConfig, { - arrayMergeStrategy: { - byKey: "package", - normalizeKey: (item) => getNormalizedPluginMergeKey(item), - }, - }); - const plugins = merged.plugins; - assert.strictEqual(plugins.length, 1, "merged config must have exactly one keycloak plugin"); - assert.ok(plugins[0].package?.startsWith("oci://"), "metadata (OCI) must win over auth local path"); - }); -}); diff --git a/dist/deployment/rhdh/index.d.ts b/dist/deployment/rhdh/index.d.ts deleted file mode 100644 index 06647a6..0000000 --- a/dist/deployment/rhdh/index.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { RHDHDeployment } from "./deployment.js"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/deployment/rhdh/index.d.ts.map b/dist/deployment/rhdh/index.d.ts.map deleted file mode 100644 index 50ffec0..0000000 --- a/dist/deployment/rhdh/index.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/deployment/rhdh/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC"} \ No newline at end of file diff --git a/dist/deployment/rhdh/index.js b/dist/deployment/rhdh/index.js deleted file mode 100644 index 8e83699..0000000 --- a/dist/deployment/rhdh/index.js +++ /dev/null @@ -1 +0,0 @@ -export { RHDHDeployment } from "./deployment.js"; diff --git a/dist/deployment/rhdh/types.d.ts b/dist/deployment/rhdh/types.d.ts deleted file mode 100644 index 15af005..0000000 --- a/dist/deployment/rhdh/types.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -export type DeploymentMethod = "helm" | "operator"; -export type AuthProvider = "guest" | "keycloak" | "github"; -export type DeploymentOptions = { - version?: string; - namespace?: string; - auth?: AuthProvider; - appConfig?: string; - secrets?: string; - dynamicPlugins?: string; - method?: DeploymentMethod; - valueFile?: string; - subscription?: string; - disableWrappers?: string[]; -}; -export type HelmDeploymentConfig = { - method: "helm"; - valueFile: string; -}; -export type OperatorDeploymentConfig = { - method: "operator"; - subscription: string; -}; -export type DeploymentConfigBase = { - version: string; - namespace: string; - auth: AuthProvider; - appConfig: string; - secrets: string; - dynamicPlugins: string; - disableWrappers: string[]; -}; -export type DeploymentConfig = DeploymentConfigBase & (HelmDeploymentConfig | OperatorDeploymentConfig); -//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/dist/deployment/rhdh/types.d.ts.map b/dist/deployment/rhdh/types.d.ts.map deleted file mode 100644 index f809571..0000000 --- a/dist/deployment/rhdh/types.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/deployment/rhdh/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,UAAU,CAAC;AACnD,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,CAAC;AAE3D,MAAM,MAAM,iBAAiB,GAAG;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,gBAAgB,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG;IACrC,MAAM,EAAE,UAAU,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,YAAY,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,MAAM,EAAE,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG,oBAAoB,GACjD,CAAC,oBAAoB,GAAG,wBAAwB,CAAC,CAAC"} \ No newline at end of file diff --git a/dist/deployment/rhdh/types.js b/dist/deployment/rhdh/types.js deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/deployment/rhdh/types.js +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/eslint/base.config.d.ts b/dist/eslint/base.config.d.ts deleted file mode 100644 index 9b21583..0000000 --- a/dist/eslint/base.config.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Linter } from "eslint"; -/** - * Creates a base ESLint configuration for RHDH E2E tests. - * This configuration includes TypeScript, Playwright, and file naming conventions. - * - * @param tsconfigRootDir - The root directory for tsconfig.json resolution - * @returns ESLint flat config array - */ -export declare function createEslintConfig(tsconfigRootDir: string): Linter.Config[]; -//# sourceMappingURL=base.config.d.ts.map \ No newline at end of file diff --git a/dist/eslint/base.config.d.ts.map b/dist/eslint/base.config.d.ts.map deleted file mode 100644 index 442fb61..0000000 --- a/dist/eslint/base.config.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"base.config.d.ts","sourceRoot":"","sources":["../../src/eslint/base.config.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAErC;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,eAAe,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,CAmN3E"} \ No newline at end of file diff --git a/dist/eslint/base.config.js b/dist/eslint/base.config.js deleted file mode 100644 index 2f2aca0..0000000 --- a/dist/eslint/base.config.js +++ /dev/null @@ -1,220 +0,0 @@ -import js from "@eslint/js"; -import tseslint from "typescript-eslint"; -import checkFile from "eslint-plugin-check-file"; -import playwright from "eslint-plugin-playwright"; -/** - * Creates a base ESLint configuration for RHDH E2E tests. - * This configuration includes TypeScript, Playwright, and file naming conventions. - * - * @param tsconfigRootDir - The root directory for tsconfig.json resolution - * @returns ESLint flat config array - */ -export function createEslintConfig(tsconfigRootDir) { - return [ - // Global ignores - must be first for ESLint flat config - { - ignores: [ - "node_modules/**", - "playwright-report/**", - "test-results/**", - "blob-report/**", - "*.config.js", - "dist/**", - ], - }, - js.configs.recommended, - ...tseslint.configs.recommended, - { - files: ["**/*.ts"], - languageOptions: { - parserOptions: { - project: "./tsconfig.json", - tsconfigRootDir, - }, - }, - rules: { - // TypeScript naming conventions - "@typescript-eslint/naming-convention": [ - "error", - { - selector: "variable", - format: ["camelCase", "PascalCase"], - leadingUnderscore: "allow", - }, - { - selector: "variable", - modifiers: ["const"], - format: ["camelCase", "PascalCase", "UPPER_CASE"], - }, - { - selector: "function", - format: ["camelCase", "PascalCase"], - }, - { - selector: "parameter", - format: ["camelCase", "PascalCase"], - leadingUnderscore: "allow", - }, - { - selector: "typeLike", - format: ["PascalCase"], - }, - { - selector: "enumMember", - format: ["PascalCase"], - }, - { - selector: "memberLike", - modifiers: ["private"], - format: ["camelCase"], - leadingUnderscore: "allow", - }, - { - selector: "memberLike", - modifiers: ["public"], - format: ["camelCase"], - }, - // Allow HTTP headers in object literals which require specific formats - { - selector: "objectLiteralProperty", - format: null, - filter: { - regex: "^(Accept|Authorization|Content-Type|X-GitHub-Api-Version|X-[A-Za-z-]+)$", - match: true, - }, - }, - ], - // Promise handling - "@typescript-eslint/no-floating-promises": "error", - "@typescript-eslint/await-thenable": "error", - "@typescript-eslint/no-misused-promises": "error", - // Allow any type in tests (for mocking, test data) - "@typescript-eslint/no-explicit-any": "warn", - // Prefer modern syntax - "@typescript-eslint/prefer-optional-chain": "error", - // Allow unused vars starting with underscore - "@typescript-eslint/no-unused-vars": [ - "error", - { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - }, - ], - // Allow empty functions (for test stubs) - "@typescript-eslint/no-empty-function": "off", - }, - }, - { - files: ["**/*.{js,ts}"], - plugins: { - "check-file": checkFile, - }, - rules: { - "check-file/filename-naming-convention": [ - "error", - { - "**/*.{js,ts}": "KEBAB_CASE", - }, - { - ignoreMiddleExtensions: true, - }, - ], - "check-file/folder-naming-convention": [ - "error", - { - "**": "KEBAB_CASE", - }, - ], - }, - }, - // Playwright test files - { - ...playwright.configs["flat/recommended"], - files: [ - "**/*.spec.ts", - "**/*.test.ts", - "**/tests/**/*.ts", - "**/e2e/**/*.ts", - ], - rules: { - ...playwright.configs["flat/recommended"].rules, - // Playwright best practices - "playwright/expect-expect": "warn", - "playwright/max-nested-describe": ["warn", { max: 2 }], - "playwright/missing-playwright-await": "error", - "playwright/no-conditional-in-test": "warn", - "playwright/no-element-handle": "warn", - "playwright/no-eval": "error", - "playwright/no-focused-test": "error", - "playwright/no-force-option": "warn", - "playwright/no-page-pause": "warn", - "playwright/no-skipped-test": [ - "warn", - { - allowConditional: true, - }, - ], - "playwright/no-useless-await": "warn", - "playwright/no-useless-not": "warn", - "playwright/no-wait-for-selector": "warn", - "playwright/no-wait-for-timeout": "warn", - "playwright/prefer-web-first-assertions": "error", - "playwright/require-top-level-describe": "off", - "playwright/valid-describe-callback": "off", - "playwright/valid-expect": "error", - "playwright/valid-title": "warn", - // Custom restrictions - "no-restricted-syntax": [ - "error", - { - selector: "CallExpression[callee.property.name='fixme'][callee.object.property.name='describe'][callee.object.object.name='test']", - message: "test.describe.fixme() is not valid. Use test.fixme() on individual tests instead.", - }, - ], - // Disallow console.log in tests (use test.info() instead) - "no-console": [ - "warn", - { - allow: ["warn", "error"], - }, - ], - }, - }, - // Page Object Models - require class suffix - { - files: ["**/page-objects/**/*.ts", "**/page/**/*.ts", "**/pages/**/*.ts"], - rules: { - "@typescript-eslint/naming-convention": [ - "error", - { - selector: "class", - format: ["PascalCase"], - suffix: ["Page", "Component", "PO"], - }, - ], - }, - }, - // Fixtures - { - files: ["**/fixtures/**/*.ts"], - rules: { - "@typescript-eslint/no-explicit-any": "off", - }, - }, - // Config files - { - files: ["playwright.config.ts", "**/*.config.ts"], - rules: { - "@typescript-eslint/naming-convention": "off", - "check-file/filename-naming-convention": "off", - }, - }, - // Node test runner (*.test.ts) - describe/it return promises the runner handles - { - files: ["**/*.test.ts"], - rules: { - "@typescript-eslint/no-floating-promises": "off", - }, - }, - ]; -} diff --git a/dist/playwright/base-config.d.ts b/dist/playwright/base-config.d.ts deleted file mode 100644 index 09bfc91..0000000 --- a/dist/playwright/base-config.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { PlaywrightTestConfig } from "@playwright/test"; -/** - * Base Playwright configuration that can be extended by workspace-specific configs. - * Provides sensible defaults for RHDH plugin e2e testing. - */ -export declare const baseConfig: PlaywrightTestConfig; -/** - * Defines a workspace-specific config by merging with base config. - * Only allows overriding the projects configuration. - * @param overrides - Object containing projects to override - * @returns Merged Playwright configuration - */ -export declare function defineConfig(overrides?: Pick): PlaywrightTestConfig; -//# sourceMappingURL=base-config.d.ts.map \ No newline at end of file diff --git a/dist/playwright/base-config.d.ts.map b/dist/playwright/base-config.d.ts.map deleted file mode 100644 index d40f1be..0000000 --- a/dist/playwright/base-config.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"base-config.d.ts","sourceRoot":"","sources":["../../src/playwright/base-config.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,oBAAoB,EACrB,MAAM,kBAAkB,CAAC;AAG1B;;;GAGG;AACH,eAAO,MAAM,UAAU,EAAE,oBA8BxB,CAAC;AAEF;;;;;GAKG;AAEH,wBAAgB,YAAY,CAC1B,SAAS,GAAE,IAAI,CAAC,oBAAoB,EAAE,UAAU,CAAM,GACrD,oBAAoB,CAKtB"} \ No newline at end of file diff --git a/dist/playwright/base-config.js b/dist/playwright/base-config.js deleted file mode 100644 index 32c9bbe..0000000 --- a/dist/playwright/base-config.js +++ /dev/null @@ -1,49 +0,0 @@ -import { defineConfig as baseDefineConfig, } from "@playwright/test"; -import { resolve } from "path"; -/** - * Base Playwright configuration that can be extended by workspace-specific configs. - * Provides sensible defaults for RHDH plugin e2e testing. - */ -export const baseConfig = { - testDir: "./tests", - forbidOnly: !!process.env.CI, - retries: Number(process.env.PLAYWRIGHT_RETRIES ?? 0), - workers: process.env.PLAYWRIGHT_WORKERS || "50%", - outputDir: "node_modules/.cache/e2e-test-results", - timeout: 90_000, - reporter: [ - ["list"], - ["html", { outputFolder: "playwright-report", open: "on-failure" }], - ["json", { outputFile: "playwright-report/results.json" }], - ["junit", { outputFile: "playwright-report/junit-results.xml" }], - [resolve(import.meta.dirname, "../playwright/teardown-reporter.js")], - ], - use: { - ignoreHTTPSErrors: true, - trace: "retain-on-failure", - screenshot: "only-on-failure", - viewport: { width: 1920, height: 1080 }, - video: { - mode: "retain-on-failure", - size: { width: 1280, height: 720 }, - }, - actionTimeout: 10_000, - navigationTimeout: 50_000, - }, - expect: { - timeout: 10_000, - }, - globalSetup: resolve(import.meta.dirname, "../playwright/global-setup.js"), -}; -/** - * Defines a workspace-specific config by merging with base config. - * Only allows overriding the projects configuration. - * @param overrides - Object containing projects to override - * @returns Merged Playwright configuration - */ -export function defineConfig(overrides = {}) { - return baseDefineConfig({ - ...baseConfig, - projects: overrides.projects, - }); -} diff --git a/dist/playwright/fixtures/test.d.ts b/dist/playwright/fixtures/test.d.ts deleted file mode 100644 index 357b089..0000000 --- a/dist/playwright/fixtures/test.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { RHDHDeployment } from "../../deployment/rhdh/index.js"; -import { LoginHelper, UIhelper } from "../helpers/index.js"; -import { runOnce } from "../run-once.js"; -type RHDHDeploymentTestFixtures = { - rhdh: RHDHDeployment; - uiHelper: UIhelper; - loginHelper: LoginHelper; - autoAnnotations: void; -}; -type RHDHDeploymentWorkerFixtures = { - rhdhDeploymentWorker: RHDHDeployment; -}; -export declare const test: import("playwright/test").TestType & { - runOnce: typeof runOnce; -}; -export * from "@playwright/test"; -//# sourceMappingURL=test.d.ts.map \ No newline at end of file diff --git a/dist/playwright/fixtures/test.d.ts.map b/dist/playwright/fixtures/test.d.ts.map deleted file mode 100644 index 9f459f1..0000000 --- a/dist/playwright/fixtures/test.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"test.d.ts","sourceRoot":"","sources":["../../../src/playwright/fixtures/test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAEhE,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAKzC,KAAK,0BAA0B,GAAG;IAChC,IAAI,EAAE,cAAc,CAAC;IACrB,QAAQ,EAAE,QAAQ,CAAC;IACnB,WAAW,EAAE,WAAW,CAAC;IACzB,eAAe,EAAE,IAAI,CAAC;CACvB,CAAC;AAEF,KAAK,4BAA4B,GAAG;IAClC,oBAAoB,EAAE,cAAc,CAAC;CACtC,CAAC;AAgEF,eAAO,MAAM,IAAI;;CAEf,CAAC;AAEH,cAAc,kBAAkB,CAAC"} \ No newline at end of file diff --git a/dist/playwright/fixtures/test.js b/dist/playwright/fixtures/test.js deleted file mode 100644 index 8eab45c..0000000 --- a/dist/playwright/fixtures/test.js +++ /dev/null @@ -1,63 +0,0 @@ -import { RHDHDeployment } from "../../deployment/rhdh/index.js"; -import { test as base } from "@playwright/test"; -import { LoginHelper, UIhelper } from "../helpers/index.js"; -import { runOnce } from "../run-once.js"; -import { $ } from "../../utils/bash.js"; -import { WorkspacePaths } from "../../utils/workspace-paths.js"; -import path from "path"; -const baseTest = base.extend({ - rhdhDeploymentWorker: [ - // eslint-disable-next-line no-empty-pattern - async ({}, use, workerInfo) => { - // Set CWD to the workspace's e2e-tests directory so that relative - // config paths resolve correctly even when Playwright runs from the repo root. - // Each worker is a separate process, so this doesn't affect other workers. - const e2eRoot = path.resolve(workerInfo.project.testDir, ".."); - process.chdir(e2eRoot); - $.cwd = e2eRoot; - const rhdhDeployment = new RHDHDeployment(workerInfo.project.name); - await rhdhDeployment.configure(); - await use(rhdhDeployment); - }, - { scope: "worker", auto: true }, - ], - rhdh: [ - async ({ rhdhDeploymentWorker }, use) => { - await use(rhdhDeploymentWorker); - }, - { auto: true, scope: "test" }, - ], - uiHelper: [ - async ({ page }, use) => { - await use(new UIhelper(page)); - }, - { scope: "test" }, - ], - loginHelper: [ - async ({ page }, use) => { - await use(new LoginHelper(page)); - }, - { scope: "test" }, - ], - baseURL: [ - async ({ rhdhDeploymentWorker }, use) => { - await use(rhdhDeploymentWorker.rhdhUrl); - }, - { scope: "test" }, - ], - autoAnnotations: [ - // eslint-disable-next-line no-empty-pattern - async ({}, use, testInfo) => { - testInfo.annotations.push({ - type: "workspace", - description: path.basename(WorkspacePaths.workspaceRoot), - }, { type: "project", description: testInfo.project.name }); - await use(); - }, - { auto: true, scope: "test" }, - ], -}); -export const test = Object.assign(baseTest, { - runOnce, -}); -export * from "@playwright/test"; diff --git a/dist/playwright/global-setup.d.ts b/dist/playwright/global-setup.d.ts deleted file mode 100644 index 9db6b81..0000000 --- a/dist/playwright/global-setup.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Global setup for Playwright tests. - * This file runs once before all tests. - */ -import { type FullConfig } from "@playwright/test"; -export default function globalSetup(config: FullConfig): Promise; -//# sourceMappingURL=global-setup.d.ts.map \ No newline at end of file diff --git a/dist/playwright/global-setup.d.ts.map b/dist/playwright/global-setup.d.ts.map deleted file mode 100644 index 90d2931..0000000 --- a/dist/playwright/global-setup.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"global-setup.d.ts","sourceRoot":"","sources":["../../src/playwright/global-setup.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AA6EnD,wBAA8B,WAAW,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAQ3E"} \ No newline at end of file diff --git a/dist/playwright/global-setup.js b/dist/playwright/global-setup.js deleted file mode 100644 index 802c89b..0000000 --- a/dist/playwright/global-setup.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Global setup for Playwright tests. - * This file runs once before all tests. - */ -import dotenv from "dotenv"; -import { resolve } from "path"; -import { KubernetesClientHelper } from "../utils/kubernetes-client.js"; -import { $ } from "../utils/bash.js"; -import { KeycloakHelper } from "../deployment/keycloak/index.js"; -import { DEFAULT_KEYCLOAK_CONFIG, DEFAULT_RHDH_CLIENT, DEFAULT_USERS, } from "../deployment/keycloak/constants.js"; -import { loadLocalVaultSecrets } from "../utils/vault.js"; -const REQUIRED_BINARIES = ["oc", "kubectl", "helm"]; -async function checkRequiredBinaries() { - const missingBinaries = []; - for (const binary of REQUIRED_BINARIES) { - try { - await $ `command -v ${binary} > /dev/null 2>&1`; - } - catch { - missingBinaries.push(binary); - } - } - if (missingBinaries.length > 0) { - throw new Error(`ERROR: Missing required binaries: ${missingBinaries.join(", ")}. Please install them before running tests.`); - } -} -async function setClusterRouterBaseEnv() { - const k8sClient = new KubernetesClientHelper(); - process.env.K8S_CLUSTER_ROUTER_BASE = - await k8sClient.getClusterIngressDomain(); - console.log(`Cluster router base: ${process.env.K8S_CLUSTER_ROUTER_BASE}`); -} -async function deployKeycloak() { - if (process.env.SKIP_KEYCLOAK_DEPLOYMENT === "true") { - console.log("Skipping Keycloak deployment"); - return; - } - console.log("Set SKIP_KEYCLOAK_DEPLOYMENT=true if test doesn't require keycloak/oidc as auth provider"); - const keycloak = new KeycloakHelper({ namespace: "rhdh-keycloak" }); - // Check if Keycloak is already running - if (await keycloak.isRunning()) { - console.log("Keycloak is already running, skipping deployment"); - } - else { - await keycloak.deploy(); - await keycloak.configureForRHDH(); - } - // Set environment variables for RHDH integration - const realm = DEFAULT_KEYCLOAK_CONFIG.realm; - process.env.KEYCLOAK_CLIENT_SECRET = DEFAULT_RHDH_CLIENT.clientSecret; - process.env.KEYCLOAK_CLIENT_ID = DEFAULT_RHDH_CLIENT.clientId; - process.env.KEYCLOAK_REALM = realm; - process.env.KEYCLOAK_LOGIN_REALM = realm; - process.env.KEYCLOAK_METADATA_URL = `${keycloak.keycloakUrl}/realms/${realm}`; - process.env.KEYCLOAK_BASE_URL = keycloak.keycloakUrl; - console.table({ - keycloakURL: keycloak.keycloakUrl, - adminUser: keycloak.deploymentConfig.adminUser, - adminPassword: keycloak.deploymentConfig.adminPassword, - testUsername: DEFAULT_USERS[0].username, - testPassword: DEFAULT_USERS[0].password, - }); -} -export default async function globalSetup(config) { - console.log("Running global setup..."); - await checkRequiredBinaries(); - await loadLocalVaultSecrets(); - loadDotenvFromProjects(config); - await setClusterRouterBaseEnv(); - await deployKeycloak(); - console.log("Global setup completed successfully"); -} -/** - * Loads .env files from each project's e2e-tests directory. - * Uses `override: true` so local .env values take priority over Vault secrets. - */ -function loadDotenvFromProjects(config) { - const seen = new Set(); - for (const project of config.projects) { - // testDir points to e2e-tests/tests, go up one level to e2e-tests/ - const e2eRoot = resolve(project.testDir, ".."); - if (seen.has(e2eRoot)) - continue; - seen.add(e2eRoot); - dotenv.config({ path: resolve(e2eRoot, ".env"), override: true }); - } -} diff --git a/dist/playwright/helpers/accessibility.d.ts b/dist/playwright/helpers/accessibility.d.ts deleted file mode 100644 index d28360a..0000000 --- a/dist/playwright/helpers/accessibility.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Page } from "@playwright/test"; -export interface AccessibilityTestOptions { - /** Custom name for the attached results file. Defaults to "accessibility-scan-results.violations.json" */ - attachName?: string; - /** Whether to assert that there are no violations. Defaults to true */ - assertNoViolations?: boolean; - /** WCAG tags to test against. Defaults to ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] */ - wcagTags?: string[]; - /** Rules to disable during the scan. Defaults to ["color-contrast"] */ - disabledRules?: string[]; -} -export declare function runAccessibilityTests(page: Page, options?: AccessibilityTestOptions): Promise; -//# sourceMappingURL=accessibility.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/accessibility.d.ts.map b/dist/playwright/helpers/accessibility.d.ts.map deleted file mode 100644 index f604ba3..0000000 --- a/dist/playwright/helpers/accessibility.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"accessibility.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/accessibility.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAG7C,MAAM,WAAW,wBAAwB;IACvC,0GAA0G;IAC1G,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,0FAA0F;IAC1F,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,uEAAuE;IACvE,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AASD,wBAAsB,qBAAqB,CACzC,IAAI,EAAE,IAAI,EACV,OAAO,GAAE,wBAA6B,0CAuBvC"} \ No newline at end of file diff --git a/dist/playwright/helpers/accessibility.js b/dist/playwright/helpers/accessibility.js deleted file mode 100644 index 6a6d097..0000000 --- a/dist/playwright/helpers/accessibility.js +++ /dev/null @@ -1,24 +0,0 @@ -import AxeBuilder from "@axe-core/playwright"; -import { expect, test } from "@playwright/test"; -const DEFAULT_OPTIONS = { - attachName: "accessibility-scan-results.violations.json", - assertNoViolations: true, - wcagTags: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"], - disabledRules: ["color-contrast"], -}; -export async function runAccessibilityTests(page, options = {}) { - const config = { ...DEFAULT_OPTIONS, ...options }; - const testInfo = test.info(); - const accessibilityScanResults = await new AxeBuilder({ page }) - .withTags(config.wcagTags) - .disableRules(config.disabledRules) - .analyze(); - await testInfo.attach(config.attachName, { - body: JSON.stringify(accessibilityScanResults.violations, null, 2), - contentType: "application/json", - }); - if (config.assertNoViolations) { - expect(accessibilityScanResults.violations, `Found ${accessibilityScanResults.violations.length} accessibility violation(s)`).toHaveLength(0); - } - return accessibilityScanResults; -} diff --git a/dist/playwright/helpers/api-endpoints.d.ts b/dist/playwright/helpers/api-endpoints.d.ts deleted file mode 100644 index 35d2f8b..0000000 --- a/dist/playwright/helpers/api-endpoints.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -export declare const GITHUB_API_ENDPOINTS: { - pull: (owner: string, repo: string, state: "open" | "closed" | "all") => string; - issues: (state: string) => string; - workflowRuns: string; - getOrg: (owner: string) => string; - createRepo: (owner: string) => string; - getRepo: (owner: string, repo: string) => string; - deleteRepo: (owner: string, repo: string) => string; - mergePR: (owner: string, repoName: string, pullNumber: number) => string; - pullFiles: (owner: string, repoName: string, pr: number) => string; - contents: (owner: string, repoName: string) => string; -}; -//# sourceMappingURL=api-endpoints.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/api-endpoints.d.ts.map b/dist/playwright/helpers/api-endpoints.d.ts.map deleted file mode 100644 index b79aedd..0000000 --- a/dist/playwright/helpers/api-endpoints.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"api-endpoints.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/api-endpoints.ts"],"names":[],"mappings":"AASA,eAAO,MAAM,oBAAoB;kBACjB,MAAM,QAAQ,MAAM,SAAS,MAAM,GAAG,QAAQ,GAAG,KAAK;oBAGpD,MAAM;;oBARE,MAAM;wBAeV,MAAM;qBAjBD,MAAM,QAAQ,MAAM;wBAApB,MAAM,QAAQ,MAAM;qBAuB5B,MAAM,YAAY,MAAM,cAAc,MAAM;uBAG1C,MAAM,YAAY,MAAM,MAAM,MAAM;sBAGrC,MAAM,YAAY,MAAM;CAE3C,CAAC"} \ No newline at end of file diff --git a/dist/playwright/helpers/api-endpoints.js b/dist/playwright/helpers/api-endpoints.js deleted file mode 100644 index f665e5c..0000000 --- a/dist/playwright/helpers/api-endpoints.js +++ /dev/null @@ -1,17 +0,0 @@ -const baseApiUrl = "https://api.github.com"; -const perPage = 100; -const getRepoUrl = (owner, repo) => `${baseApiUrl}/repos/${owner}/${repo}`; -const getOrgUrl = (owner) => `${baseApiUrl}/orgs/${owner}`; -const backstageShowcaseAPI = getRepoUrl("janus-idp", "backstage-showcase"); -export const GITHUB_API_ENDPOINTS = { - pull: (owner, repo, state) => `${getRepoUrl(owner, repo)}/pulls?per_page=${perPage}&state=${state}`, - issues: (state) => `${backstageShowcaseAPI}/issues?per_page=${perPage}&sort=updated&state=${state}`, - workflowRuns: `${backstageShowcaseAPI}/actions/runs?per_page=${perPage}`, - getOrg: getOrgUrl, - createRepo: (owner) => `${getOrgUrl(owner)}/repos`, - getRepo: getRepoUrl, - deleteRepo: getRepoUrl, - mergePR: (owner, repoName, pullNumber) => `${getRepoUrl(owner, repoName)}/pulls/${pullNumber}/merge`, - pullFiles: (owner, repoName, pr) => `${getRepoUrl(owner, repoName)}/pulls/${pr}/files`, - contents: (owner, repoName) => `${getRepoUrl(owner, repoName)}/contents`, -}; diff --git a/dist/playwright/helpers/api-helper.d.ts b/dist/playwright/helpers/api-helper.d.ts deleted file mode 100644 index 2c48028..0000000 --- a/dist/playwright/helpers/api-helper.d.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { APIResponse } from "@playwright/test"; -export declare class APIHelper { - private static githubAPIVersion; - private staticToken; - private baseUrl; - useStaticToken: boolean; - static githubRequest(method: string, url: string, body?: string | object): Promise; - static getGithubPaginatedRequest(url: string, pageNo?: number, response?: unknown[]): Promise; - static createGitHubRepo(owner: string, repoName: string): Promise; - static createGitHubRepoWithFile(owner: string, repoName: string, filename: string, fileContent: string): Promise; - static createFileInRepo(owner: string, repoName: string, filePath: string, content: string, commitMessage: string, branch?: string): Promise; - static initCommit(owner: string, repo: string, branch?: string): Promise; - static deleteGitHubRepo(owner: string, repoName: string): Promise; - static mergeGitHubPR(owner: string, repoName: string, pullNumber: number): Promise; - static getGitHubPRs(owner: string, repoName: string, state: "open" | "closed" | "all", paginated?: boolean): Promise; - static getfileContentFromPR(owner: string, repoName: string, pr: number, filename: string): Promise; - getGuestToken(): Promise; - getGuestAuthHeader(): Promise<{ - [key: string]: string; - }>; - setStaticToken(token: string): Promise; - setBaseUrl(url: string): Promise; - static apiRequestWithStaticToken(method: string, url: string, staticToken: string, body?: string | object): Promise; - getAllCatalogUsersFromAPI(): Promise; - getAllCatalogLocationsFromAPI(): Promise; - getAllCatalogGroupsFromAPI(): Promise; - getGroupEntityFromAPI(group: string): Promise; - getCatalogUserFromAPI(user: string): Promise; - deleteUserEntityFromAPI(user: string): Promise<(() => string) | undefined>; - getCatalogGroupFromAPI(group: string): Promise; - deleteGroupEntityFromAPI(group: string): Promise<() => string>; - scheduleEntityRefreshFromAPI(entity: string, kind: string, token: string): Promise; - /** - * Fetches the UID of an entity by its name from the Backstage catalog. - * - * @param name - The name of the entity (e.g., 'hello-world-2'). - * @returns The UID string if found, otherwise undefined. - */ - static getEntityUidByName(name: string): Promise; - /** - * Deletes a location from the Backstage catalog by its UID. - * - * @param uid - The UID of the location to delete. - * @returns The status code of the delete operation. - */ - static deleteLocationByUid(uid: string): Promise; - /** - * Fetches the UID of a Template entity by its name and namespace from the Backstage catalog. - * - * @param name - The name of the template entity (e.g., 'hello-world-2'). - * @param namespace - The namespace of the template entity (default: 'default'). - * @returns The UID string if found, otherwise undefined. - */ - static getTemplateEntityUidByName(name: string, namespace?: string): Promise; - /** - * Deletes an entity location from the Backstage catalog by its ID. - * - * @param id - The ID of the entity to delete. - * @returns The status code of the delete operation. - */ - static deleteEntityLocationById(id: string): Promise; - /** - * Registers a new location in the Backstage catalog. - * - * @param target - The target URL of the location to register. - * @returns The status code of the registration operation. - */ - static registerLocation(target: string): Promise; - /** - * Fetches the ID of a location from the Backstage catalog by its target URL. - * - * @param target - The target URL of the location to search for. - * @returns The ID string if found, otherwise undefined. - */ - static getLocationIdByTarget(target: string): Promise; -} -//# sourceMappingURL=api-helper.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/api-helper.d.ts.map b/dist/playwright/helpers/api-helper.d.ts.map deleted file mode 100644 index 3c011db..0000000 --- a/dist/playwright/helpers/api-helper.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"api-helper.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/api-helper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAKpD,qBAAa,SAAS;IACpB,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAgB;IAC/C,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,OAAO,CAAc;IAC7B,cAAc,UAAS;WAEV,aAAa,CACxB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACX,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GACrB,OAAO,CAAC,WAAW,CAAC;WAuBV,yBAAyB,CACpC,GAAG,EAAE,MAAM,EACX,MAAM,GAAE,MAAU,EAClB,QAAQ,GAAE,OAAO,EAAO,GACvB,OAAO,CAAC,OAAO,EAAE,CAAC;WAmBR,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;WAYhD,wBAAwB,CACnC,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM;WAgCR,gBAAgB,CAC3B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,aAAa,EAAE,MAAM,EACrB,MAAM,SAAS;WAeJ,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,SAAS;WAgBvD,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;WAOhD,aAAa,CACxB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM;WAQP,YAAY,CACvB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,GAAG,QAAQ,GAAG,KAAK,EAChC,SAAS,UAAQ;WAUN,oBAAoB,CAC/B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,MAAM,CAAC;IAcZ,aAAa,IAAI,OAAO,CAAC,MAAM,CAAC;IAQhC,kBAAkB,IAAI,OAAO,CAAC;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IAQxD,cAAc,CAAC,KAAK,EAAE,MAAM;IAK5B,UAAU,CAAC,GAAG,EAAE,MAAM;WAIf,yBAAyB,CACpC,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,MAAM,EACnB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GACrB,OAAO,CAAC,WAAW,CAAC;IAejB,yBAAyB;IAWzB,6BAA6B;IAW7B,0BAA0B;IAW1B,qBAAqB,CAAC,KAAK,EAAE,MAAM;IAWnC,qBAAqB,CAAC,IAAI,EAAE,MAAM;IAWlC,uBAAuB,CAAC,IAAI,EAAE,MAAM;IAepC,sBAAsB,CAAC,KAAK,EAAE,MAAM;IAWpC,wBAAwB,CAAC,KAAK,EAAE,MAAM;IAYtC,4BAA4B,CAChC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM;IAaf;;;;;OAKG;WACU,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAY1E;;;;;OAKG;WACU,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAQ9D;;;;;;OAMG;WACU,0BAA0B,CACrC,IAAI,EAAE,MAAM,EACZ,SAAS,GAAE,MAAkB,GAC5B,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAe9B;;;;;OAKG;WACU,wBAAwB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAQlE;;;;;OAKG;WACU,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAgB9D;;;;;OAKG;WACU,qBAAqB,CAChC,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;CAe/B"} \ No newline at end of file diff --git a/dist/playwright/helpers/api-helper.js b/dist/playwright/helpers/api-helper.js deleted file mode 100644 index 1a8714d..0000000 --- a/dist/playwright/helpers/api-helper.js +++ /dev/null @@ -1,295 +0,0 @@ -import { request, expect } from "@playwright/test"; -import { GITHUB_API_ENDPOINTS } from "./api-endpoints.js"; -export class APIHelper { - static githubAPIVersion = "2022-11-28"; - staticToken = ""; - baseUrl = ""; - useStaticToken = false; - static async githubRequest(method, url, body) { - const context = await request.newContext(); - const options = { - method: method, - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${process.env.VAULT_GITHUB_USER_TOKEN}`, - "X-GitHub-Api-Version": this.githubAPIVersion, - }, - }; - if (body) { - options.data = body; - } - const response = await context.fetch(url, options); - return response; - } - static async getGithubPaginatedRequest(url, pageNo = 1, response = []) { - const fullUrl = `${url}&page=${pageNo}`; - const result = await this.githubRequest("GET", fullUrl); - const body = await result.json(); - if (!Array.isArray(body)) { - throw new Error(`Expected array but got ${typeof body}: ${JSON.stringify(body)}`); - } - if (body.length === 0) { - return response; - } - response = [...response, ...body]; - return await this.getGithubPaginatedRequest(url, pageNo + 1, response); - } - static async createGitHubRepo(owner, repoName) { - const response = await APIHelper.githubRequest("POST", GITHUB_API_ENDPOINTS.createRepo(owner), { - name: repoName, - private: false, - }); - expect(response.status() === 201 || response.ok()).toBeTruthy(); - } - static async createGitHubRepoWithFile(owner, repoName, filename, fileContent) { - // Create the repository - await APIHelper.createGitHubRepo(owner, repoName); - // Wait until repository is created - await expect - .poll(async () => { - const res = await APIHelper.githubRequest("GET", GITHUB_API_ENDPOINTS.getRepo(owner, repoName)); - return res.status(); - }, { - timeout: 30_000, - intervals: [5000], - }) - .toBe(200); - // Add the specified file - await APIHelper.createFileInRepo(owner, repoName, filename, fileContent, `Add ${filename} file`); - } - static async createFileInRepo(owner, repoName, filePath, content, commitMessage, branch = "main") { - const encodedContent = Buffer.from(content).toString("base64"); - const response = await APIHelper.githubRequest("PUT", `${GITHUB_API_ENDPOINTS.contents(owner, repoName)}/${filePath}`, { - message: commitMessage, - content: encodedContent, - branch: branch, - }); - expect(response.status() === 201 || response.ok()).toBeTruthy(); - } - static async initCommit(owner, repo, branch = "main") { - const content = Buffer.from("This is the initial commit for the repository.").toString("base64"); - const response = await APIHelper.githubRequest("PUT", `${GITHUB_API_ENDPOINTS.contents(owner, repo)}/initial-commit.md`, { - message: "Initial commit", - content: content, - branch: branch, - }); - expect(response.status() === 201 || response.ok()).toBeTruthy(); - } - static async deleteGitHubRepo(owner, repoName) { - await APIHelper.githubRequest("DELETE", GITHUB_API_ENDPOINTS.deleteRepo(owner, repoName)); - } - static async mergeGitHubPR(owner, repoName, pullNumber) { - await APIHelper.githubRequest("PUT", GITHUB_API_ENDPOINTS.mergePR(owner, repoName, pullNumber)); - } - static async getGitHubPRs(owner, repoName, state, paginated = false) { - const url = GITHUB_API_ENDPOINTS.pull(owner, repoName, state); - if (paginated) { - return await APIHelper.getGithubPaginatedRequest(url); - } - const response = await APIHelper.githubRequest("GET", url); - return response.json(); - } - static async getfileContentFromPR(owner, repoName, pr, filename) { - const response = await APIHelper.githubRequest("GET", GITHUB_API_ENDPOINTS.pullFiles(owner, repoName, pr)); - const fileRawUrl = (await response.json()).find((file) => file.filename === filename).raw_url; - const rawFileContent = await (await APIHelper.githubRequest("GET", fileRawUrl)).text(); - return rawFileContent; - } - async getGuestToken() { - const context = await request.newContext(); - const response = await context.post("/api/auth/guest/refresh"); - expect(response.status()).toBe(200); - const data = await response.json(); - return data.backstageIdentity.token; - } - async getGuestAuthHeader() { - const token = await this.getGuestToken(); - const headers = { - Authorization: `Bearer ${token}`, - }; - return headers; - } - async setStaticToken(token) { - this.useStaticToken = true; - this.staticToken = "Bearer " + token; - } - async setBaseUrl(url) { - this.baseUrl = url; - } - static async apiRequestWithStaticToken(method, url, staticToken, body) { - const context = await request.newContext(); - const options = { - method: method, - headers: { - Accept: "application/json", - Authorization: `${staticToken}`, - }, - ...(body && { data: body }), - }; - const response = await context.fetch(url, options); - return response; - } - async getAllCatalogUsersFromAPI() { - const url = `${this.baseUrl}/api/catalog/entities/by-query?orderField=metadata.name%2Casc&filter=kind%3Duser`; - const token = this.useStaticToken ? this.staticToken : ""; - const response = await APIHelper.apiRequestWithStaticToken("GET", url, token); - return response.json(); - } - async getAllCatalogLocationsFromAPI() { - const url = `${this.baseUrl}/api/catalog/entities/by-query?orderField=metadata.name%2Casc&filter=kind%3Dlocation`; - const token = this.useStaticToken ? this.staticToken : ""; - const response = await APIHelper.apiRequestWithStaticToken("GET", url, token); - return response.json(); - } - async getAllCatalogGroupsFromAPI() { - const url = `${this.baseUrl}/api/catalog/entities/by-query?orderField=metadata.name%2Casc&filter=kind%3Dgroup`; - const token = this.useStaticToken ? this.staticToken : ""; - const response = await APIHelper.apiRequestWithStaticToken("GET", url, token); - return response.json(); - } - async getGroupEntityFromAPI(group) { - const url = `${this.baseUrl}/api/catalog/entities/by-name/group/default/${group}`; - const token = this.useStaticToken ? this.staticToken : ""; - const response = await APIHelper.apiRequestWithStaticToken("GET", url, token); - return response.json(); - } - async getCatalogUserFromAPI(user) { - const url = `${this.baseUrl}/api/catalog/entities/by-name/user/default/${user}`; - const token = this.useStaticToken ? this.staticToken : ""; - const response = await APIHelper.apiRequestWithStaticToken("GET", url, token); - return response.json(); - } - async deleteUserEntityFromAPI(user) { - const r = await this.getCatalogUserFromAPI(user); - if (!r.metadata?.uid) { - return; - } - const url = `${this.baseUrl}/api/catalog/entities/by-uid/${r.metadata.uid}`; - const token = this.useStaticToken ? this.staticToken : ""; - const response = await APIHelper.apiRequestWithStaticToken("DELETE", url, token); - return response.statusText; - } - async getCatalogGroupFromAPI(group) { - const url = `${this.baseUrl}/api/catalog/entities/by-name/group/default/${group}`; - const token = this.useStaticToken ? this.staticToken : ""; - const response = await APIHelper.apiRequestWithStaticToken("GET", url, token); - return response.json(); - } - async deleteGroupEntityFromAPI(group) { - const r = await this.getCatalogGroupFromAPI(group); - const url = `${this.baseUrl}/api/catalog/entities/by-uid/${r.metadata.uid}`; - const token = this.useStaticToken ? this.staticToken : ""; - const response = await APIHelper.apiRequestWithStaticToken("DELETE", url, token); - return response.statusText; - } - async scheduleEntityRefreshFromAPI(entity, kind, token) { - const url = `${this.baseUrl}/api/catalog/refresh`; - const reqBody = { entityRef: `${kind}:default/${entity}` }; - const responseRefresh = await APIHelper.apiRequestWithStaticToken("POST", url, token, reqBody); - return responseRefresh.status(); - } - /** - * Fetches the UID of an entity by its name from the Backstage catalog. - * - * @param name - The name of the entity (e.g., 'hello-world-2'). - * @returns The UID string if found, otherwise undefined. - */ - static async getEntityUidByName(name) { - const baseUrl = process.env.RHDH_BASE_URL; - const url = `${baseUrl}/api/catalog/entities/by-name/template/default/${name}`; - const context = await request.newContext(); - const response = await context.get(url); - if (response.status() !== 200) { - return undefined; - } - const data = await response.json(); - return data?.metadata?.uid; - } - /** - * Deletes a location from the Backstage catalog by its UID. - * - * @param uid - The UID of the location to delete. - * @returns The status code of the delete operation. - */ - static async deleteLocationByUid(uid) { - const baseUrl = process.env.RHDH_BASE_URL; - const url = `${baseUrl}/api/catalog/locations/${uid}`; - const context = await request.newContext(); - const response = await context.delete(url); - return response.status(); - } - /** - * Fetches the UID of a Template entity by its name and namespace from the Backstage catalog. - * - * @param name - The name of the template entity (e.g., 'hello-world-2'). - * @param namespace - The namespace of the template entity (default: 'default'). - * @returns The UID string if found, otherwise undefined. - */ - static async getTemplateEntityUidByName(name, namespace = "default") { - const baseUrl = process.env.RHDH_BASE_URL; - const url = `${baseUrl}/api/catalog/locations/by-entity/template/${namespace}/${name}`; - const context = await request.newContext(); - const response = await context.get(url); - if (response.status() === 200) { - const data = await response.json(); - return data?.metadata?.uid; - } - if (response.status() === 404) { - return undefined; - } - return undefined; - } - /** - * Deletes an entity location from the Backstage catalog by its ID. - * - * @param id - The ID of the entity to delete. - * @returns The status code of the delete operation. - */ - static async deleteEntityLocationById(id) { - const baseUrl = process.env.RHDH_BASE_URL; - const url = `${baseUrl}/api/catalog/locations/${id}`; - const context = await request.newContext(); - const response = await context.delete(url); - return response.status(); - } - /** - * Registers a new location in the Backstage catalog. - * - * @param target - The target URL of the location to register. - * @returns The status code of the registration operation. - */ - static async registerLocation(target) { - const baseUrl = process.env.RHDH_BASE_URL; - const url = `${baseUrl}/api/catalog/locations`; - const context = await request.newContext(); - const response = await context.post(url, { - data: { - type: "url", - target, - }, - headers: { - "Content-Type": "application/json", - }, - }); - return response.status(); - } - /** - * Fetches the ID of a location from the Backstage catalog by its target URL. - * - * @param target - The target URL of the location to search for. - * @returns The ID string if found, otherwise undefined. - */ - static async getLocationIdByTarget(target) { - const baseUrl = process.env.RHDH_BASE_URL; - const url = `${baseUrl}/api/catalog/locations`; - const context = await request.newContext(); - const response = await context.get(url); - if (response.status() !== 200) { - return undefined; - } - const data = await response.json(); - // data is expected to be an array of objects with a 'data' property - const location = (Array.isArray(data) ? data : []).find((entry) => entry?.data?.target === target); - return location?.data?.id; - } -} diff --git a/dist/playwright/helpers/auth-api-helper.d.ts b/dist/playwright/helpers/auth-api-helper.d.ts deleted file mode 100644 index b148101..0000000 --- a/dist/playwright/helpers/auth-api-helper.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Page } from "@playwright/test"; -export declare class AuthApiHelper { - private readonly page; - constructor(page: Page); - getToken(provider?: string, environment?: string): Promise; -} -//# sourceMappingURL=auth-api-helper.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/auth-api-helper.d.ts.map b/dist/playwright/helpers/auth-api-helper.d.ts.map deleted file mode 100644 index d08a61d..0000000 --- a/dist/playwright/helpers/auth-api-helper.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"auth-api-helper.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/auth-api-helper.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAGxC,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAO;gBAEhB,IAAI,EAAE,IAAI;IAIhB,QAAQ,CACZ,QAAQ,GAAE,MAAe,EACzB,WAAW,GAAE,MAAqB;CA8BrC"} \ No newline at end of file diff --git a/dist/playwright/helpers/auth-api-helper.js b/dist/playwright/helpers/auth-api-helper.js deleted file mode 100644 index f72f1a9..0000000 --- a/dist/playwright/helpers/auth-api-helper.js +++ /dev/null @@ -1,31 +0,0 @@ -// here, we spy on the request to get the Backstage token to use APIs -export class AuthApiHelper { - page; - constructor(page) { - this.page = page; - } - async getToken(provider = "oidc", environment = "production") { - try { - const response = await this.page.request.get(`/api/auth/${provider}/refresh?optional=&scope=&env=${environment}`, { - headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention - "x-requested-with": "XMLHttpRequest", - }, - }); - if (!response.ok()) { - throw new Error(`HTTP error! Status: ${response.status()}`); - } - const body = await response.json(); - if (typeof body?.backstageIdentity?.token === "string") { - return body.backstageIdentity.token; - } - else { - throw new TypeError("Token not found in response body"); - } - } - catch (error) { - console.error("Failed to retrieve the token:", error); - throw error; - } - } -} diff --git a/dist/playwright/helpers/common.d.ts b/dist/playwright/helpers/common.d.ts deleted file mode 100644 index c4a06e1..0000000 --- a/dist/playwright/helpers/common.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { UIhelper } from "./ui-helper.js"; -import type { Browser, Page, TestInfo } from "@playwright/test"; -export declare class LoginHelper { - page: Page; - uiHelper: UIhelper; - constructor(page: Page); - loginAsGuest(): Promise; - signOut(): Promise; - private logintoGithub; - logintoKeycloak(popup: Page, userid: string, password: string): Promise; - loginAsKeycloakUser(userid?: string, password?: string): Promise; - loginAsGithubUser(userid?: string): Promise; - checkAndReauthorizeGithubApp(): Promise; - googleSignIn(email: string): Promise; - checkAndClickOnGHloginPopup(force?: boolean): Promise; - getButtonSelector(label: string): string; - getLoginBtnSelector(): string; - clickOnGHloginPopup(): Promise; - getGitHub2FAOTP(userid: string): string; - getGoogle2FAOTP(): string; - keycloakLogin(username: string, password: string): Promise<"Already logged in" | "Login successful" | "User does not exist">; - private handleGitHubPopupLogin; - githubLogin(username: string, password: string, twofactor: string): Promise; - githubLoginFromSettingsPage(username: string, password: string, twofactor: string): Promise; - microsoftAzureLogin(username: string, password: string): Promise<"Already logged in" | "Login successful" | "User does not exist">; -} -export declare function setupBrowser(browser: Browser, testInfo: TestInfo): Promise<{ - page: Page; - context: import("playwright-core").BrowserContext; -}>; -//# sourceMappingURL=common.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/common.d.ts.map b/dist/playwright/helpers/common.d.ts.map deleted file mode 100644 index 8375503..0000000 --- a/dist/playwright/helpers/common.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/common.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAG1C,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAOhE,qBAAa,WAAW;IACtB,IAAI,EAAE,IAAI,CAAC;IACX,QAAQ,EAAE,QAAQ,CAAC;gBAEP,IAAI,EAAE,IAAI;IAKhB,YAAY;IAcZ,OAAO;YAMC,aAAa;IAyCrB,eAAe,CAAC,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;IAO7D,mBAAmB,CACvB,MAAM,GAAE,MAAkC,EAC1C,QAAQ,GAAE,MAAkC;IAWxC,iBAAiB,CACrB,MAAM,GAAE,MAA+C;IA+DnD,4BAA4B;IAqB5B,YAAY,CAAC,KAAK,EAAE,MAAM;IA2B1B,2BAA2B,CAAC,KAAK,UAAQ;IAU/C,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM;IAIxC,mBAAmB,IAAI,MAAM;IAIvB,mBAAmB;IAgBzB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM;IAevC,eAAe,IAAI,MAAM;IAKnB,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;YAqCxC,sBAAsB;IAoD9B,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM;IAYjE,2BAA2B,CAC/B,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM;IAYb,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;CA2C7D;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ;;;GAWtE"} \ No newline at end of file diff --git a/dist/playwright/helpers/common.js b/dist/playwright/helpers/common.js deleted file mode 100644 index c75d754..0000000 --- a/dist/playwright/helpers/common.js +++ /dev/null @@ -1,366 +0,0 @@ -import { UIhelper } from "./ui-helper.js"; -import { authenticator } from "otplib"; -import { test, expect } from "@playwright/test"; -import { SETTINGS_PAGE_COMPONENTS } from "../page-objects/page-obj.js"; -import { UI_HELPER_ELEMENTS } from "../page-objects/global-obj.js"; -import * as path from "path"; -import * as fs from "fs"; -import { DEFAULT_USERS } from "../../deployment/keycloak/constants.js"; -export class LoginHelper { - page; - uiHelper; - constructor(page) { - this.page = page; - this.uiHelper = new UIhelper(page); - } - async loginAsGuest() { - await this.page.goto("/"); - await this.uiHelper.waitForLoad(240000); - // TODO - Remove it after https://issues.redhat.com/browse/RHIDP-2043. A Dynamic plugin for Guest Authentication Provider needs to be created - this.page.on("dialog", async (dialog) => { - console.log(`Dialog message: ${dialog.message()}`); - await dialog.accept(); - }); - await this.uiHelper.verifyHeading("Select a sign-in method"); - await this.uiHelper.clickButton("Enter"); - await this.page.waitForSelector("nav a", { timeout: 10_000 }); - } - async signOut() { - await this.page.click(SETTINGS_PAGE_COMPONENTS.userSettingsMenu); - await this.page.click(SETTINGS_PAGE_COMPONENTS.signOut); - await this.uiHelper.verifyHeading("Select a sign-in method"); - } - async logintoGithub(userid) { - await this.page.goto("https://github.com/login"); - await this.page.waitForSelector("#login_field"); - await this.page.fill("#login_field", userid); - switch (userid) { - case process.env.VAULT_GH_USER_ID: - await this.page.fill("#password", process.env.VAULT_GH_USER_PASS); - break; - case process.env.VAULT_GH_USER2_ID: - await this.page.fill("#password", process.env.VAULT_GH_USER2_PASS); - break; - default: - throw new Error("Invalid User ID"); - } - await this.page.click('[value="Sign in"]'); - await this.page.fill("#app_totp", this.getGitHub2FAOTP(userid)); - test.setTimeout(260_000); - if ((await this.uiHelper.isTextVisible("The two-factor code you entered has already been used")) || - (await this.uiHelper.isTextVisible("too many codes have been submitted", 3000))) { - await this.page.waitForTimeout(60000); - await this.page.fill("#app_totp", this.getGitHub2FAOTP(userid)); - } - await this.page.waitForTimeout(30_000); - } - async logintoKeycloak(popup, userid, password) { - await popup.waitForLoadState(); - await popup.locator("#username").fill(userid); - await popup.locator("#password").fill(password); - await popup.locator("#kc-login").click(); - } - async loginAsKeycloakUser(userid = DEFAULT_USERS[0].username, password = DEFAULT_USERS[0].password) { - await this.page.goto("/"); - await this.uiHelper.waitForLoad(240000); - const popupPromise = this.page.waitForEvent("popup"); - await this.uiHelper.clickButton("Sign In"); - const popup = await popupPromise; - await this.logintoKeycloak(popup, userid, password); - await this.page.waitForSelector("nav a", { timeout: 10_000 }); - } - async loginAsGithubUser(userid = process.env.VAULT_GH_USER_ID) { - const sessionFileName = `authState_${userid}.json`; - // Check if a session file for this specific user already exists - if (fs.existsSync(sessionFileName)) { - // Load and reuse existing authentication state - const cookies = JSON.parse(fs.readFileSync(sessionFileName, "utf-8")).cookies; - await this.page.context().addCookies(cookies); - console.log(`Reusing existing authentication state for user: ${userid}`); - await this.page.goto("/"); - await this.uiHelper.waitForLoad(12000); - await this.uiHelper.clickButton("Sign In"); - // Wait for either: sidebar appears (auto-login) or popup opens (needs auth) - const navPromise = this.page - .waitForSelector("nav a", { timeout: 15_000 }) - .then(() => "nav") - .catch(() => null); - const popupPromise = this.page - .waitForEvent("popup", { timeout: 15_000 }) - .then((popup) => ({ popup })) - .catch(() => null); - const result = await Promise.race([navPromise, popupPromise]); - if (result === null) { - throw new Error("GitHub login failed: neither sidebar nor popup appeared after Sign In β€” session file may be stale"); - } - if (typeof result === "object" && "popup" in result) { - // Popup opened β€” handle reauthorization - // TODO this is the same code as checkAndReauthorizeGithubApp's promise body - const popup = result.popup; - await popup.waitForLoadState(); - for (let attempts = 0; attempts < 10 && !popup.isClosed(); attempts++) { - await this.page.waitForTimeout(1000); - } - const locator = popup.locator("button.js-oauth-authorize-btn"); - if (!popup.isClosed() && (await locator.isVisible())) { - await popup.locator("body").click(); - await locator.waitFor(); - await locator.click(); - } - } - } - else { - // Perform login if no session file exists, then save the state - await this.logintoGithub(userid); - await this.page.goto("/"); - await this.uiHelper.waitForLoad(240000); - await this.uiHelper.clickButton("Sign In"); - await this.checkAndReauthorizeGithubApp(); - await this.page.waitForSelector("nav a", { timeout: 10_000 }); - await this.page.context().storageState({ path: sessionFileName }); - console.log(`Authentication state saved for user: ${userid}`); - } - } - async checkAndReauthorizeGithubApp() { - await new Promise((resolve) => { - this.page.once("popup", async (popup) => { - await popup.waitForLoadState(); - // Check for popup closure for up to 10 seconds before proceeding - for (let attempts = 0; attempts < 10 && !popup.isClosed(); attempts++) { - await this.page.waitForTimeout(1000); // Using page here because if the popup closes automatically, it throws an error during the wait - } - const locator = popup.locator("button.js-oauth-authorize-btn"); - if (!popup.isClosed() && (await locator.isVisible())) { - await popup.locator("body").click(); - await locator.waitFor(); - await locator.click(); - } - resolve(); - }); - }); - } - async googleSignIn(email) { - await new Promise((resolve) => { - this.page.once("popup", async (popup) => { - await popup.waitForLoadState(); - const locator = popup - .getByRole("link", { name: email, exact: false }) - .first(); - await popup.waitForTimeout(3000); - await locator.waitFor({ state: "visible" }); - await locator.click({ force: true }); - await popup.waitForTimeout(3000); - await popup - .locator("[name=Passwd]") - .fill(process.env.GOOGLE_USER_PASS); - await popup.locator("[name=Passwd]").press("Enter"); - await popup.waitForTimeout(3500); - await popup.locator("[name=totpPin]").fill(this.getGoogle2FAOTP()); - await popup.locator("[name=totpPin]").press("Enter"); - await popup - .getByRole("button", { name: /Continue|Weiter/ }) - .click({ timeout: 60000 }); - resolve(); - }); - }); - } - async checkAndClickOnGHloginPopup(force = false) { - const frameLocator = this.page.getByLabel("Login Required"); - try { - await frameLocator.waitFor({ state: "visible", timeout: 2000 }); - await this.clickOnGHloginPopup(); - } - catch (error) { - if (force) - throw error; - } - } - getButtonSelector(label) { - return `${UI_HELPER_ELEMENTS.MuiButtonLabel}:has-text("${label}")`; - } - getLoginBtnSelector() { - return 'MuiListItem-root li.MuiListItem-root button.MuiButton-root:has(span.MuiButton-label:text("Log in"))'; - } - async clickOnGHloginPopup() { - const isLoginRequiredVisible = await this.uiHelper.isTextVisible("Sign in"); - if (isLoginRequiredVisible) { - await this.uiHelper.clickButton("Sign in"); - await this.uiHelper.clickButton("Log in"); - await this.checkAndReauthorizeGithubApp(); - await this.page.waitForSelector(this.getLoginBtnSelector(), { - state: "detached", - }); - } - else { - console.log('"Log in" button is not visible. Skipping login popup actions.'); - } - } - getGitHub2FAOTP(userid) { - const secrets = { - [process.env.VAULT_GH_USER_ID]: process.env.VAULT_GH_2FA_SECRET, - [process.env.VAULT_GH_USER2_ID]: process.env.VAULT_GH_USER2_2FA_SECRET, - }; - const secret = secrets[userid]; - if (!secret) { - throw new Error("Invalid User ID"); - } - return authenticator.generate(secret); - } - getGoogle2FAOTP() { - const secret = process.env.GOOGLE_2FA_SECRET; - return authenticator.generate(secret); - } - async keycloakLogin(username, password) { - await this.page.goto("/"); - await this.page.waitForSelector('p:has-text("Sign in using OIDC")'); - const [popup] = await Promise.all([ - this.page.waitForEvent("popup"), - this.uiHelper.clickButton("Sign In"), - ]); - await popup.waitForLoadState("domcontentloaded"); - // Check if popup closes automatically (already logged in) - try { - await popup.waitForEvent("close", { timeout: 5000 }); - return "Already logged in"; - } - catch { - // Popup didn't close, proceed with login - } - try { - await popup.locator("#username").click(); - await popup.locator("#username").fill(username); - await popup.locator("#password").fill(password); - await popup.locator("[name=login]").click({ timeout: 5000 }); - await popup.waitForEvent("close", { timeout: 2000 }); - return "Login successful"; - } - catch (e) { - const usernameError = popup.locator("id=input-error"); - if (await usernameError.isVisible()) { - await popup.close(); - return "User does not exist"; - } - else { - throw e; - } - } - } - async handleGitHubPopupLogin(popup, username, password, twofactor) { - await expect(async () => { - await popup.waitForLoadState("domcontentloaded"); - expect(popup).toBeTruthy(); - }).toPass({ - intervals: [5_000, 10_000], - timeout: 20 * 1000, - }); - // Check if popup closes automatically - try { - await popup.waitForEvent("close", { timeout: 5000 }); - return "Already logged in"; - } - catch { - // Popup didn't close, proceed with login - } - try { - await popup.locator("#login_field").click({ timeout: 5000 }); - await popup.locator("#login_field").fill(username, { timeout: 5000 }); - const cookieLocator = popup.locator("#wcpConsentBannerCtrl"); - if (await cookieLocator.isVisible()) { - await popup.click('button:has-text("Reject")', { timeout: 5000 }); - } - await popup.locator("#password").click({ timeout: 5000 }); - await popup.locator("#password").fill(password, { timeout: 5000 }); - await popup - .locator("[type='submit'][value='Sign in']:not(webauthn-status *)") - .first() - .click({ timeout: 5000 }); - const twofactorcode = authenticator.generate(twofactor); - await popup.locator("#app_totp").click({ timeout: 5000 }); - await popup.locator("#app_totp").fill(twofactorcode, { timeout: 5000 }); - await popup.waitForEvent("close", { timeout: 20000 }); - return "Login successful"; - } - catch (e) { - const authorization = popup.locator("button.js-oauth-authorize-btn"); - if (await authorization.isVisible()) { - await authorization.click(); - return "Login successful"; - } - else { - throw e; - } - } - } - async githubLogin(username, password, twofactor) { - await this.page.goto("/"); - await this.page.waitForSelector('p:has-text("Sign in using GitHub")'); - const [popup] = await Promise.all([ - this.page.waitForEvent("popup"), - this.uiHelper.clickButton("Sign In"), - ]); - return this.handleGitHubPopupLogin(popup, username, password, twofactor); - } - async githubLoginFromSettingsPage(username, password, twofactor) { - await this.page.goto("/settings/auth-providers"); - const [popup] = await Promise.all([ - this.page.waitForEvent("popup"), - this.page.getByTitle("Sign in to GitHub").click(), - this.uiHelper.clickButton("Log in"), - ]); - return this.handleGitHubPopupLogin(popup, username, password, twofactor); - } - async microsoftAzureLogin(username, password) { - await this.page.goto("/"); - await this.page.waitForSelector('p:has-text("Sign in using Microsoft")'); - const [popup] = await Promise.all([ - this.page.waitForEvent("popup"), - this.uiHelper.clickButton("Sign In"), - ]); - await popup.waitForLoadState("domcontentloaded"); - if (popup.url().startsWith(process.env.RHDH_BASE_URL)) { - // an active microsoft session is already logged in and the popup will automatically close - return "Already logged in"; - } - else { - try { - await popup.locator("[name=loginfmt]").click(); - await popup - .locator("[name=loginfmt]") - .fill(username, { timeout: 5000 }); - await popup - .locator('[type=submit]:has-text("Next")') - .click({ timeout: 5000 }); - await popup.locator("[name=passwd]").click(); - await popup.locator("[name=passwd]").fill(password, { timeout: 5000 }); - await popup - .locator('[type=submit]:has-text("Sign in")') - .click({ timeout: 5000 }); - await popup - .locator('[type=button]:has-text("No")') - .click({ timeout: 15000 }); - return "Login successful"; - } - catch (e) { - const usernameError = popup.locator("id=usernameError"); - if (await usernameError.isVisible()) { - return "User does not exist"; - } - else { - throw e; - } - } - } - } -} -export async function setupBrowser(browser, testInfo) { - const context = await browser.newContext({ - recordVideo: { - dir: `test-results/${path - .parse(testInfo.file) - .name.replace(".spec", "")}/${testInfo.titlePath[1]}`, - size: { width: 1920, height: 1080 }, - }, - }); - const page = await context.newPage(); - return { page, context }; -} diff --git a/dist/playwright/helpers/index.d.ts b/dist/playwright/helpers/index.d.ts deleted file mode 100644 index 551e804..0000000 --- a/dist/playwright/helpers/index.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { GITHUB_API_ENDPOINTS } from "./api-endpoints.js"; -export { APIHelper } from "./api-helper.js"; -export { LoginHelper, setupBrowser } from "./common.js"; -export { UIhelper } from "./ui-helper.js"; -export { RbacApiHelper, Policy, Role, Response } from "./rbac-api-helper.js"; -export { AuthApiHelper } from "./auth-api-helper.js"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/index.d.ts.map b/dist/playwright/helpers/index.d.ts.map deleted file mode 100644 index 945dbc7..0000000 --- a/dist/playwright/helpers/index.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC"} \ No newline at end of file diff --git a/dist/playwright/helpers/index.js b/dist/playwright/helpers/index.js deleted file mode 100644 index 6847fe9..0000000 --- a/dist/playwright/helpers/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { GITHUB_API_ENDPOINTS } from "./api-endpoints.js"; -export { APIHelper } from "./api-helper.js"; -export { LoginHelper, setupBrowser } from "./common.js"; -export { UIhelper } from "./ui-helper.js"; -export { RbacApiHelper, Response } from "./rbac-api-helper.js"; -export { AuthApiHelper } from "./auth-api-helper.js"; diff --git a/dist/playwright/helpers/navbar.d.ts b/dist/playwright/helpers/navbar.d.ts deleted file mode 100644 index 79639fc..0000000 --- a/dist/playwright/helpers/navbar.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type SidebarTabs = "Catalog" | "Settings" | "My Group" | "Home" | "Self-service" | "Learning Paths" | "Extensions" | "Bulk import" | "Docs" | "Clusters" | "Tech Radar" | "Notifications" | "Orchestrator"; -//# sourceMappingURL=navbar.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/navbar.d.ts.map b/dist/playwright/helpers/navbar.d.ts.map deleted file mode 100644 index 7e46f52..0000000 --- a/dist/playwright/helpers/navbar.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"navbar.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/navbar.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GACnB,SAAS,GACT,UAAU,GACV,UAAU,GACV,MAAM,GACN,cAAc,GACd,gBAAgB,GAChB,YAAY,GACZ,aAAa,GACb,MAAM,GACN,UAAU,GACV,YAAY,GACZ,eAAe,GACf,cAAc,CAAC"} \ No newline at end of file diff --git a/dist/playwright/helpers/navbar.js b/dist/playwright/helpers/navbar.js deleted file mode 100644 index cb0ff5c..0000000 --- a/dist/playwright/helpers/navbar.js +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/dist/playwright/helpers/rbac-api-helper.d.ts b/dist/playwright/helpers/rbac-api-helper.d.ts deleted file mode 100644 index 10a7b97..0000000 --- a/dist/playwright/helpers/rbac-api-helper.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { PermissionAction, RoleConditionalPolicyDecision } from "@backstage-community/plugin-rbac-common"; -import { APIResponse } from "@playwright/test"; -export interface Policy { - entityReference: string; - permission: string; - policy: string; - effect: string; -} -export interface Role { - memberReferences: string[]; - name: string; -} -/** - * Thin HTTP client for the RHDH RBAC permission API. - * Uses a static factory (`build`) because the Playwright `APIRequestContext` - * must be created asynchronously β€” a constructor cannot await it. - */ -export declare class RbacApiHelper { - private readonly token; - private readonly apiUrl; - private readonly authHeader; - private myContext; - private constructor(); - /** Creates a fully-initialised instance with a live Playwright request context. */ - static build(token: string): Promise; - createRoles(role: Role): Promise; - getRoles(): Promise; - updateRole(role: string, oldRole: Role, newRole: Role): Promise; - deleteRole(role: string): Promise; - getPoliciesByRole(policy: string): Promise; - createPolicies(policy: Policy[]): Promise; - deletePolicy(policy: string, policies: Policy[]): Promise; - /** Fetches all conditional policies across all roles. */ - getConditions(): Promise; - /** Filters a full conditions list down to those belonging to a specific role entity ref. */ - getConditionsByRole(role: string, remainingConditions: RoleConditionalPolicyDecision[]): Promise[]>; - /** `id` comes from the `RoleConditionalPolicyDecision.id` field returned by the API. */ - deleteCondition(id: string): Promise; -} -export declare class Response { - static removeMetadataFromResponse(response: APIResponse): Promise; -} -//# sourceMappingURL=rbac-api-helper.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/rbac-api-helper.d.ts.map b/dist/playwright/helpers/rbac-api-helper.d.ts.map deleted file mode 100644 index 9ad0c60..0000000 --- a/dist/playwright/helpers/rbac-api-helper.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"rbac-api-helper.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/rbac-api-helper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gBAAgB,EAChB,6BAA6B,EAC9B,MAAM,yCAAyC,CAAC;AACjD,OAAO,EAAqB,WAAW,EAAW,MAAM,kBAAkB,CAAC;AAE3E,MAAM,WAAW,MAAM;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,IAAI;IACnB,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,qBAAa,aAAa;IAUJ,OAAO,CAAC,QAAQ,CAAC,KAAK;IAT1C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkD;IACzE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAKzB;IACF,OAAO,CAAC,SAAS,CAAqB;IAEtC,OAAO;IAOP,mFAAmF;WAC/D,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;IAWnD,WAAW,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,WAAW,CAAC;IAI7C,QAAQ,IAAI,OAAO,CAAC,WAAW,CAAC;IAIhC,UAAU,CACrB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,IAAI,EACb,OAAO,EAAE,IAAI,GACZ,OAAO,CAAC,WAAW,CAAC;IAMV,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;IAM9C,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;IAIvD,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,WAAW,CAAC;IAItD,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE;IAQ5D,yDAAyD;IAC5C,aAAa,IAAI,OAAO,CAAC,WAAW,CAAC;IAIlD,4FAA4F;IAC/E,mBAAmB,CAC9B,IAAI,EAAE,MAAM,EACZ,mBAAmB,EAAE,6BAA6B,CAAC,gBAAgB,CAAC,EAAE,GACrE,OAAO,CAAC,6BAA6B,CAAC,gBAAgB,CAAC,EAAE,CAAC;IAM7D,wFAAwF;IAC3E,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;CAG/D;AAED,qBAAa,QAAQ;WACN,0BAA0B,CACrC,QAAQ,EAAE,WAAW,GACpB,OAAO,CAAC,OAAO,EAAE,CAAC;CActB"} \ No newline at end of file diff --git a/dist/playwright/helpers/rbac-api-helper.js b/dist/playwright/helpers/rbac-api-helper.js deleted file mode 100644 index 62608f1..0000000 --- a/dist/playwright/helpers/rbac-api-helper.js +++ /dev/null @@ -1,80 +0,0 @@ -import { request } from "@playwright/test"; -/** - * Thin HTTP client for the RHDH RBAC permission API. - * Uses a static factory (`build`) because the Playwright `APIRequestContext` - * must be created asynchronously β€” a constructor cannot await it. - */ -export class RbacApiHelper { - token; - apiUrl = process.env.RHDH_BASE_URL + "/api/permission/"; - authHeader; - myContext; - constructor(token) { - this.token = token; - this.authHeader = { - Accept: "application/json", - Authorization: `Bearer ${this.token}`, - }; - } - /** Creates a fully-initialised instance with a live Playwright request context. */ - static async build(token) { - const instance = new RbacApiHelper(token); - instance.myContext = await request.newContext({ - baseURL: instance.apiUrl, - extraHTTPHeaders: instance.authHeader, - }); - return instance; - } - // Roles: - async createRoles(role) { - return await this.myContext.post("roles", { data: role }); - } - async getRoles() { - return await this.myContext.get("roles"); - } - async updateRole(role, oldRole, newRole) { - return await this.myContext.put(`roles/role/default/${role}`, { - data: { oldRole, newRole }, - }); - } - async deleteRole(role) { - return await this.myContext.delete(`roles/role/default/${role}`); - } - // Policies: - async getPoliciesByRole(policy) { - return await this.myContext.get(`policies/role/default/${policy}`); - } - async createPolicies(policy) { - return await this.myContext.post("policies", { data: policy }); - } - async deletePolicy(policy, policies) { - return await this.myContext.delete(`policies/role/default/${policy}`, { - data: policies, - }); - } - // Conditions: - /** Fetches all conditional policies across all roles. */ - async getConditions() { - return await this.myContext.get(`roles/conditions`); - } - /** Filters a full conditions list down to those belonging to a specific role entity ref. */ - async getConditionsByRole(role, remainingConditions) { - return remainingConditions.filter((condition) => condition.roleEntityRef === role); - } - /** `id` comes from the `RoleConditionalPolicyDecision.id` field returned by the API. */ - async deleteCondition(id) { - return await this.myContext.delete(`roles/conditions/${id}`); - } -} -export class Response { - static async removeMetadataFromResponse(response) { - const responseJson = await response.json(); - if (!Array.isArray(responseJson)) { - throw new TypeError(`Expected an array from policy response but received: ${JSON.stringify(responseJson)}`); - } - return responseJson.map((item) => { - delete item.metadata; - return item; - }); - } -} diff --git a/dist/playwright/helpers/ui-helper.d.ts b/dist/playwright/helpers/ui-helper.d.ts deleted file mode 100644 index 738046e..0000000 --- a/dist/playwright/helpers/ui-helper.d.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { Locator, Page } from "@playwright/test"; -import type { SidebarTabs } from "./navbar.js"; -export declare class UIhelper { - private page; - constructor(page: Page); - waitForLoad(timeout?: number): Promise; - /** - * Closes the quickstart drawer when the "Hide" button is visible (RHDH quickstart plugin), - * so it does not cover catalog or other UI under test. - */ - dismissQuickstartIfVisible(options?: { - waitHiddenMs?: number; - }): Promise; - verifyComponentInCatalog(kind: string, expectedRows: string[]): Promise; - getSideBarMenuItem(menuItem: string): Locator; - fillTextInputByLabel(label: string, text: string): Promise; - /** - * Fills the search input with the provided text. - * - * @param searchText - The text to be entered into the search input field. - */ - searchInputPlaceholder(searchText: string): Promise; - searchInputAriaLabel(searchText: string): Promise; - pressTab(): Promise; - checkCheckbox(text: string): Promise; - uncheckCheckbox(text: string): Promise; - clickButton(label: string | RegExp, options?: { - exact?: boolean; - force?: boolean; - }): Promise; - clickBtnByTitleIfNotPressed(title: string): Promise; - clickByDataTestId(dataTestId: string): Promise; - /** - * Clicks on a button element by its text content, waiting for it to be visible first. - * - * @param buttonText - The text content of the button to click on. - * @param options - Optional configuration for exact match, timeout, and force click. - */ - clickButtonByText(buttonText: string | RegExp, options?: { - exact?: boolean; - timeout?: number; - force?: boolean; - }): Promise; - clickButtonByLabel(label: string | RegExp): Promise; - clickLink(options: string | { - href: string; - } | { - ariaLabel: string; - }): Promise; - openProfileDropdown(): Promise; - goToPageUrl(url: string, heading?: string): Promise; - verifyLink(arg: string | { - label: string; - }, options?: { - exact?: boolean; - notVisible?: boolean; - }): Promise; - private isElementVisible; - isBtnVisibleByTitle(text: string): Promise; - isBtnVisible(text: string): Promise; - isTextVisible(text: string, timeout?: number): Promise; - verifyTextVisible(text: string, exact?: boolean, timeout?: number): Promise; - verifyLinkVisible(text: string, timeout?: number): Promise; - openSidebar(navBarText: SidebarTabs): Promise; - openCatalogSidebar(kind: string): Promise; - openSidebarButton(navBarButtonLabel: string): Promise; - selectMuiBox(label: string, value: string): Promise; - verifyRowsInTable(rowTexts: (string | RegExp)[], exact?: boolean): Promise; - waitForTextDisappear(text: string): Promise; - verifyText(text: string | RegExp, exact?: boolean): Promise; - private verifyTextInLocator; - verifyTextInSelector(selector: string, expectedText: string): Promise; - verifyColumnHeading(rowTexts: string[] | RegExp[], exact?: boolean): Promise; - verifyHeading(heading: string | RegExp, timeout?: number): Promise; - verifyParagraph(paragraph: string): Promise; - waitForTitle(text: string, level?: number): Promise; - clickTab(tabName: string): Promise; - verifyCellsInTable(texts: (string | RegExp)[]): Promise; - verifyButtonURL(label: string | RegExp, url: string | RegExp, options?: { - locator?: string; - exact?: boolean; - }): Promise; - /** - * Verifies that a table row, identified by unique text, contains specific cell texts. - * @param {string} uniqueRowText - The unique text present in one of the cells within the row. This is used to identify the specific row. - * @param {Array} cellTexts - An array of cell texts or regular expressions to match against the cells within the identified row. - * @example - * // Example usage to verify that a row containing "Developer-hub" has cells with the texts "service" and "active": - * await verifyRowInTableByUniqueText('Developer-hub', ['service', 'active']); - */ - verifyRowInTableByUniqueText(uniqueRowText: string, cellTexts: string[] | RegExp[]): Promise; - /** - * Clicks on a link within a table row that contains a unique text and matches a link's text. - * @param {string} uniqueRowText - The unique text present in one of the cells within the row. This is used to identify the specific row. - * @param {string | RegExp} linkText - The text of the link, can be a string or a regular expression. - * @param {boolean} [exact=true] - Whether to match the link text exactly. By default, this is set to true. - */ - clickOnLinkInTableByUniqueText(uniqueRowText: string, linkText: string | RegExp, exact?: boolean): Promise; - /** - * Clicks on a button within a table row that contains a unique text and matches a button's label or aria-label. - * @param {string} uniqueRowText - The unique text present in one of the cells within the row. This is used to identify the specific row. - * @param {string | RegExp} textOrLabel - The text of the button or the `aria-label` attribute, can be a string or a regular expression. - */ - clickOnButtonInTableByUniqueText(uniqueRowText: string, textOrLabel: string | RegExp): Promise; - verifyLinkinCard(cardHeading: string, linkText: string, exact?: boolean): Promise; - clickBtnInCard(cardText: string, btnText: string, exact?: boolean): Promise; - verifyTextinCard(cardHeading: string, text: string | RegExp, exact?: boolean): Promise; - verifyTableHeadingAndRows(texts: string[]): Promise; - verifyTableIsEmpty(): Promise; - verifyAlertErrorMessage(message: string | RegExp): Promise; - verifyTextInTooltip(text: string | RegExp): Promise; -} -//# sourceMappingURL=ui-helper.d.ts.map \ No newline at end of file diff --git a/dist/playwright/helpers/ui-helper.d.ts.map b/dist/playwright/helpers/ui-helper.d.ts.map deleted file mode 100644 index e73c3b2..0000000 --- a/dist/playwright/helpers/ui-helper.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"ui-helper.d.ts","sourceRoot":"","sources":["../../../src/playwright/helpers/ui-helper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAMtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAG/C,qBAAa,QAAQ;IACnB,OAAO,CAAC,IAAI,CAAO;gBAEP,IAAI,EAAE,IAAI;IAIhB,WAAW,CAAC,OAAO,SAAS;IASlC;;;OAGG;IACG,0BAA0B,CAAC,OAAO,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE;IAY9D,wBAAwB,CAAC,IAAI,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE;IAMnE,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO;IAIvC,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;IAItD;;;;OAIG;IACG,sBAAsB,CAAC,UAAU,EAAE,MAAM;IAOzC,oBAAoB,CAAC,UAAU,EAAE,MAAM;IAIvC,QAAQ;IAIR,aAAa,CAAC,IAAI,EAAE,MAAM;IAO1B,eAAe,CAAC,IAAI,EAAE,MAAM;IAO5B,WAAW,CACf,KAAK,EAAE,MAAM,GAAG,MAAM,EACtB,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAG1C;IAgBG,2BAA2B,CAAC,KAAK,EAAE,MAAM;IASzC,iBAAiB,CAAC,UAAU,EAAE,MAAM;IAM1C;;;;;OAKG;IACG,iBAAiB,CACrB,UAAU,EAAE,MAAM,GAAG,MAAM,EAC3B,OAAO,GAAE;QACP,KAAK,CAAC,EAAE,OAAO,CAAC;QAChB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,KAAK,CAAC,EAAE,OAAO,CAAC;KAKjB;IAkBG,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM;IAIzC,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE;IAiBpE,mBAAmB;IAQnB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM;IASzC,UAAU,CACd,GAAG,EAAE,MAAM,GAAG;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,EAC/B,OAAO,GAAE;QACP,KAAK,CAAC,EAAE,OAAO,CAAC;QAChB,UAAU,CAAC,EAAE,OAAO,CAAC;KAItB;YAwBW,gBAAgB;IAkBxB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKnD,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAK5C,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,SAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;IAK9D,iBAAiB,CACrB,IAAI,EAAE,MAAM,EACZ,KAAK,UAAQ,EACb,OAAO,SAAQ,GACd,OAAO,CAAC,IAAI,CAAC;IAKV,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,SAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAK/D,WAAW,CAAC,UAAU,EAAE,WAAW;IAQnC,kBAAkB,CAAC,IAAI,EAAE,MAAM;IAa/B,iBAAiB,CAAC,iBAAiB,EAAE,MAAM;IAQ3C,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM;IAOzC,iBAAiB,CACrB,QAAQ,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,EAC7B,KAAK,GAAE,OAAc;IAOjB,oBAAoB,CAAC,IAAI,EAAE,MAAM;IAIjC,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,KAAK,GAAE,OAAc;YAI/C,mBAAmB;IAsB3B,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM;IA+B3D,mBAAmB,CACvB,QAAQ,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,EAC7B,KAAK,GAAE,OAAc;IAajB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,OAAO,GAAE,MAAc;IAU/D,eAAe,CAAC,SAAS,EAAE,MAAM;IASjC,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,GAAE,MAAU;IAI5C,QAAQ,CAAC,OAAO,EAAE,MAAM;IAMxB,kBAAkB,CAAC,KAAK,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE;IAoB7C,eAAe,CACnB,KAAK,EAAE,MAAM,GAAG,MAAM,EACtB,GAAG,EAAE,MAAM,GAAG,MAAM,EACpB,OAAO,GAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAG3C;IAgBH;;;;;;;OAOG;IAEG,4BAA4B,CAChC,aAAa,EAAE,MAAM,EACrB,SAAS,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE;IAWhC;;;;;OAKG;IACG,8BAA8B,CAClC,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,KAAK,GAAE,OAAc;IAWvB;;;;OAIG;IACG,gCAAgC,CACpC,aAAa,EAAE,MAAM,EACrB,WAAW,EAAE,MAAM,GAAG,MAAM;IAYxB,gBAAgB,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,UAAO;IAUpE,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,UAAO;IAW9D,gBAAgB,CACpB,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,MAAM,GAAG,MAAM,EACrB,KAAK,UAAO;IAUR,yBAAyB,CAAC,KAAK,EAAE,MAAM,EAAE;IAiBzC,kBAAkB;IAMlB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM;IAMhD,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;CAIhD"} \ No newline at end of file diff --git a/dist/playwright/helpers/ui-helper.js b/dist/playwright/helpers/ui-helper.js deleted file mode 100644 index 6bcfc6a..0000000 --- a/dist/playwright/helpers/ui-helper.js +++ /dev/null @@ -1,455 +0,0 @@ -import { expect } from "@playwright/test"; -import { UI_HELPER_ELEMENTS, WAIT_OBJECTS, } from "../page-objects/global-obj.js"; -import { SEARCH_OBJECTS_COMPONENTS } from "../page-objects/page-obj.js"; -export class UIhelper { - page; - constructor(page) { - this.page = page; - } - async waitForLoad(timeout = 120000) { - for (const item of Object.values(WAIT_OBJECTS)) { - await this.page.waitForSelector(item, { - state: "hidden", - timeout: timeout, - }); - } - } - /** - * Closes the quickstart drawer when the "Hide" button is visible (RHDH quickstart plugin), - * so it does not cover catalog or other UI under test. - */ - async dismissQuickstartIfVisible(options) { - const waitHiddenMs = options?.waitHiddenMs ?? 5000; - const quickstartHide = this.page.getByRole("button", { name: "Hide" }); - if (await quickstartHide.isVisible()) { - await quickstartHide.click(); - await quickstartHide.waitFor({ - state: "hidden", - timeout: waitHiddenMs, - }); - } - } - async verifyComponentInCatalog(kind, expectedRows) { - await this.openSidebar("Catalog"); - await this.selectMuiBox("Kind", kind); - await this.verifyRowsInTable(expectedRows); - } - getSideBarMenuItem(menuItem) { - return this.page.getByTestId("login-button").getByText(menuItem); - } - async fillTextInputByLabel(label, text) { - await this.page.getByLabel(label).fill(text); - } - /** - * Fills the search input with the provided text. - * - * @param searchText - The text to be entered into the search input field. - */ - async searchInputPlaceholder(searchText) { - await this.page.fill(SEARCH_OBJECTS_COMPONENTS.placeholderSearch, searchText); - } - async searchInputAriaLabel(searchText) { - await this.page.fill(SEARCH_OBJECTS_COMPONENTS.ariaLabelSearch, searchText); - } - async pressTab() { - await this.page.keyboard.press("Tab"); - } - async checkCheckbox(text) { - const locator = this.page.getByRole("checkbox", { - name: text, - }); - await locator.check(); - } - async uncheckCheckbox(text) { - const locator = this.page.getByRole("checkbox", { - name: text, - }); - await locator.uncheck(); - } - async clickButton(label, options = { - exact: true, - force: false, - }) { - const selector = `${UI_HELPER_ELEMENTS.MuiButtonLabel}`; - const button = this.page - .locator(selector) - .getByText(label, { exact: options.exact }) - .first(); - if (options?.force) { - await button.click({ force: true }); - } - else { - await button.click(); - } - return button; - } - async clickBtnByTitleIfNotPressed(title) { - const button = this.page.locator(`button[title="${title}"]`); - const isPressed = await button.getAttribute("aria-pressed"); - if (isPressed === "false") { - await button.click(); - } - } - async clickByDataTestId(dataTestId) { - const element = this.page.getByTestId(dataTestId); - await element.waitFor({ state: "visible" }); - await element.dispatchEvent("click"); - } - /** - * Clicks on a button element by its text content, waiting for it to be visible first. - * - * @param buttonText - The text content of the button to click on. - * @param options - Optional configuration for exact match, timeout, and force click. - */ - async clickButtonByText(buttonText, options = { - exact: true, - timeout: 10000, - force: false, - }) { - const buttonElement = this.page - .getByRole("button") - .getByText(buttonText, { exact: options.exact }); - await buttonElement.waitFor({ - state: "visible", - timeout: options.timeout, - }); - if (options.force) { - await buttonElement.click({ force: true }); - } - else { - await buttonElement.click(); - } - } - async clickButtonByLabel(label) { - await this.page.getByRole("button", { name: label }).first().click(); - } - async clickLink(options) { - let linkLocator; - if (typeof options === "string") { - linkLocator = this.page.locator("a").filter({ hasText: options }).first(); - } - else if ("href" in options) { - linkLocator = this.page.locator(`a[href="${options.href}"]`).first(); - } - else { - linkLocator = this.page - .locator(`div[aria-label='${options.ariaLabel}'] a`) - .first(); - } - await linkLocator.waitFor({ state: "visible" }); - await linkLocator.click(); - } - async openProfileDropdown() { - const header = this.page.locator("nav[id='global-header']"); - await expect(header).toBeVisible(); - await header - .locator("[data-testid='KeyboardArrowDownOutlinedIcon']") - .click(); - } - async goToPageUrl(url, heading) { - await this.page.goto(url); - await expect(this.page).toHaveURL(url); - await this.waitForLoad(); - if (heading) { - await this.verifyHeading(heading); - } - } - async verifyLink(arg, options = { - exact: true, - notVisible: false, - }) { - let linkLocator; - let notVisibleCheck; - if (typeof arg != "object") { - linkLocator = this.page - .locator("a") - .getByText(arg, { exact: options.exact }) - .first(); - notVisibleCheck = options?.notVisible ?? false; - } - else { - linkLocator = this.page.locator(`div[aria-label="${arg.label}"] a`); - notVisibleCheck = false; - } - if (notVisibleCheck) { - await expect(linkLocator).toBeHidden(); - } - else { - await expect(linkLocator).toBeVisible(); - } - } - async isElementVisible(locator, timeout = 10000, force = false) { - try { - await this.page.waitForSelector(locator, { - state: "visible", - timeout: timeout, - }); - const button = this.page.locator(locator).first(); - return button.isVisible(); - } - catch (error) { - if (force) - throw error; - return false; - } - } - async isBtnVisibleByTitle(text) { - const locator = `BUTTON[title="${text}"]`; - return await this.isElementVisible(locator); - } - async isBtnVisible(text) { - const locator = `button:has-text("${text}")`; - return await this.isElementVisible(locator); - } - async isTextVisible(text, timeout = 10000) { - const locator = `:has-text("${text}")`; - return await this.isElementVisible(locator, timeout); - } - async verifyTextVisible(text, exact = false, timeout = 10000) { - const locator = this.page.getByText(text, { exact }); - await expect(locator).toBeVisible({ timeout }); - } - async verifyLinkVisible(text, timeout = 10000) { - const locator = this.page.locator(`a:has-text("${text}")`); - await expect(locator).toBeVisible({ timeout }); - } - async openSidebar(navBarText) { - const navLink = this.page - .locator(`nav a:has-text("${navBarText}")`) - .first(); - await navLink.waitFor({ state: "visible", timeout: 15_000 }); - await navLink.dispatchEvent("click"); - } - async openCatalogSidebar(kind) { - await this.openSidebar("Catalog"); - await this.selectMuiBox("Kind", kind); - await expect(async () => { - await this.clickByDataTestId("user-picker-all"); - await this.page.waitForTimeout(1_500); - await this.verifyHeading(new RegExp(`all ${kind}`, "i")); - }).toPass({ - intervals: [3_000], - timeout: 20_000, - }); - } - async openSidebarButton(navBarButtonLabel) { - const navLink = this.page.locator(`nav button[aria-label="${navBarButtonLabel}"]`); - await navLink.waitFor({ state: "visible" }); - await navLink.click(); - } - async selectMuiBox(label, value) { - await this.page.click(`div[aria-label="${label}"]`); - const optionSelector = `li[role="option"]:has-text("${value}")`; - await this.page.waitForSelector(optionSelector); - await this.page.click(optionSelector); - } - async verifyRowsInTable(rowTexts, exact = true) { - for (const rowText of rowTexts) { - await this.verifyTextInLocator(`tr>td`, rowText, exact); - } - } - async waitForTextDisappear(text) { - await this.page.waitForSelector(`text=${text}`, { state: "detached" }); - } - async verifyText(text, exact = true) { - await this.verifyTextInLocator("", text, exact); - } - async verifyTextInLocator(locator, text, exact) { - const elementLocator = locator - ? this.page.locator(locator).getByText(text, { exact }).first() - : this.page.getByText(text, { exact }).first(); - await elementLocator.waitFor({ state: "visible" }); - await elementLocator.waitFor({ state: "attached" }); - try { - await elementLocator.scrollIntoViewIfNeeded(); - } - catch (error) { - console.warn(`Warning: Could not scroll element into view. Error: ${error instanceof Error ? error.message : String(error)}`); - } - await expect(elementLocator).toBeVisible(); - } - async verifyTextInSelector(selector, expectedText) { - const elementLocator = this.page - .locator(selector) - .getByText(expectedText, { exact: true }); - try { - await elementLocator.waitFor({ state: "visible" }); - const actualText = (await elementLocator.textContent()) || "No content"; - if (actualText.trim() !== expectedText.trim()) { - console.error(`Verification failed for text: Expected "${expectedText}", but got "${actualText}"`); - throw new Error(`Expected text "${expectedText}" not found. Actual content: "${actualText}".`); - } - console.log(`Text "${expectedText}" verified successfully in selector: ${selector}`); - } - catch (error) { - const allTextContent = await this.page - .locator(selector) - .allTextContents(); - console.error(`Verification failed for text: Expected "${expectedText}". Selector content: ${allTextContent.join(", ")}`); - throw error; - } - } - async verifyColumnHeading(rowTexts, exact = true) { - for (const rowText of rowTexts) { - const rowLocator = this.page - .locator(`tr>th`) - .getByText(rowText, { exact: exact }) - .first(); - await rowLocator.waitFor({ state: "visible" }); - await rowLocator.scrollIntoViewIfNeeded(); - await expect(rowLocator).toBeVisible(); - } - } - async verifyHeading(heading, timeout = 20000) { - const headingLocator = this.page - .locator("h1, h2, h3, h4, h5, h6") - .filter({ hasText: heading }) - .first(); - await headingLocator.waitFor({ state: "visible", timeout: timeout }); - await expect(headingLocator).toBeVisible(); - } - async verifyParagraph(paragraph) { - const headingLocator = this.page - .locator("p") - .filter({ hasText: paragraph }) - .first(); - await headingLocator.waitFor({ state: "visible", timeout: 20000 }); - await expect(headingLocator).toBeVisible(); - } - async waitForTitle(text, level = 1) { - await this.page.waitForSelector(`h${level}:has-text("${text}")`); - } - async clickTab(tabName) { - const tabLocator = this.page.getByRole("tab", { name: tabName }); - await tabLocator.waitFor({ state: "visible" }); - await tabLocator.click(); - } - async verifyCellsInTable(texts) { - for (const text of texts) { - const cellLocator = this.page - .locator(UI_HELPER_ELEMENTS.MuiTableCell) - .filter({ hasText: text }); - const count = await cellLocator.count(); - if (count === 0) { - throw new Error(`Expected at least one cell with text matching ${text}, but none were found.`); - } - // Checks if all matching cells are visible. - for (let i = 0; i < count; i++) { - await expect(cellLocator.nth(i)).toBeVisible(); - } - } - } - async verifyButtonURL(label, url, options = { - locator: "", - exact: true, - }) { - // To verify the button URL if it is in a specific locator - const baseLocator = !options.locator || options.locator === "" - ? this.page - : this.page.locator(options.locator); - const buttonUrl = await baseLocator - .getByRole("button", { name: label, exact: options.exact }) - .first() - .getAttribute("href"); - expect(buttonUrl).toContain(url); - } - /** - * Verifies that a table row, identified by unique text, contains specific cell texts. - * @param {string} uniqueRowText - The unique text present in one of the cells within the row. This is used to identify the specific row. - * @param {Array} cellTexts - An array of cell texts or regular expressions to match against the cells within the identified row. - * @example - * // Example usage to verify that a row containing "Developer-hub" has cells with the texts "service" and "active": - * await verifyRowInTableByUniqueText('Developer-hub', ['service', 'active']); - */ - async verifyRowInTableByUniqueText(uniqueRowText, cellTexts) { - const row = this.page.locator(UI_HELPER_ELEMENTS.rowByText(uniqueRowText)); - await row.waitFor(); - for (const cellText of cellTexts) { - await expect(row.locator("td").filter({ hasText: cellText }).first()).toBeVisible(); - } - } - /** - * Clicks on a link within a table row that contains a unique text and matches a link's text. - * @param {string} uniqueRowText - The unique text present in one of the cells within the row. This is used to identify the specific row. - * @param {string | RegExp} linkText - The text of the link, can be a string or a regular expression. - * @param {boolean} [exact=true] - Whether to match the link text exactly. By default, this is set to true. - */ - async clickOnLinkInTableByUniqueText(uniqueRowText, linkText, exact = true) { - const row = this.page.locator(UI_HELPER_ELEMENTS.rowByText(uniqueRowText)); - await row.waitFor(); - await row - .locator("a") - .getByText(linkText, { exact: exact }) - .first() - .click(); - } - /** - * Clicks on a button within a table row that contains a unique text and matches a button's label or aria-label. - * @param {string} uniqueRowText - The unique text present in one of the cells within the row. This is used to identify the specific row. - * @param {string | RegExp} textOrLabel - The text of the button or the `aria-label` attribute, can be a string or a regular expression. - */ - async clickOnButtonInTableByUniqueText(uniqueRowText, textOrLabel) { - const row = this.page.locator(UI_HELPER_ELEMENTS.rowByText(uniqueRowText)); - await row.waitFor(); - await row - .locator(`button:has-text("${textOrLabel}"), button[aria-label="${textOrLabel}"]`) - .first() - .click(); - } - async verifyLinkinCard(cardHeading, linkText, exact = true) { - const link = this.page - .locator(UI_HELPER_ELEMENTS.MuiCard(cardHeading)) - .locator("a") - .getByText(linkText, { exact: exact }) - .first(); - await link.scrollIntoViewIfNeeded(); - await expect(link).toBeVisible(); - } - async clickBtnInCard(cardText, btnText, exact = true) { - const cardLocator = this.page - .locator(UI_HELPER_ELEMENTS.MuiCardRoot(cardText)) - .first(); - await cardLocator.scrollIntoViewIfNeeded(); - await cardLocator - .getByRole("button", { name: btnText, exact: exact }) - .first() - .click(); - } - async verifyTextinCard(cardHeading, text, exact = true) { - const locator = this.page - .locator(UI_HELPER_ELEMENTS.MuiCard(cardHeading)) - .getByText(text, { exact: exact }) - .first(); - await locator.scrollIntoViewIfNeeded(); - await expect(locator).toBeVisible(); - } - async verifyTableHeadingAndRows(texts) { - // Wait for the table to load by checking for the presence of table rows - await this.page.waitForSelector("table tbody tr", { state: "visible" }); - for (const column of texts) { - const columnSelector = `table th:has-text("${column}")`; - //check if columnSelector has at least one element or more - const columnCount = await this.page.locator(columnSelector).count(); - expect(columnCount).toBeGreaterThan(0); - } - // Checks if the table has at least one row with data - // Excludes rows that have cells spanning multiple columns, such as "No data available" messages - const rowSelector = `table tbody tr:not(:has(td[colspan]))`; - const rowCount = await this.page.locator(rowSelector).count(); - expect(rowCount).toBeGreaterThan(0); - } - async verifyTableIsEmpty() { - const rowSelector = `table tbody tr:not(:has(td[colspan]))`; - const rowCount = await this.page.locator(rowSelector).count(); - expect(rowCount).toEqual(0); - } - async verifyAlertErrorMessage(message) { - const alert = this.page.getByRole("alert"); - await alert.waitFor(); - await expect(alert).toHaveText(message); - } - async verifyTextInTooltip(text) { - const tooltip = this.page.getByRole("tooltip").getByText(text); - await expect(tooltip).toBeVisible(); - } -} diff --git a/dist/playwright/page-objects/global-obj.d.ts b/dist/playwright/page-objects/global-obj.d.ts deleted file mode 100644 index 8772255..0000000 --- a/dist/playwright/page-objects/global-obj.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -export declare const WAIT_OBJECTS: { - MuiLinearProgress: string; - MuiCircularProgress: string; -}; -export declare const UI_HELPER_ELEMENTS: { - MuiButtonLabel: string; - MuiToggleButtonLabel: string; - MuiBoxLabel: string; - MuiTableHead: string; - MuiTableCell: string; - MuiTableRow: string; - MuiTypographyColorPrimary: string; - MuiSwitchColorPrimary: string; - MuiButtonTextPrimary: string; - MuiCard: (cardHeading: string) => string; - MuiCardRoot: (cardText: string) => string; - MuiTable: string; - MuiCardHeader: string; - MuiInputBase: string; - MuiTypography: string; - MuiAlert: string; - tabs: string; - rowByText: (text: string) => string; -}; -//# sourceMappingURL=global-obj.d.ts.map \ No newline at end of file diff --git a/dist/playwright/page-objects/global-obj.d.ts.map b/dist/playwright/page-objects/global-obj.d.ts.map deleted file mode 100644 index 666c326..0000000 --- a/dist/playwright/page-objects/global-obj.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"global-obj.d.ts","sourceRoot":"","sources":["../../../src/playwright/page-objects/global-obj.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,YAAY;;;CAGxB,CAAC;AAEF,eAAO,MAAM,kBAAkB;;;;;;;;;;2BAWN,MAAM;4BAEL,MAAM;;;;;;;sBAQZ,MAAM;CACzB,CAAC"} \ No newline at end of file diff --git a/dist/playwright/page-objects/global-obj.js b/dist/playwright/page-objects/global-obj.js deleted file mode 100644 index e4b6629..0000000 --- a/dist/playwright/page-objects/global-obj.js +++ /dev/null @@ -1,24 +0,0 @@ -export const WAIT_OBJECTS = { - MuiLinearProgress: 'div[class*="MuiLinearProgress-root"]', - MuiCircularProgress: '[class*="MuiCircularProgress-root"]', -}; -export const UI_HELPER_ELEMENTS = { - MuiButtonLabel: 'span[class^="MuiButton-label"],button[class*="MuiButton-root"]', - MuiToggleButtonLabel: 'span[class^="MuiToggleButton-label"]', - MuiBoxLabel: 'div[class*="MuiBox-root"] label', - MuiTableHead: 'th[class*="MuiTableCell-root"]', - MuiTableCell: 'td[class*="MuiTableCell-root"]', - MuiTableRow: 'tr[class*="MuiTableRow-root"]', - MuiTypographyColorPrimary: ".MuiTypography-colorPrimary", - MuiSwitchColorPrimary: ".MuiSwitch-colorPrimary", - MuiButtonTextPrimary: ".MuiButton-textPrimary", - MuiCard: (cardHeading) => `//div[contains(@class,'MuiCardHeader-root') and descendant::*[text()='${cardHeading}']]/..`, - MuiCardRoot: (cardText) => `//div[contains(@class,'MuiCard-root')][descendant::text()[contains(., '${cardText}')]]`, - MuiTable: "table.MuiTable-root", - MuiCardHeader: 'div[class*="MuiCardHeader-root"]', - MuiInputBase: 'div[class*="MuiInputBase-root"]', - MuiTypography: 'span[class*="MuiTypography-root"]', - MuiAlert: 'div[class*="MuiAlert-message"]', - tabs: '[role="tab"]', - rowByText: (text) => `tr:has(:text-is("${text}"))`, -}; diff --git a/dist/playwright/page-objects/page-obj.d.ts b/dist/playwright/page-objects/page-obj.d.ts deleted file mode 100644 index c0992bc..0000000 --- a/dist/playwright/page-objects/page-obj.d.ts +++ /dev/null @@ -1,41 +0,0 @@ -export declare const HOME_PAGE_COMPONENTS: { - MuiAccordion: string; - MuiCard: string; -}; -export declare const SEARCH_OBJECTS_COMPONENTS: { - ariaLabelSearch: string; - placeholderSearch: string; -}; -export declare const CATALOG_IMPORT_COMPONENTS: { - componentURL: string; -}; -export declare const KUBERNETES_COMPONENTS: { - MuiAccordion: string; - statusOk: string; - podLogs: string; - MuiSnackbarContent: string; -}; -export declare const BACKSTAGE_SHOWCASE_COMPONENTS: { - tableNextPage: string; - tablePreviousPage: string; - tableLastPage: string; - tableFirstPage: string; - tableRows: string; - tablePageSelectBox: string; -}; -export declare const SETTINGS_PAGE_COMPONENTS: { - userSettingsMenu: string; - signOut: string; -}; -export declare const ROLES_PAGE_COMPONENTS: { - editRole: (name: string) => string; - deleteRole: (name: string) => string; -}; -export declare const DELETE_ROLE_COMPONENTS: { - roleName: string; -}; -export declare const ROLE_OVERVIEW_COMPONENTS_TEST_ID: { - updatePolicies: string; - updateMembers: string; -}; -//# sourceMappingURL=page-obj.d.ts.map \ No newline at end of file diff --git a/dist/playwright/page-objects/page-obj.d.ts.map b/dist/playwright/page-objects/page-obj.d.ts.map deleted file mode 100644 index 14dc83c..0000000 --- a/dist/playwright/page-objects/page-obj.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"page-obj.d.ts","sourceRoot":"","sources":["../../../src/playwright/page-objects/page-obj.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,oBAAoB;;;CAGhC,CAAC;AAEF,eAAO,MAAM,yBAAyB;;;CAGrC,CAAC;AAEF,eAAO,MAAM,yBAAyB;;CAErC,CAAC;AAEF,eAAO,MAAM,qBAAqB;;;;;CAKjC,CAAC;AAEF,eAAO,MAAM,6BAA6B;;;;;;;CAOzC,CAAC;AAEF,eAAO,MAAM,wBAAwB;;;CAGpC,CAAC;AAEF,eAAO,MAAM,qBAAqB;qBACf,MAAM;uBACJ,MAAM;CAC1B,CAAC;AAEF,eAAO,MAAM,sBAAsB;;CAElC,CAAC;AAEF,eAAO,MAAM,gCAAgC;;;CAG5C,CAAC"} \ No newline at end of file diff --git a/dist/playwright/page-objects/page-obj.js b/dist/playwright/page-objects/page-obj.js deleted file mode 100644 index e673e2b..0000000 --- a/dist/playwright/page-objects/page-obj.js +++ /dev/null @@ -1,40 +0,0 @@ -export const HOME_PAGE_COMPONENTS = { - MuiAccordion: 'div[class*="MuiAccordion-root-"]', - MuiCard: 'div[class*="MuiCard-root-"]', -}; -export const SEARCH_OBJECTS_COMPONENTS = { - ariaLabelSearch: 'input[aria-label="Search"]', - placeholderSearch: 'input[placeholder="Search"]', -}; -export const CATALOG_IMPORT_COMPONENTS = { - componentURL: 'input[name="url"]', -}; -export const KUBERNETES_COMPONENTS = { - MuiAccordion: 'div[class*="MuiAccordion-root-"]', - statusOk: 'span[aria-label="Status ok"]', - podLogs: 'label[aria-label="get logs"]', - MuiSnackbarContent: 'div[class*="MuiSnackbarContent-message-"]', -}; -export const BACKSTAGE_SHOWCASE_COMPONENTS = { - tableNextPage: 'button[aria-label="Next Page"]', - tablePreviousPage: 'button[aria-label="Previous Page"]', - tableLastPage: 'button[aria-label="Last Page"]', - tableFirstPage: 'button[aria-label="First Page"]', - tableRows: 'table[class*="MuiTable-root-"] tbody tr', - tablePageSelectBox: 'div[class*="MuiTablePagination-input"]', -}; -export const SETTINGS_PAGE_COMPONENTS = { - userSettingsMenu: 'button[data-testid="user-settings-menu"]', - signOut: 'li[data-testid="sign-out"]', -}; -export const ROLES_PAGE_COMPONENTS = { - editRole: (name) => `button[data-testid="edit-role-${name}"]`, - deleteRole: (name) => `button[data-testid="delete-role-${name}"]`, -}; -export const DELETE_ROLE_COMPONENTS = { - roleName: 'input[name="delete-role"]', -}; -export const ROLE_OVERVIEW_COMPONENTS_TEST_ID = { - updatePolicies: "update-policies", - updateMembers: "update-members", -}; diff --git a/dist/playwright/pages/catalog-import.d.ts b/dist/playwright/pages/catalog-import.d.ts deleted file mode 100644 index 869dde9..0000000 --- a/dist/playwright/pages/catalog-import.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Page } from "@playwright/test"; -export declare class CatalogImportPage { - private page; - private uiHelper; - constructor(page: Page); - /** - * Fills the component URL input and clicks the "Analyze" button. - * Waits until the analyze button is no longer visible (processing done). - * - * @param url - The URL of the component to analyze - */ - private analyzeAndWait; - /** - * Returns true if the component is already registered - * (i.e., "Refresh" button is visible instead of "Import"). - * - * @returns boolean indicating if the component is already registered - */ - isComponentAlreadyRegistered(): Promise; - /** - * Registers an existing component if it has not been registered yet. - * If already registered, clicks the "Refresh" button instead. - * - * @param url - The component URL to register - * @param clickViewComponent - Whether to click "View Component" after import - */ - registerExistingComponent(url: string, clickViewComponent?: boolean): Promise; - analyzeComponent(url: string): Promise; - inspectEntityAndVerifyYaml(text: string): Promise; -} -//# sourceMappingURL=catalog-import.d.ts.map \ No newline at end of file diff --git a/dist/playwright/pages/catalog-import.d.ts.map b/dist/playwright/pages/catalog-import.d.ts.map deleted file mode 100644 index 7113acf..0000000 --- a/dist/playwright/pages/catalog-import.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"catalog-import.d.ts","sourceRoot":"","sources":["../../../src/playwright/pages/catalog-import.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAK7C,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,IAAI,CAAO;IACnB,OAAO,CAAC,QAAQ,CAAW;gBAEf,IAAI,EAAE,IAAI;IAKtB;;;;;OAKG;YACW,cAAc;IAO5B;;;;;OAKG;IACG,4BAA4B,IAAI,OAAO,CAAC,OAAO,CAAC;IAItD;;;;;;OAMG;IACG,yBAAyB,CAC7B,GAAG,EAAE,MAAM,EACX,kBAAkB,GAAE,OAAc;IAiB9B,gBAAgB,CAAC,GAAG,EAAE,MAAM;IAK5B,0BAA0B,CAAC,IAAI,EAAE,MAAM;CAO9C"} \ No newline at end of file diff --git a/dist/playwright/pages/catalog-import.js b/dist/playwright/pages/catalog-import.js deleted file mode 100644 index 182ea94..0000000 --- a/dist/playwright/pages/catalog-import.js +++ /dev/null @@ -1,65 +0,0 @@ -import { expect } from "@playwright/test"; -import { UIhelper } from "../helpers/ui-helper.js"; -import { CATALOG_IMPORT_COMPONENTS } from "../page-objects/page-obj.js"; -export class CatalogImportPage { - page; - uiHelper; - constructor(page) { - this.page = page; - this.uiHelper = new UIhelper(page); - } - /** - * Fills the component URL input and clicks the "Analyze" button. - * Waits until the analyze button is no longer visible (processing done). - * - * @param url - The URL of the component to analyze - */ - async analyzeAndWait(url) { - await this.page.fill(CATALOG_IMPORT_COMPONENTS.componentURL, url); - await expect(await this.uiHelper.clickButton("Analyze")).not.toBeVisible({ - timeout: 25_000, - }); - } - /** - * Returns true if the component is already registered - * (i.e., "Refresh" button is visible instead of "Import"). - * - * @returns boolean indicating if the component is already registered - */ - async isComponentAlreadyRegistered() { - return await this.uiHelper.isBtnVisible("Refresh"); - } - /** - * Registers an existing component if it has not been registered yet. - * If already registered, clicks the "Refresh" button instead. - * - * @param url - The component URL to register - * @param clickViewComponent - Whether to click "View Component" after import - */ - async registerExistingComponent(url, clickViewComponent = true) { - await this.analyzeAndWait(url); - const isComponentAlreadyRegistered = await this.isComponentAlreadyRegistered(); - if (isComponentAlreadyRegistered) { - await this.uiHelper.clickButton("Refresh"); - expect(await this.uiHelper.isBtnVisible("Register another")).toBeTruthy(); - } - else { - await this.uiHelper.clickButton("Import"); - if (clickViewComponent) { - await this.uiHelper.clickButton("View Component"); - } - } - return isComponentAlreadyRegistered; - } - async analyzeComponent(url) { - await this.page.fill(CATALOG_IMPORT_COMPONENTS.componentURL, url); - await this.uiHelper.clickButton("Analyze"); - } - async inspectEntityAndVerifyYaml(text) { - await this.page.getByTitle("More").click(); - await this.page.getByRole("menuitem").getByText("Inspect entity").click(); - await this.uiHelper.clickTab("Raw YAML"); - await expect(this.page.getByTestId("code-snippet")).toContainText(text); - await this.uiHelper.clickButton("Close"); - } -} diff --git a/dist/playwright/pages/catalog.d.ts b/dist/playwright/pages/catalog.d.ts deleted file mode 100644 index 0ab9687..0000000 --- a/dist/playwright/pages/catalog.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Locator, Page } from "@playwright/test"; -export declare class CatalogPage { - private page; - private uiHelper; - private searchField; - constructor(page: Page); - go(): Promise; - goToByName(name: string): Promise; - goToBackstageJanusProjectCITab(): Promise; - goToBackstageJanusProject(): Promise; - search(s: string): Promise; - tableRow(content: string): Promise; -} -//# sourceMappingURL=catalog.d.ts.map \ No newline at end of file diff --git a/dist/playwright/pages/catalog.d.ts.map b/dist/playwright/pages/catalog.d.ts.map deleted file mode 100644 index 078039c..0000000 --- a/dist/playwright/pages/catalog.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../../../src/playwright/pages/catalog.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAItD,qBAAa,WAAW;IACtB,OAAO,CAAC,IAAI,CAAO;IACnB,OAAO,CAAC,QAAQ,CAAW;IAC3B,OAAO,CAAC,WAAW,CAAU;gBAEjB,IAAI,EAAE,IAAI;IAMhB,EAAE;IAIF,UAAU,CAAC,IAAI,EAAE,MAAM;IAKvB,8BAA8B;IAO9B,yBAAyB;IAIzB,MAAM,CAAC,CAAC,EAAE,MAAM;IAWhB,QAAQ,CAAC,OAAO,EAAE,MAAM;CAG/B"} \ No newline at end of file diff --git a/dist/playwright/pages/catalog.js b/dist/playwright/pages/catalog.js deleted file mode 100644 index 33320fd..0000000 --- a/dist/playwright/pages/catalog.js +++ /dev/null @@ -1,37 +0,0 @@ -import { UIhelper } from "../helpers/ui-helper.js"; -//${RHDH_BASE_URL}/catalog page -export class CatalogPage { - page; - uiHelper; - searchField; - constructor(page) { - this.page = page; - this.uiHelper = new UIhelper(page); - this.searchField = page.locator("#input-with-icon-adornment"); - } - async go() { - await this.uiHelper.openSidebar("Catalog"); - } - async goToByName(name) { - await this.uiHelper.openCatalogSidebar("Component"); - await this.uiHelper.clickLink(name); - } - async goToBackstageJanusProjectCITab() { - await this.goToBackstageJanusProject(); - await this.uiHelper.clickTab("CI"); - await this.page.waitForSelector('h2:text("Pipeline Runs")'); - await this.uiHelper.verifyHeading("Pipeline Runs"); - } - async goToBackstageJanusProject() { - await this.goToByName("backstage-janus"); - } - async search(s) { - await this.searchField.clear(); - const searchResponse = this.page.waitForResponse(new RegExp(`${process.env.RHDH_BASE_URL}/api/catalog/entities/by-query/*`)); - await this.searchField.fill(s); - await searchResponse; - } - async tableRow(content) { - return this.page.locator(`tr >> a >> text="${content}"`); - } -} diff --git a/dist/playwright/pages/extensions.d.ts b/dist/playwright/pages/extensions.d.ts deleted file mode 100644 index c0dd8f9..0000000 --- a/dist/playwright/pages/extensions.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Page, Locator } from "@playwright/test"; -export declare class ExtensionsPage { - private page; - badge: Locator; - private uiHelper; - private commonHeadings; - private tableHeaders; - constructor(page: Page); - clickReadMoreByPluginTitle(pluginTitle: string): Promise; - selectDropdown(name: string): Promise; - toggleOption(name: string): Promise; - clickAway(): Promise; - selectSupportTypeFilter(supportType: string): Promise; - resetSupportTypeFilter(supportType: string): Promise; - verifyMultipleHeadings(headings?: string[]): Promise; - waitForSearchResults(searchText: string): Promise; - verifyPluginDetails({ pluginName, badgeLabel, badgeText, headings, includeTable, includeAbout, }: { - pluginName: string; - badgeLabel: string; - badgeText: string; - headings?: string[]; - includeTable?: boolean; - includeAbout?: boolean; - }): Promise; - verifySupportTypeBadge({ supportType, pluginName, badgeLabel, badgeText, tooltipText, searchTerm, headings, includeTable, includeAbout, }: { - supportType: string; - pluginName?: string; - badgeLabel: string; - badgeText: string; - tooltipText: string; - searchTerm?: string; - headings?: string[]; - includeTable?: boolean; - includeAbout?: boolean; - }): Promise; - verifyKeyValueRowElements(rowTitle: string, rowValue: string): Promise; -} -//# sourceMappingURL=extensions.d.ts.map \ No newline at end of file diff --git a/dist/playwright/pages/extensions.d.ts.map b/dist/playwright/pages/extensions.d.ts.map deleted file mode 100644 index 670d56d..0000000 --- a/dist/playwright/pages/extensions.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"extensions.d.ts","sourceRoot":"","sources":["../../../src/playwright/pages/extensions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAItD,qBAAa,cAAc;IACzB,OAAO,CAAC,IAAI,CAAO;IACZ,KAAK,EAAE,OAAO,CAAC;IACtB,OAAO,CAAC,QAAQ,CAAW;IAE3B,OAAO,CAAC,cAAc,CAOpB;IACF,OAAO,CAAC,YAAY,CAMlB;gBAEU,IAAI,EAAE,IAAI;IAMhB,0BAA0B,CAAC,WAAW,EAAE,MAAM;IAM9C,cAAc,CAAC,IAAI,EAAE,MAAM;IAO3B,YAAY,CAAC,IAAI,EAAE,MAAM;IAOzB,SAAS;IAIT,uBAAuB,CAAC,WAAW,EAAE,MAAM;IAM3C,sBAAsB,CAAC,WAAW,EAAE,MAAM;IAM1C,sBAAsB,CAAC,QAAQ,GAAE,MAAM,EAAwB;IAO/D,oBAAoB,CAAC,UAAU,EAAE,MAAM;IAMvC,mBAAmB,CAAC,EACxB,UAAU,EACV,UAAU,EACV,SAAS,EACT,QAA8B,EAC9B,YAAmB,EACnB,YAAoB,GACrB,EAAE;QACD,UAAU,EAAE,MAAM,CAAC;QACnB,UAAU,EAAE,MAAM,CAAC;QACnB,SAAS,EAAE,MAAM,CAAC;QAClB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,YAAY,CAAC,EAAE,OAAO,CAAC;QACvB,YAAY,CAAC,EAAE,OAAO,CAAC;KACxB;IAuBK,sBAAsB,CAAC,EAC3B,WAAW,EACX,UAAU,EACV,UAAU,EACV,SAAS,EACT,WAAW,EACX,UAAU,EACV,QAA8B,EAC9B,YAAmB,EACnB,YAAoB,GACrB,EAAE;QACD,WAAW,EAAE,MAAM,CAAC;QACpB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,UAAU,EAAE,MAAM,CAAC;QACnB,SAAS,EAAE,MAAM,CAAC;QAClB,WAAW,EAAE,MAAM,CAAC;QACpB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,YAAY,CAAC,EAAE,OAAO,CAAC;QACvB,YAAY,CAAC,EAAE,OAAO,CAAC;KACxB;IA2BK,yBAAyB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;CAMnE"} \ No newline at end of file diff --git a/dist/playwright/pages/extensions.js b/dist/playwright/pages/extensions.js deleted file mode 100644 index 5d8d124..0000000 --- a/dist/playwright/pages/extensions.js +++ /dev/null @@ -1,110 +0,0 @@ -import { expect } from "@playwright/test"; -import { UIhelper } from "../helpers/ui-helper.js"; -export class ExtensionsPage { - page; - badge; - uiHelper; - commonHeadings = [ - "Versions", - "Author", - "Tags", - "Category", - "Publisher", - "Support Provider", - ]; - tableHeaders = [ - "Package name", - "Version", - "Role", - "Backstage compatibility version", - "Status", - ]; - constructor(page) { - this.page = page; - this.badge = this.page.getByTestId("TaskAltIcon"); - this.uiHelper = new UIhelper(page); - } - async clickReadMoreByPluginTitle(pluginTitle) { - const allCards = this.page.locator(".v5-MuiPaper-outlined"); - const targetCard = allCards.filter({ hasText: pluginTitle }); - await targetCard.getByRole("link", { name: "Read more" }).click(); - } - async selectDropdown(name) { - await this.page - .getByLabel(name) - .getByRole("button", { name: "Open" }) - .click(); - } - async toggleOption(name) { - await this.page - .getByRole("option", { name: name }) - .getByRole("checkbox") - .click(); - } - async clickAway() { - await this.page.locator("#menu- div").first().click(); - } - async selectSupportTypeFilter(supportType) { - await this.selectDropdown("Support type"); - await this.toggleOption(supportType); - await this.page.keyboard.press("Escape"); - } - async resetSupportTypeFilter(supportType) { - await this.selectDropdown("Support type"); - await this.toggleOption(supportType); - await this.page.keyboard.press("Escape"); - } - async verifyMultipleHeadings(headings = this.commonHeadings) { - for (const heading of headings) { - console.log(`Verifying heading: ${heading}`); - await this.uiHelper.verifyHeading(heading); - } - } - async waitForSearchResults(searchText) { - await expect(this.page.locator(".v5-MuiPaper-outlined").first()).toContainText(searchText, { timeout: 10000 }); - } - async verifyPluginDetails({ pluginName, badgeLabel, badgeText, headings = this.commonHeadings, includeTable = true, includeAbout = false, }) { - await this.clickReadMoreByPluginTitle(pluginName); - await expect(this.page.getByLabel(badgeLabel).getByText(badgeText)).toBeVisible(); - if (includeAbout) { - await this.uiHelper.verifyText("About"); - } - await this.verifyMultipleHeadings(headings); - if (includeTable) { - await this.uiHelper.verifyTableHeadingAndRows(this.tableHeaders); - } - await this.page - .getByRole("button", { - name: "close", - }) - .click(); - } - async verifySupportTypeBadge({ supportType, pluginName, badgeLabel, badgeText, tooltipText, searchTerm, headings = this.commonHeadings, includeTable = true, includeAbout = false, }) { - await this.selectSupportTypeFilter(supportType); - if (searchTerm) { - await this.uiHelper.searchInputPlaceholder(searchTerm); - await this.waitForSearchResults(searchTerm); - } - if (pluginName) { - await this.verifyPluginDetails({ - pluginName, - badgeLabel, - badgeText, - headings, - includeTable, - includeAbout, - }); - } - else { - await expect(this.page.getByLabel(badgeLabel).first()).toBeVisible(); - await expect(this.badge.first()).toBeVisible(); - await this.badge.first().hover(); - await this.uiHelper.verifyTextInTooltip(tooltipText); - } - await this.resetSupportTypeFilter(supportType); - } - async verifyKeyValueRowElements(rowTitle, rowValue) { - const rowLocator = this.page.locator(".v5-MuiTableRow-root"); - await expect(rowLocator.filter({ hasText: rowTitle })).toContainText(rowValue); - } -} diff --git a/dist/playwright/pages/home-page.d.ts b/dist/playwright/pages/home-page.d.ts deleted file mode 100644 index ce91ea4..0000000 --- a/dist/playwright/pages/home-page.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Page } from "@playwright/test"; -export declare class HomePage { - private page; - private uiHelper; - constructor(page: Page); - verifyQuickSearchBar(text: string): Promise; - verifyQuickAccess(section: string, quickAccessItem: string, expand?: boolean): Promise; - verifyVisitedCardContent(section: string): Promise; -} -//# sourceMappingURL=home-page.d.ts.map \ No newline at end of file diff --git a/dist/playwright/pages/home-page.d.ts.map b/dist/playwright/pages/home-page.d.ts.map deleted file mode 100644 index 41222cc..0000000 --- a/dist/playwright/pages/home-page.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"home-page.d.ts","sourceRoot":"","sources":["../../../src/playwright/pages/home-page.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAG7C,qBAAa,QAAQ;IACnB,OAAO,CAAC,IAAI,CAAO;IACnB,OAAO,CAAC,QAAQ,CAAW;gBAEf,IAAI,EAAE,IAAI;IAIhB,oBAAoB,CAAC,IAAI,EAAE,MAAM;IAUjC,iBAAiB,CACrB,OAAO,EAAE,MAAM,EACf,eAAe,EAAE,MAAM,EACvB,MAAM,UAAQ;IAyBV,wBAAwB,CAAC,OAAO,EAAE,MAAM;CAY/C"} \ No newline at end of file diff --git a/dist/playwright/pages/home-page.js b/dist/playwright/pages/home-page.js deleted file mode 100644 index c98f0bd..0000000 --- a/dist/playwright/pages/home-page.js +++ /dev/null @@ -1,46 +0,0 @@ -import { HOME_PAGE_COMPONENTS, SEARCH_OBJECTS_COMPONENTS, } from "../page-objects/page-obj.js"; -import { UIhelper } from "../helpers/ui-helper.js"; -import { expect } from "@playwright/test"; -export class HomePage { - page; - uiHelper; - constructor(page) { - this.page = page; - this.uiHelper = new UIhelper(page); - } - async verifyQuickSearchBar(text) { - const searchBar = this.page.locator(SEARCH_OBJECTS_COMPONENTS.ariaLabelSearch); - await searchBar.waitFor(); - await searchBar.fill(""); - await searchBar.type(text + "\n"); // '\n' simulates pressing the Enter key - await this.uiHelper.verifyLink(text); - } - async verifyQuickAccess(section, quickAccessItem, expand = false) { - await this.page.waitForSelector(HOME_PAGE_COMPONENTS.MuiAccordion, { - state: "visible", - }); - const sectionLocator = this.page - .locator(HOME_PAGE_COMPONENTS.MuiAccordion) - .filter({ hasText: section }); - if (expand) { - await sectionLocator.click(); - await this.page.waitForTimeout(500); - } - const itemLocator = sectionLocator - .locator(`a div[class*="MuiListItemText-root"]`) - .filter({ hasText: quickAccessItem }); - await itemLocator.waitFor({ state: "visible" }); - const isVisible = itemLocator; - await expect(isVisible).toBeVisible(); - } - async verifyVisitedCardContent(section) { - await this.page.waitForSelector(HOME_PAGE_COMPONENTS.MuiCard, { - state: "visible", - }); - const sectionLocator = this.page - .locator(HOME_PAGE_COMPONENTS.MuiCard) - .filter({ hasText: section }); - const itemLocator = sectionLocator.locator(`li[class*="MuiListItem-root"]`); - expect(await itemLocator.count()).toBeGreaterThanOrEqual(0); - } -} diff --git a/dist/playwright/pages/index.d.ts b/dist/playwright/pages/index.d.ts deleted file mode 100644 index 29a5a43..0000000 --- a/dist/playwright/pages/index.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { CatalogImportPage } from "./catalog-import.js"; -export { CatalogPage } from "./catalog.js"; -export { ExtensionsPage } from "./extensions.js"; -export { HomePage } from "./home-page.js"; -export { NotificationPage } from "./notifications.js"; -export { OrchestratorPage } from "./orchestrator.js"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/playwright/pages/index.d.ts.map b/dist/playwright/pages/index.d.ts.map deleted file mode 100644 index e0dfea1..0000000 --- a/dist/playwright/pages/index.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/playwright/pages/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC"} \ No newline at end of file diff --git a/dist/playwright/pages/index.js b/dist/playwright/pages/index.js deleted file mode 100644 index 7d0d12e..0000000 --- a/dist/playwright/pages/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { CatalogImportPage } from "./catalog-import.js"; -export { CatalogPage } from "./catalog.js"; -export { ExtensionsPage } from "./extensions.js"; -export { HomePage } from "./home-page.js"; -export { NotificationPage } from "./notifications.js"; -export { OrchestratorPage } from "./orchestrator.js"; diff --git a/dist/playwright/pages/notifications.d.ts b/dist/playwright/pages/notifications.d.ts deleted file mode 100644 index 81bec0e..0000000 --- a/dist/playwright/pages/notifications.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { type Page } from "@playwright/test"; -export declare class NotificationPage { - private readonly page; - private readonly uiHelper; - constructor(page: Page); - clickNotificationsNavBarItem(): Promise; - notificationContains(text: string | RegExp): Promise; - clickNotificationHeadingLink(text: string | RegExp): Promise; - markAllNotificationsAsRead(): Promise; - selectAllNotifications(): Promise; - selectNotification(nth?: number): Promise; - selectSeverity(severity?: string): Promise; - saveSelected(): Promise; - saveAllSelected(): Promise; - viewSaved(): Promise; - markLastNotificationAsRead(): Promise; - markNotificationAsRead(text: string): Promise; - markLastNotificationAsUnRead(): Promise; - viewRead(): Promise; - viewUnRead(): Promise; - sortByOldestOnTop(): Promise; - sortByNewestOnTop(): Promise; -} -//# sourceMappingURL=notifications.d.ts.map \ No newline at end of file diff --git a/dist/playwright/pages/notifications.d.ts.map b/dist/playwright/pages/notifications.d.ts.map deleted file mode 100644 index a40a3e7..0000000 --- a/dist/playwright/pages/notifications.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"notifications.d.ts","sourceRoot":"","sources":["../../../src/playwright/pages/notifications.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAGrD,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAO;IAC5B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAW;gBAExB,IAAI,EAAE,IAAI;IAKhB,4BAA4B;IAO5B,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAW1C,4BAA4B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAOlD,0BAA0B;IAiB1B,sBAAsB;IAItB,kBAAkB,CAAC,GAAG,SAAI;IAI1B,cAAc,CAAC,QAAQ,SAAK;IAW5B,YAAY;IAWZ,eAAe;IAWf,SAAS;IAQT,0BAA0B;IAK1B,sBAAsB,CAAC,IAAI,EAAE,MAAM;IAKnC,4BAA4B;IAK5B,QAAQ;IAUR,UAAU;IAUV,iBAAiB;IAQjB,iBAAiB;CAOxB"} \ No newline at end of file diff --git a/dist/playwright/pages/notifications.js b/dist/playwright/pages/notifications.js deleted file mode 100644 index 6cd5ac8..0000000 --- a/dist/playwright/pages/notifications.js +++ /dev/null @@ -1,112 +0,0 @@ -import { expect } from "@playwright/test"; -import { UIhelper } from "../helpers/ui-helper.js"; -export class NotificationPage { - page; - uiHelper; - constructor(page) { - this.page = page; - this.uiHelper = new UIhelper(page); - } - async clickNotificationsNavBarItem() { - await this.uiHelper.openSidebar("Notifications"); - await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); - } - async notificationContains(text) { - await this.page.getByLabel(/.*rows/).click(); - // always expand the notifications table to show as many notifications as possible - await this.page.getByRole("option", { name: "20" }).click(); - await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); - const row = this.page.locator(`tr`, { hasText: text }).first(); - await expect(row).toHaveCount(1); - } - async clickNotificationHeadingLink(text) { - await this.page - .getByRole("cell", { name: text, exact: true }) - .first() - .getByRole("heading") - .click(); - } - async markAllNotificationsAsRead() { - const markAllNotificationsAsReadIsVisible = await this.page - .getByTitle("Mark all read") - .getByRole("button") - .isVisible(); - console.log(markAllNotificationsAsReadIsVisible); - // If button isn't visible there are no records in the notification table - if (markAllNotificationsAsReadIsVisible.toString() != "false") { - await this.page.getByTitle("Mark all read").getByRole("button").click(); - await this.page.getByRole("button", { name: "MARK ALL" }).click(); - await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); - await expect(this.page.getByText("No records to display")).toBeVisible(); - } - } - async selectAllNotifications() { - await this.page.getByRole("checkbox").first().click(); - } - async selectNotification(nth = 1) { - await this.page.getByRole("checkbox").nth(nth).click(); - } - async selectSeverity(severity = "") { - await this.page.getByLabel("Severity").click(); - await this.page.getByRole("option", { name: severity }).click(); - await expect(this.page.getByRole("table").filter({ hasText: "Rows per page" })).toBeVisible(); - await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); - } - async saveSelected() { - await this.page - .locator("thead") - .getByTitle("Save selected for later") - .getByRole("button") - .click(); - await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); - } - async saveAllSelected() { - await this.page - .locator("thead") - .getByTitle("Save selected for later") - .getByRole("button") - .click(); - await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); - } - async viewSaved() { - await this.page.getByLabel("View").click(); - await this.page.getByRole("option", { name: "Saved" }).click(); - await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); - } - async markLastNotificationAsRead() { - const row = this.page.locator("td:nth-child(3) > div").first(); - await row.getByRole("button").nth(1).click(); - } - async markNotificationAsRead(text) { - const row = this.page.locator(`tr:has-text("${text}")`); - await row.getByRole("button").nth(1).click(); - } - async markLastNotificationAsUnRead() { - const row = this.page.locator("td:nth-child(3) > div").first(); - await row.getByRole("button").nth(1).click(); - } - async viewRead() { - await this.page.getByLabel("View").click(); - await this.page - .getByRole("option", { name: "Read notifications", exact: true }) - .click(); - await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); - } - async viewUnRead() { - await this.page.getByLabel("View").click(); - await this.page - .getByRole("option", { name: "Unread notifications", exact: true }) - .click(); - await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); - } - async sortByOldestOnTop() { - await this.page.getByLabel("Sort by").click(); - await this.page.getByRole("option", { name: "Oldest on top" }).click(); - await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); - } - async sortByNewestOnTop() { - await this.page.getByLabel("Sort by").click(); - await this.page.getByRole("option", { name: "Newest on top" }).click(); - await expect(this.page.getByTestId("loading-indicator").getByRole("img")).toHaveCount(0); - } -} diff --git a/dist/playwright/pages/orchestrator.d.ts b/dist/playwright/pages/orchestrator.d.ts deleted file mode 100644 index eb98ce4..0000000 --- a/dist/playwright/pages/orchestrator.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { type Page } from "@playwright/test"; -export declare class OrchestratorPage { - private readonly page; - constructor(page: Page); - selectGreetingWorkflowItem(timeout?: number): Promise; - runGreetingWorkflow(language?: string, status?: string): Promise; - reRunGreetingWorkflow(language?: string, status?: string): Promise; - validateGreetingWorkflow(): Promise; - validateWorkflowRunsDetails(): Promise; - validateWorkflowAllRuns(): Promise; - validateWorkflowAllRunsStatusIcons(): Promise; - abortWorkflow(): Promise; - selectFailSwitchWorkflowItem(timeout?: number): Promise; - runFailSwitchWorkflow(input?: string): Promise; - validateWorkflowStatusDetails(status?: string): Promise; - validateCurrentWorkflowStatus(status?: string, timeout?: number): Promise; - reRunFailSwitchWorkflow(input?: string): Promise; - reRunOnFailure(input?: string): Promise; - verifyWorkflowsTabVisible(): Promise; - verifyWorkflowInEntityTab(workflowName: string): Promise; - clickWorkflowsTab(): Promise; -} -//# sourceMappingURL=orchestrator.d.ts.map \ No newline at end of file diff --git a/dist/playwright/pages/orchestrator.d.ts.map b/dist/playwright/pages/orchestrator.d.ts.map deleted file mode 100644 index 43d65e9..0000000 --- a/dist/playwright/pages/orchestrator.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"orchestrator.d.ts","sourceRoot":"","sources":["../../../src/playwright/pages/orchestrator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAGrD,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAO;gBAEhB,IAAI,EAAE,IAAI;IAIhB,0BAA0B,CAAC,OAAO,GAAE,MAAc;IAclD,mBAAmB,CAAC,QAAQ,SAAY,EAAE,MAAM,SAAc;IAe9D,qBAAqB,CAAC,QAAQ,SAAY,EAAE,MAAM,SAAc;IAchE,wBAAwB;IAwDxB,2BAA2B;IAS3B,uBAAuB;IAiDvB,kCAAkC;IA0BlC,aAAa;IAab,4BAA4B,CAAC,OAAO,GAAE,MAAc;IAepD,qBAAqB,CAAC,KAAK,SAAO;IAsBlC,6BAA6B,CAAC,MAAM,SAAc;IA+DlD,6BAA6B,CAAC,MAAM,SAAc,EAAE,OAAO,SAAS;IAQpE,uBAAuB,CAAC,KAAK,SAAO;IASpC,cAAc,CAAC,KAAK,SAAoB;IAQxC,yBAAyB;IAKzB,yBAAyB,CAAC,YAAY,EAAE,MAAM;IAK9C,iBAAiB;CAIxB"} \ No newline at end of file diff --git a/dist/playwright/pages/orchestrator.js b/dist/playwright/pages/orchestrator.js deleted file mode 100644 index 4684950..0000000 --- a/dist/playwright/pages/orchestrator.js +++ /dev/null @@ -1,248 +0,0 @@ -import { expect } from "@playwright/test"; -import { workflowsTable } from "./workflows.js"; -export class OrchestratorPage { - page; - constructor(page) { - this.page = page; - } - async selectGreetingWorkflowItem(timeout = 30000) { - const workflowHeader = this.page.getByRole("heading", { - name: "Workflows", - }); - await expect(workflowHeader).toBeVisible(); - await expect(workflowHeader).toHaveText("Workflows"); - await expect(workflowsTable(this.page)).toBeVisible(); - const greetingLink = this.page.getByRole("link", { - name: "Greeting workflow", - }); - await expect(greetingLink).toBeVisible({ timeout }); - await greetingLink.click(); - } - async runGreetingWorkflow(language = "English", status = "Completed") { - const runButton = this.page.getByRole("button", { name: "Run" }); - await expect(runButton).toBeVisible(); - await runButton.click(); - await this.page.getByLabel("Language").click(); - await this.page.getByRole("option", { name: language }).click(); - await this.page.getByRole("button", { name: "Next" }).click(); - await this.page.getByRole("button", { name: "Run" }).click(); - await expect(this.page.getByText(`${status}`, { exact: true })).toBeVisible({ - timeout: 600000, - }); - } - async reRunGreetingWorkflow(language = "English", status = "Completed") { - await expect(this.page.getByText("Run again")).toBeVisible(); - await this.page.getByText("Run again").click(); - await this.page.getByLabel("Language").click(); - await this.page.getByRole("option", { name: language }).click(); - await this.page.getByRole("button", { name: "Next" }).click(); - await this.page.getByRole("button", { name: "Run" }).click(); - await expect(this.page.getByText(`${status}`, { exact: true })).toBeVisible({ - timeout: 600000, - }); - } - async validateGreetingWorkflow() { - await this.page.getByRole("tab", { name: "Workflows" }).click(); - const workflowHeader = this.page.getByRole("heading", { - name: "Workflows", - }); - await expect(workflowHeader).toBeVisible(); - await expect(workflowHeader).toHaveText("Workflows"); - await expect(workflowsTable(this.page)).toBeVisible(); - await expect(this.page.locator(`input[aria-label="Search"]`)).toHaveAttribute("placeholder", "Filter"); - await expect(this.page.getByRole("columnheader", { name: "Name", exact: true })).toBeVisible(); - await expect(this.page.getByRole("columnheader", { - name: "Workflow Status", - exact: true, - })).toBeVisible(); - await expect(this.page.getByRole("columnheader", { name: "Last run", exact: true })).toBeVisible(); - await expect(this.page.getByRole("columnheader", { - name: "Last run status", - exact: true, - })).toBeVisible(); - await expect(this.page.getByRole("columnheader", { name: "Actions", exact: true })).toBeVisible(); - const workFlowRow = this.page.locator(`tr:has-text("Greeting workflow")`); - await expect(workFlowRow.locator("td").nth(0)).toHaveText("Greeting workflow"); - await expect(workFlowRow.locator("td").nth(1)).toHaveText("Available"); - await expect(workFlowRow.locator("td").nth(2)).toHaveText(/^\d{1,2}\/\d{1,2}\/\d{4}, \d{1,2}:\d{1,2}:\d{1,2} (AM|PM)$/); - await expect(workFlowRow.locator("td").nth(3)).toHaveText("Completed"); - await expect(workFlowRow.locator("td").nth(4)).toHaveText("YAML based greeting workflow"); - await expect(workFlowRow.getByRole("button", { name: "Run", exact: true }).first()).toBeVisible(); - await expect(workFlowRow.getByRole("button", { name: "View runs" }).first()).toBeVisible(); - await expect(workFlowRow.getByRole("button", { name: "View input schema" }).first()).toBeVisible(); - } - async validateWorkflowRunsDetails() { - await expect(this.page.getByText("Details")).toBeVisible(); - await expect(this.page.getByText("Results")).toBeVisible(); - await expect(this.page.getByText("Workflow progress")).toBeVisible(); - await expect(this.page.locator("div").filter({ hasText: "Completed" }).first()).toBeVisible(); - } - async validateWorkflowAllRuns() { - await this.page.getByRole("tab", { name: "all runs" }).click(); - await expect(this.page - .locator("tbody") - .getByRole("row") - .nth(0) - .getByRole("cell") - .nth(0)).toBeVisible(); - await expect(this.page.getByTestId("select").first()).toHaveAttribute("aria-label", "Status"); - await this.page - .getByLabel("Status") - .getByRole("button", { name: "All" }) - .click(); - const statuses = ["All", "Running", "Failed", "Completed", "Aborted"]; - for (const status of statuses) { - await expect(this.page.getByRole("option", { name: status })).toHaveText(status); - await this.page.getByRole("option", { name: status }).click(); - await this.page - .getByLabel("Status") - .getByRole("button", { name: status }) - .click(); - } - await this.page.getByRole("option", { name: "All" }).click(); - const columnHeaders = [ - "ID", - "Workflow name", - "Run Status", - "Started", - "Duration", - ]; - for (const columnHeader of columnHeaders) { - await expect(this.page.getByRole("columnheader", { - name: columnHeader, - exact: true, - })).toBeVisible(); - } - } - async validateWorkflowAllRunsStatusIcons() { - await this.page.getByRole("tab", { name: "all runs" }).click(); - const statuses = ["Running", "Failed", "Completed", "-- Aborted"]; - for (const status of statuses) { - await expect(this.page.getByText(status).first()).toHaveText(status); - } - await expect(this.page - .getByRole("cell", { name: /Running/ }) - .locator("svg") - .first()).toBeVisible(); - await expect(this.page - .getByRole("cell", { name: /Completed/ }) - .locator("svg") - .first()).toBeVisible(); - await expect(this.page - .getByRole("cell", { name: /Failed/ }) - .locator("svg") - .first()).toBeVisible(); - } - async abortWorkflow() { - await expect(this.page.getByRole("button", { name: "Abort" })).toBeEnabled(); - await this.page.getByRole("button", { name: "Abort" }).click(); - await this.page - .getByRole("dialog", { name: /Abort workflow run\?/i }) - .getByRole("button", { name: "Abort" }) - .click(); - await expect(this.page.getByText("Run has aborted")).toBeVisible(); - await expect(this.page.getByText("-- Aborted")).toBeVisible(); - } - async selectFailSwitchWorkflowItem(timeout = 30000) { - const workflowHeader = this.page.getByRole("heading", { - name: "Workflows", - }); - await expect(workflowHeader).toBeVisible(); - await expect(workflowHeader).toHaveText("Workflows"); - await expect(workflowsTable(this.page)).toBeVisible(); - // Wait for the workflow to be visible with explicit timeout for RBAC permission propagation - const failSwitchLink = this.page.getByRole("link", { - name: "FailSwitch workflow", - }); - await expect(failSwitchLink).toBeVisible({ timeout }); - await failSwitchLink.click(); - } - async runFailSwitchWorkflow(input = "OK") { - const runButton = this.page.getByRole("button", { name: "Run" }); - await expect(runButton).toBeVisible(); - await runButton.click(); - await this.page.getByLabel(/switch/i).click(); - await this.page.getByRole("option", { name: input }).click(); - await this.page.getByRole("button", { name: "Next" }).click(); - await this.page.getByRole("button", { name: "Run" }).click(); - switch (input) { - case "OK": - await this.validateCurrentWorkflowStatus("Completed"); - break; - case "KO": - await this.validateCurrentWorkflowStatus("Failed"); - break; - case "Wait": - await this.validateCurrentWorkflowStatus("Running"); - break; - } - } - async validateWorkflowStatusDetails(status = "Completed") { - const details = this.page - .getByRole("article") - .filter({ has: this.page.getByRole("heading", { name: "Workflow" }) }); - if (status === "Running") { - // Verify Run status heading and spinner in details area - await expect(details.getByRole("heading", { name: /Run\s*status/i })).toBeVisible(); - await expect(this.page - .locator("b") - .filter({ hasText: "Running" }) - .getByRole("progressbar")).toBeVisible(); - // Verify a button shows 'Running' text and has a spinner - const workflowButtons = this.page - .locator("div") - .filter({ hasText: "Abort Running..." }) - .nth(4); - await expect(workflowButtons).toHaveText(/Running/i); - await expect(workflowButtons.getByRole("progressbar")).toBeVisible(); - await expect(this.page.getByTestId("info-card-subheader").getByRole("img")).toBeVisible(); - // Verify workflow is running message is visible with timestamp - // Note: Following line is blocked in main branch due to bug RHDHBUGS-2220. TODO: Uncomment this once the bug is fixed. - await expect(this.page.getByText(/workflow is running\.?\s*Started at\s+\d{1,2}\/\d{1,2}\/\d{4},\s+\d{1,2}:\d{2}:\d{2}\s+(AM|PM)/i)).toBeVisible(); - } - if (status === "Failed") { - await expect(details.getByTestId("ErrorOutlineOutlinedIcon")).toBeVisible(); - await expect(this.page.getByText(/Run has failed at\s+\d{1,2}\/\d{1,2}\/\d{4},\s+\d{1,2}:\d{2}:\d{2}\s+(AM|PM)/)).toBeVisible(); - await expect(this.page.getByTestId("ErrorOutlineOutlinedIcon")).toBeVisible(); - } - if (status === "Completed") { - await expect(this.page - .locator("b") - .filter({ hasText: "Completed" }) - .getByTestId("CheckCircleOutlinedIcon")).toBeVisible(); - await expect(this.page.getByText(/Run completed at\s+\d{1,2}\/\d{1,2}\/\d{4},\s+\d{1,2}:\d{2}:\d{2}\s+(AM|PM)/)).toBeVisible(); - await expect(this.page.getByTestId("SuccessOutlinedIcon")).toBeVisible(); - } - } - async validateCurrentWorkflowStatus(status = "Completed", timeout = 120000) { - await expect(this.page.getByText(`${status}`, { exact: true })).toBeVisible({ - timeout, - }); - } - async reRunFailSwitchWorkflow(input = "OK") { - await expect(this.page.getByText("Run again")).toBeVisible(); - await this.page.getByText("Run again").click(); - await this.page.getByLabel("switch").click(); - await this.page.getByRole("option", { name: input }).click(); - await this.page.getByRole("button", { name: "Next" }).click(); - await this.page.getByRole("button", { name: "Run" }).click(); - } - async reRunOnFailure(input = "Entire workflow") { - await expect(this.page.getByText("Run again")).toBeVisible(); - await this.page.getByText("Run again").click(); - await this.page.getByRole("menuitem", { name: input }).click(); - } - // Entity-Workflow Integration Methods (RHIDP-11833 through RHIDP-11840) - async verifyWorkflowsTabVisible() { - const workflowsTab = this.page.getByRole("tab", { name: "Workflows" }); - await expect(workflowsTab).toBeVisible(); - } - async verifyWorkflowInEntityTab(workflowName) { - const workflowLink = this.page.getByRole("link", { name: workflowName }); - await expect(workflowLink).toBeVisible(); - } - async clickWorkflowsTab() { - await this.page.getByRole("tab", { name: "Workflows" }).click(); - await this.page.waitForLoadState("load"); - } -} diff --git a/dist/playwright/pages/workflows.d.ts b/dist/playwright/pages/workflows.d.ts deleted file mode 100644 index f44189c..0000000 --- a/dist/playwright/pages/workflows.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { Page } from "@playwright/test"; -export declare const workflowsTable: (page: Page) => import("playwright-core").Locator; -//# sourceMappingURL=workflows.d.ts.map \ No newline at end of file diff --git a/dist/playwright/pages/workflows.d.ts.map b/dist/playwright/pages/workflows.d.ts.map deleted file mode 100644 index dc65d8e..0000000 --- a/dist/playwright/pages/workflows.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"workflows.d.ts","sourceRoot":"","sources":["../../../src/playwright/pages/workflows.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAE7C,eAAO,MAAM,cAAc,GAAI,MAAM,IAAI,sCAC0B,CAAC"} \ No newline at end of file diff --git a/dist/playwright/pages/workflows.js b/dist/playwright/pages/workflows.js deleted file mode 100644 index 8e97449..0000000 --- a/dist/playwright/pages/workflows.js +++ /dev/null @@ -1 +0,0 @@ -export const workflowsTable = (page) => page.locator("#root div").filter({ hasText: "Workflows" }).nth(2); diff --git a/dist/playwright/run-once.d.ts b/dist/playwright/run-once.d.ts deleted file mode 100644 index f06d16e..0000000 --- a/dist/playwright/run-once.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Executes a function only once per test run, even across multiple workers. - * Automatically resets between test runs (each run uses a unique flag directory). - * Safe for fullyParallel: true (uses proper-lockfile for cross-process coordination). - * - * @param key - Unique identifier for this setup operation - * @param fn - Function to execute once - * @returns true if executed, false if skipped (already ran) - */ -export declare function runOnce(key: string, fn: () => Promise | void): Promise; -//# sourceMappingURL=run-once.d.ts.map \ No newline at end of file diff --git a/dist/playwright/run-once.d.ts.map b/dist/playwright/run-once.d.ts.map deleted file mode 100644 index 707ffe9..0000000 --- a/dist/playwright/run-once.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"run-once.d.ts","sourceRoot":"","sources":["../../src/playwright/run-once.ts"],"names":[],"mappings":"AAQA;;;;;;;;GAQG;AACH,wBAAsB,OAAO,CAC3B,GAAG,EAAE,MAAM,EACX,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,GAC7B,OAAO,CAAC,OAAO,CAAC,CAyBlB"} \ No newline at end of file diff --git a/dist/playwright/run-once.js b/dist/playwright/run-once.js deleted file mode 100644 index 08ff4b7..0000000 --- a/dist/playwright/run-once.js +++ /dev/null @@ -1,40 +0,0 @@ -import fs from "fs"; -import path from "path"; -import os from "os"; -import lockfile from "proper-lockfile"; -// Each test run gets its own flag directory (ppid = Playwright runner PID) -const flagDir = path.join(os.tmpdir(), `playwright-once-${process.ppid}`); -/** - * Executes a function only once per test run, even across multiple workers. - * Automatically resets between test runs (each run uses a unique flag directory). - * Safe for fullyParallel: true (uses proper-lockfile for cross-process coordination). - * - * @param key - Unique identifier for this setup operation - * @param fn - Function to execute once - * @returns true if executed, false if skipped (already ran) - */ -export async function runOnce(key, fn) { - const flagFile = path.join(flagDir, `${key}.done`); - const lockTarget = path.join(flagDir, key); - fs.mkdirSync(flagDir, { recursive: true }); - // already executed, skip without locking - if (fs.existsSync(flagFile)) - return false; - // Ensure lock target file exists - fs.writeFileSync(lockTarget, "", { flag: "a" }); - const release = await lockfile.lock(lockTarget, { - retries: { retries: 30, minTimeout: 200 }, - stale: 300_000, - }); - try { - // Double-check after acquiring lock - if (fs.existsSync(flagFile)) - return false; - await fn(); - fs.writeFileSync(flagFile, ""); - return true; - } - finally { - await release(); - } -} diff --git a/dist/playwright/teardown-namespaces.d.ts b/dist/playwright/teardown-namespaces.d.ts deleted file mode 100644 index 9a01426..0000000 --- a/dist/playwright/teardown-namespaces.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Registers a namespace for teardown after all tests in a project complete. - * Used by consumers who deploy to custom namespaces (not matching the project name). - */ -export declare function registerTeardownNamespace(projectName: string, namespace: string): void; -/** - * Returns all custom namespaces registered for teardown for a project. - * Used by the teardown reporter. - */ -export declare function getTeardownNamespaces(projectName: string): string[]; -//# sourceMappingURL=teardown-namespaces.d.ts.map \ No newline at end of file diff --git a/dist/playwright/teardown-namespaces.d.ts.map b/dist/playwright/teardown-namespaces.d.ts.map deleted file mode 100644 index 3bc7d0f..0000000 --- a/dist/playwright/teardown-namespaces.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"teardown-namespaces.d.ts","sourceRoot":"","sources":["../../src/playwright/teardown-namespaces.ts"],"names":[],"mappings":"AAsBA;;;GAGG;AACH,wBAAgB,yBAAyB,CACvC,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,GAChB,IAAI,CASN;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,EAAE,CAEnE"} \ No newline at end of file diff --git a/dist/playwright/teardown-namespaces.js b/dist/playwright/teardown-namespaces.js deleted file mode 100644 index c0c90fa..0000000 --- a/dist/playwright/teardown-namespaces.js +++ /dev/null @@ -1,34 +0,0 @@ -import fs from "fs"; -import path from "path"; -import os from "os"; -// Workers use process.ppid (Playwright runner PID) -// Reporter (main process) uses process.pid -// Both resolve to the same directory. -const TEARDOWN_DIR = path.join(os.tmpdir(), `playwright-teardown-${process.ppid || process.pid}`); -const TEARDOWN_FILE = path.join(TEARDOWN_DIR, "namespaces.json"); -function read() { - if (!fs.existsSync(TEARDOWN_FILE)) - return {}; - return JSON.parse(fs.readFileSync(TEARDOWN_FILE, "utf-8")); -} -/** - * Registers a namespace for teardown after all tests in a project complete. - * Used by consumers who deploy to custom namespaces (not matching the project name). - */ -export function registerTeardownNamespace(projectName, namespace) { - fs.mkdirSync(TEARDOWN_DIR, { recursive: true }); - const registry = read(); - const namespaces = registry[projectName] ?? []; - if (!namespaces.includes(namespace)) { - namespaces.push(namespace); - registry[projectName] = namespaces; - fs.writeFileSync(TEARDOWN_FILE, JSON.stringify(registry)); - } -} -/** - * Returns all custom namespaces registered for teardown for a project. - * Used by the teardown reporter. - */ -export function getTeardownNamespaces(projectName) { - return read()[projectName] ?? []; -} diff --git a/dist/playwright/teardown-reporter.d.ts b/dist/playwright/teardown-reporter.d.ts deleted file mode 100644 index 59ab13d..0000000 --- a/dist/playwright/teardown-reporter.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Reporter, Suite, TestCase, TestResult } from "@playwright/test/reporter"; -/** - * Playwright reporter that deletes namespaces per-project as soon as all tests - * in that project finish. This frees cluster resources early instead of waiting - * for the entire suite to complete. - * - * Handles retries: a test is only counted as done when it passes/is skipped, - * or exhausts all retry attempts. - * - * Falls back in onEnd() to clean up any projects that didn't complete naturally - * (e.g., interrupted runs, maxFailures). - * - * Diagnostic log collection runs always (CI and local). - * Namespace deletion only runs when process.env.CI === "true". - * - * By default, deletes the namespace matching the project name. - * For custom namespaces, consumers can register them via registerTeardownNamespace(). - */ -export default class TeardownReporter implements Reporter { - private _projectTestCounts; - private _projectCompleted; - private _projectsWithFailures; - private _pendingDeletions; - onBegin(_config: unknown, suite: Suite): void; - onTestEnd(test: TestCase, result: TestResult): void; - onEnd(): Promise; - private _deleteProjectNamespaces; -} -//# sourceMappingURL=teardown-reporter.d.ts.map \ No newline at end of file diff --git a/dist/playwright/teardown-reporter.d.ts.map b/dist/playwright/teardown-reporter.d.ts.map deleted file mode 100644 index d341630..0000000 --- a/dist/playwright/teardown-reporter.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"teardown-reporter.d.ts","sourceRoot":"","sources":["../../src/playwright/teardown-reporter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,QAAQ,EACR,KAAK,EACL,QAAQ,EACR,UAAU,EACX,MAAM,2BAA2B,CAAC;AAKnC;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,OAAO,OAAO,gBAAiB,YAAW,QAAQ;IACvD,OAAO,CAAC,kBAAkB,CAA6B;IACvD,OAAO,CAAC,iBAAiB,CAA6B;IACtD,OAAO,CAAC,qBAAqB,CAAqB;IAClD,OAAO,CAAC,iBAAiB,CAAoC;IAE7D,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,GAAG,IAAI;IAa7C,SAAS,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,GAAG,IAAI;IA6B7C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YAcd,wBAAwB;CAqDvC"} \ No newline at end of file diff --git a/dist/playwright/teardown-reporter.js b/dist/playwright/teardown-reporter.js deleted file mode 100644 index 0aada2f..0000000 --- a/dist/playwright/teardown-reporter.js +++ /dev/null @@ -1,105 +0,0 @@ -import path from "path"; -import { KubernetesClientHelper } from "../utils/kubernetes-client.js"; -import { getTeardownNamespaces } from "./teardown-namespaces.js"; -/** - * Playwright reporter that deletes namespaces per-project as soon as all tests - * in that project finish. This frees cluster resources early instead of waiting - * for the entire suite to complete. - * - * Handles retries: a test is only counted as done when it passes/is skipped, - * or exhausts all retry attempts. - * - * Falls back in onEnd() to clean up any projects that didn't complete naturally - * (e.g., interrupted runs, maxFailures). - * - * Diagnostic log collection runs always (CI and local). - * Namespace deletion only runs when process.env.CI === "true". - * - * By default, deletes the namespace matching the project name. - * For custom namespaces, consumers can register them via registerTeardownNamespace(). - */ -export default class TeardownReporter { - _projectTestCounts = new Map(); - _projectCompleted = new Map(); - _projectsWithFailures = new Set(); - _pendingDeletions = new Map(); - onBegin(_config, suite) { - for (const test of suite.allTests()) { - const name = test.parent.project()?.name; - if (name) { - this._projectTestCounts.set(name, (this._projectTestCounts.get(name) ?? 0) + 1); - this._projectCompleted.set(name, 0); - } - } - } - onTestEnd(test, result) { - const project = test.parent.project(); - if (!project) - return; - const isDone = result.status === "passed" || - result.status === "skipped" || - result.retry >= project.retries; - if (!isDone) - return; - const name = project.name; - if (result.status !== "passed" && result.status !== "skipped") { - this._projectsWithFailures.add(name); - } - const completed = (this._projectCompleted.get(name) ?? 0) + 1; - this._projectCompleted.set(name, completed); - // Start cleanup immediately (fire-and-forget here, awaited in onEnd) - if (completed === this._projectTestCounts.get(name) && - !this._pendingDeletions.has(name)) { - this._pendingDeletions.set(name, this._deleteProjectNamespaces(name)); - } - } - async onEnd() { - // Await all in-flight cleanups started from onTestEnd - await Promise.all(this._pendingDeletions.values()); - // Fallback: clean up projects that didn't complete naturally - // (e.g., interrupted run, maxFailures hit) β€” always collect diagnostics - for (const [project] of this._projectTestCounts) { - if (!this._pendingDeletions.has(project)) { - this._projectsWithFailures.add(project); - await this._deleteProjectNamespaces(project); - } - } - } - async _deleteProjectNamespaces(projectName) { - let k8sClient; - try { - k8sClient = new KubernetesClientHelper(); - } - catch (error) { - console.error(`[TeardownReporter] Cannot connect to cluster, skipping cleanup:`, error); - return; - } - const customNamespaces = getTeardownNamespaces(projectName); - const namespaces = customNamespaces.length > 0 ? customNamespaces : [projectName]; - // Collect diagnostic logs on failure (always, regardless of CI) - if (this._projectsWithFailures.has(projectName)) { - for (const ns of namespaces) { - const outputDir = path.join("node_modules", ".cache", "e2e-test-results", "logs", projectName); - await k8sClient.collectDiagnosticLogs(ns, outputDir); - } - } - // Retry + catch to avoid crashing Playwright if the cluster becomes unreachable. - const maxAttempts = 2; - if (process.env.CI === "true") { - for (const ns of namespaces) { - console.log(`[TeardownReporter] Deleting namespace "${ns}" (project: ${projectName})`); - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - await k8sClient.deleteNamespace(ns); - break; - } - catch (error) { - console.error(`[TeardownReporter] Failed to delete namespace "${ns}" (attempt ${attempt}/${maxAttempts}):`, error); - if (attempt < maxAttempts) - await new Promise((r) => setTimeout(r, 5000)); - } - } - } - } - } -} diff --git a/dist/utils/bash.d.ts b/dist/utils/bash.d.ts deleted file mode 100644 index a9f1964..0000000 --- a/dist/utils/bash.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { $ } from "zx"; -/** - * Runs a shell command with stdout/stderr captured. On success, output is not printed. - * On non-zero exit, stdout and stderr are written to console.error and an error is thrown. - * Use for noisy commands that should stay quiet on success but show output when they fail. - */ -export declare function runQuietUnlessFailure(strings: TemplateStringsArray, ...values: unknown[]): Promise; -export { $ }; -//# sourceMappingURL=bash.d.ts.map \ No newline at end of file diff --git a/dist/utils/bash.d.ts.map b/dist/utils/bash.d.ts.map deleted file mode 100644 index 568ff19..0000000 --- a/dist/utils/bash.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"bash.d.ts","sourceRoot":"","sources":["../../src/utils/bash.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,IAAI,CAAC;AAYvB;;;;GAIG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,oBAAoB,EAC7B,GAAG,MAAM,EAAE,OAAO,EAAE,GACnB,OAAO,CAAC,IAAI,CAAC,CAuBf;AAED,OAAO,EAAE,CAAC,EAAE,CAAC"} \ No newline at end of file diff --git a/dist/utils/bash.js b/dist/utils/bash.js deleted file mode 100644 index a27e762..0000000 --- a/dist/utils/bash.js +++ /dev/null @@ -1,25 +0,0 @@ -import { $ } from "zx"; -$.quiet = true; -$.stdio = ["inherit", "inherit", "inherit"]; -/** - * Runs a shell command with stdout/stderr captured. On success, output is not printed. - * On non-zero exit, stdout and stderr are written to console.error and an error is thrown. - * Use for noisy commands that should stay quiet on success but show output when they fail. - */ -export async function runQuietUnlessFailure(strings, ...values) { - const runWithPipe = $({ - stdio: ["pipe", "pipe", "pipe"], - nothrow: true, - }); - const result = (await runWithPipe(strings, ...values)); - if (result.exitCode !== 0) { - if (result.stdout?.trim()) { - console.error("[command stdout]:", result.stdout.trim()); - } - if (result.stderr?.trim()) { - console.error("[command stderr]:", result.stderr.trim()); - } - throw new Error(`Command failed with exit code ${result.exitCode}. Output above.`); - } -} -export { $ }; diff --git a/dist/utils/common.d.ts b/dist/utils/common.d.ts deleted file mode 100644 index 02bf12a..0000000 --- a/dist/utils/common.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare function envsubst(str: string, env?: NodeJS.ProcessEnv): string; -declare function requireEnv(...varNames: [string, ...string[]]): void; -export { envsubst, requireEnv }; -//# sourceMappingURL=common.d.ts.map \ No newline at end of file diff --git a/dist/utils/common.d.ts.map b/dist/utils/common.d.ts.map deleted file mode 100644 index ad460e2..0000000 --- a/dist/utils/common.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../src/utils/common.ts"],"names":[],"mappings":"AAAA,iBAAS,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,oBAAc,UAS/C;AAED,iBAAS,UAAU,CAAC,GAAG,QAAQ,EAAE,CAAC,MAAM,EAAE,GAAG,MAAM,EAAE,CAAC,GAAG,IAAI,CAO5D;AAED,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC"} \ No newline at end of file diff --git a/dist/utils/common.js b/dist/utils/common.js deleted file mode 100644 index d1cb394..0000000 --- a/dist/utils/common.js +++ /dev/null @@ -1,16 +0,0 @@ -function envsubst(str, env = process.env) { - const out = str.replace(/\$([A-Za-z_]\w*)|\$\{([A-Za-z_]\w*)(?::-(.*?))?\}/g, (_, v1, v2, def) => { - const k = v1 || v2; - return env[k] ?? (def !== undefined ? def : ""); - }); - return out; -} -function requireEnv(...varNames) { - for (const varName of varNames) { - const value = process.env[varName]; - if (!value) { - throw new Error(`Required variable ${varName} is not set`); - } - } -} -export { envsubst, requireEnv }; diff --git a/dist/utils/index.d.ts b/dist/utils/index.d.ts deleted file mode 100644 index bd4a5e1..0000000 --- a/dist/utils/index.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { envsubst, requireEnv } from "./common.js"; -export { $, runQuietUnlessFailure } from "./bash.js"; -export { mergeYamlFiles, mergeYamlFilesIfExists, mergeYamlFilesToFile, } from "./merge-yamls.js"; -export { KubernetesClientHelper } from "./kubernetes-client.js"; -export { WorkspacePaths } from "./workspace-paths.js"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/utils/index.d.ts.map b/dist/utils/index.d.ts.map deleted file mode 100644 index d884ef7..0000000 --- a/dist/utils/index.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,EAAE,CAAC,EAAE,qBAAqB,EAAE,MAAM,WAAW,CAAC;AACrD,OAAO,EACL,cAAc,EACd,sBAAsB,EACtB,oBAAoB,GACrB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC"} \ No newline at end of file diff --git a/dist/utils/index.js b/dist/utils/index.js deleted file mode 100644 index 7b1aef2..0000000 --- a/dist/utils/index.js +++ /dev/null @@ -1,5 +0,0 @@ -export { envsubst, requireEnv } from "./common.js"; -export { $, runQuietUnlessFailure } from "./bash.js"; -export { mergeYamlFiles, mergeYamlFilesIfExists, mergeYamlFilesToFile, } from "./merge-yamls.js"; -export { KubernetesClientHelper } from "./kubernetes-client.js"; -export { WorkspacePaths } from "./workspace-paths.js"; diff --git a/dist/utils/kubernetes-client.d.ts b/dist/utils/kubernetes-client.d.ts deleted file mode 100644 index dfe5f12..0000000 --- a/dist/utils/kubernetes-client.d.ts +++ /dev/null @@ -1,106 +0,0 @@ -import * as k8s from "@kubernetes/client-node"; -/** - * Kubernetes client wrapper with proper abstraction - */ -declare class KubernetesClientHelper { - private _kc; - private _k8sApi; - private _appsApi; - private _customObjectsApi; - constructor(); - /** - * Create or update a ConfigMap from a file - */ - createOrUpdateConfigMap(name: string, namespace: string, configFilePath: string, dataKey?: string): Promise; - /** - * Create a namespace if it doesn't exist - */ - createNamespaceIfNotExists(namespace: string): Promise; - /** - * Apply a Kubernetes manifest from a YAML file - // */ - /** - * Apply a Kubernetes resource dynamically - */ - /** - * Create or update a Secret - */ - private _applySecret; - /** - * Create or update a ConfigMap from a plain object - */ - applyConfigMapFromObject(name: string, data: Record, namespace: string): Promise; - /** - * Create or update a Secret from a plain object - */ - applySecretFromObject(name: string, data: { - stringData?: Record; - }, namespace: string): Promise; - /** - * Delete a namespace and wait for it to be fully terminated - */ - deleteNamespace(namespace: string, waitForDeletion?: boolean, timeoutSeconds?: number): Promise; - /** - * Wait for a namespace to be fully deleted - */ - private _waitForNamespaceDeletion; - /** - * Check if an error is a "not found" (404) error. - * Handles different error formats from various k8s client versions. - */ - private _isNotFoundError; - /** - * Check if a StatefulSet is ready (all replicas are available) - */ - isStatefulSetReady(namespace: string, name: string): Promise; - /** - * Wait for a StatefulSet to be ready (all replicas available) - */ - waitForStatefulSetReady(namespace: string, name: string, timeoutSeconds?: number, pollIntervalMs?: number): Promise; - /** - * Get the cluster's ingress domain from OpenShift config - * Equivalent to: oc get ingresses.config.openshift.io cluster -o jsonpath='{.spec.domain}' - */ - getClusterIngressDomain(): Promise; - /** - * Get the URL/location of an OpenShift Route by name - * - * @param namespace - The namespace to search in - * @param name - The route name - * @returns The route URL (e.g., https://myapp.apps.cluster.example.com) - */ - getRouteLocation(namespace: string, name: string): Promise; - /** - * Extract the URL from a route object - */ - private _extractRouteUrl; - /** - * Failure states that indicate a pod will not recover without intervention - */ - private static readonly failureReasons; - /** - * Wait for pods matching a label selector to be ready, with early failure detection. - * Fails fast when it detects unrecoverable states like CrashLoopBackOff. - * - * @param namespace - Namespace to watch - * @param labelSelector - Label selector (e.g., "app=myapp") - * @param timeoutSeconds - Maximum time to wait (default: 300) - * @param pollIntervalMs - How often to check pod status (default: 5000) - */ - waitForPodsWithFailureDetection(namespace: string, labelSelector: string, timeoutSeconds?: number, pollIntervalMs?: number): Promise; - /** - * Collects diagnostic logs for all resources in a namespace and saves them as files. - * Uses kubectl for cross-platform compatibility (works on OpenShift, EKS, GKE, etc.). - * OpenShift-specific resources (routes) are collected on a best-effort basis. - * - * @param namespace - Namespace to collect diagnostics from - * @param outputDir - Directory to write log files to (defaults to playwright-report/logs/) - */ - collectDiagnosticLogs(namespace: string, outputDir?: string): Promise; - /** - * Check if a pod is in a failure state. Returns failure info or null if healthy. - */ - private _checkPodFailure; -} -export { KubernetesClientHelper }; -//# sourceMappingURL=kubernetes-client.d.ts.map \ No newline at end of file diff --git a/dist/utils/kubernetes-client.d.ts.map b/dist/utils/kubernetes-client.d.ts.map deleted file mode 100644 index 8a05a5c..0000000 --- a/dist/utils/kubernetes-client.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"kubernetes-client.d.ts","sourceRoot":"","sources":["../../src/utils/kubernetes-client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,GAAG,MAAM,yBAAyB,CAAC;AAO/C;;GAEG;AACH,cAAM,sBAAsB;IAC1B,OAAO,CAAC,GAAG,CAAiB;IAC5B,OAAO,CAAC,OAAO,CAAgB;IAC/B,OAAO,CAAC,QAAQ,CAAgB;IAChC,OAAO,CAAC,iBAAiB,CAAuB;;IAqChD;;OAEG;IACG,uBAAuB,CAC3B,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,EACtB,OAAO,CAAC,EAAE,MAAM,GACf,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC;IA8C3B;;OAEG;IACG,0BAA0B,CAC9B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC;IA+B3B;;UAEM;IAmBN;;OAEG;IAsBH;;OAEG;YACW,YAAY;IA8B1B;;OAEG;IACG,wBAAwB,CAC5B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC;IA0ChB;;OAEG;IACG,qBAAqB,CACzB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,EAC7C,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC;IAqChB;;OAEG;IACG,eAAe,CACnB,SAAS,EAAE,MAAM,EACjB,eAAe,GAAE,OAAc,EAC/B,cAAc,GAAE,MAAY,GAC3B,OAAO,CAAC,IAAI,CAAC;IAyBhB;;OAEG;YACW,yBAAyB;IAsCvC;;;OAGG;IACH,OAAO,CAAC,gBAAgB;IA2BxB;;OAEG;IACG,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAc3E;;OAEG;IACG,uBAAuB,CAC3B,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,cAAc,GAAE,MAAY,EAC5B,cAAc,GAAE,MAAa,GAC5B,OAAO,CAAC,OAAO,CAAC;IAiBnB;;;OAGG;IACG,uBAAuB,IAAI,OAAO,CAAC,MAAM,CAAC;IAuBhD;;;;;;OAMG;IACG,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAmBxE;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAmBxB;;OAEG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,cAAc,CAQnC;IAEH;;;;;;;;OAQG;IACG,+BAA+B,CACnC,SAAS,EAAE,MAAM,EACjB,aAAa,EAAE,MAAM,EACrB,cAAc,GAAE,MAAY,EAC5B,cAAc,GAAE,MAAa,GAC5B,OAAO,CAAC,IAAI,CAAC;IAwFhB;;;;;;;OAOG;IACG,qBAAqB,CACzB,SAAS,EAAE,MAAM,EACjB,SAAS,GAAE,MAMV,GACA,OAAO,CAAC,IAAI,CAAC;IAiGhB;;OAEG;IACH,OAAO,CAAC,gBAAgB;CA8BzB;AAED,OAAO,EAAE,sBAAsB,EAAE,CAAC"} \ No newline at end of file diff --git a/dist/utils/kubernetes-client.js b/dist/utils/kubernetes-client.js deleted file mode 100644 index ce41b16..0000000 --- a/dist/utils/kubernetes-client.js +++ /dev/null @@ -1,623 +0,0 @@ -import { $ } from "./bash.js"; -import * as k8s from "@kubernetes/client-node"; -import * as fs from "fs"; -import * as path from "path"; -import * as yaml from "js-yaml"; -$.verbose = true; -/** - * Kubernetes client wrapper with proper abstraction - */ -class KubernetesClientHelper { - _kc; - _k8sApi; - _appsApi; - _customObjectsApi; - constructor() { - this._kc = new k8s.KubeConfig(); - this._kc.loadFromDefault(); - try { - this._k8sApi = this._kc.makeApiClient(k8s.CoreV1Api); - this._appsApi = this._kc.makeApiClient(k8s.AppsV1Api); - this._customObjectsApi = this._kc.makeApiClient(k8s.CustomObjectsApi); - } - catch (error) { - if (error instanceof Error && - error.message.includes("No active cluster")) { - const currentContext = this._kc.getCurrentContext(); - const contexts = this._kc.getContexts().map((c) => c.name); - throw new Error(`No active Kubernetes cluster found.\n\n` + - `The kubeconfig was loaded but no cluster is configured or the current context is invalid.\n\n` + - `Current context: ${currentContext || "(none)"}\n` + - `Available contexts: ${contexts.length > 0 ? contexts.join(", ") : "(none)"}\n\n` + - `To fix this:\n` + - ` 1. Log in to your k8s cluster: oc login or kubectl login\n` + - ` 2. Or set a valid context: kubectl config use-context \n` + - ` 3. Verify your connection: oc whoami && oc cluster-info\n\n` + - `Kubeconfig locations checked:\n` + - ` - KUBECONFIG env: ${process.env.KUBECONFIG || "(not set)"}\n` + - ` - Default: ~/.kube/config`, { cause: error }); - } - throw error; - } - } - /** - * Create or update a ConfigMap from a file - */ - async createOrUpdateConfigMap(name, namespace, configFilePath, dataKey) { - try { - const fileContent = fs.readFileSync(configFilePath, "utf-8"); - const key = dataKey || path.basename(configFilePath); - const configMap = { - apiVersion: "v1", - kind: "ConfigMap", - metadata: { - name, - namespace, - }, - data: { - [key]: fileContent, - }, - }; - // Check if ConfigMap exists first - try { - await this._k8sApi.readNamespacedConfigMap({ name, namespace }); - // Exists, so update it - const response = await this._k8sApi.replaceNamespacedConfigMap({ - name, - namespace, - body: configMap, - }); - console.log(`βœ“ Updated ConfigMap ${name} in namespace ${namespace}`); - return response; - } - catch { - // Doesn't exist, create it - const response = await this._k8sApi.createNamespacedConfigMap({ - namespace, - body: configMap, - }); - console.log(`βœ“ Created ConfigMap ${name} in namespace ${namespace}`); - return response; - } - } - catch (error) { - console.error(`βœ— Failed to create/update ConfigMap ${name}:`, error instanceof Error ? error.message : error); - throw error; - } - } - /** - * Create a namespace if it doesn't exist - */ - async createNamespaceIfNotExists(namespace) { - if (!namespace?.trim()) - throw new Error("Namespace is required"); - try { - const response = await this._k8sApi.readNamespace({ name: namespace }); - console.log(`βœ“ Namespace ${namespace} already exists`); - return response; - } - catch { - // If read fails (likely 404), try to create - try { - const namespaceObj = { - apiVersion: "v1", - kind: "Namespace", - metadata: { - name: namespace, - }, - }; - const response = await this._k8sApi.createNamespace({ - body: namespaceObj, - }); - console.log(`βœ“ Created namespace ${namespace}`); - return response; - } - catch (createError) { - console.error(`βœ— Failed to create namespace ${namespace}:`, createError instanceof Error ? createError.message : createError); - throw createError; - } - } - } - /** - * Apply a Kubernetes manifest from a YAML file - // */ - // async applyManifest(filePath: string, namespace: string): Promise { - // try { - // const fileContent = fs.readFileSync(filePath, "utf-8"); - // const docs = yaml.loadAll(fileContent) as any[]; - // for (const doc of docs) { - // if (!doc || !doc.kind) continue; - // doc.metadata = doc.metadata || {}; - // doc.metadata.namespace = namespace; - // await this.applyResource(doc, namespace); - // } - // } catch (error: any) { - // console.error(`βœ— Failed to apply manifest ${filePath}:`, error.message); - // throw error; - // } - // } - /** - * Apply a Kubernetes resource dynamically - */ - // private async applyResource(resource: any, namespace: string): Promise { - // const kind = resource.kind; - // const name = resource.metadata.name; - // try { - // switch (kind) { - // case "Secret": - // await this.applySecret(resource, namespace); - // break; - // case "ConfigMap": - // await this.applyConfigMap(resource, namespace); - // break; - // default: - // console.warn(`⚠ Skipping unsupported resource type: ${kind}`); - // } - // } catch (error: any) { - // console.error(`βœ— Failed to apply ${kind} ${name}:`, error.message); - // throw error; - // } - // } - /** - * Create or update a Secret - */ - async _applySecret(secret, namespace) { - const name = secret.metadata.name; - try { - await this._k8sApi.replaceNamespacedSecret({ - name, - namespace, - body: secret, - }); - console.log(`βœ“ Updated Secret ${name} in namespace ${namespace}`); - } - catch { - // If replace fails (likely 404), try to create - try { - await this._k8sApi.createNamespacedSecret({ - namespace, - body: secret, - }); - console.log(`βœ“ Created Secret ${name} in namespace ${namespace}`); - } - catch (createError) { - console.error(`βœ— Failed to create/update Secret ${name} in namespace ${namespace}:`, createError instanceof Error ? createError.message : createError); - throw createError; - } - } - } - /** - * Create or update a ConfigMap from a plain object - */ - async applyConfigMapFromObject(name, data, namespace) { - // Convert the data object to a YAML string - const yamlContent = yaml.dump(data); - // Create proper ConfigMap structure - const fullConfigMap = { - apiVersion: "v1", - kind: "ConfigMap", - metadata: { - name, - namespace, - }, - data: { - [name + ".yaml"]: yamlContent, - }, - }; - try { - await this._k8sApi.replaceNamespacedConfigMap({ - name, - namespace, - body: fullConfigMap, - }); - console.log(`βœ“ Updated ConfigMap ${name} in namespace ${namespace}`); - } - catch { - // Check for 404 status in different possible error structures - try { - await this._k8sApi.createNamespacedConfigMap({ - namespace, - body: fullConfigMap, - }); - console.log(`βœ“ Created ConfigMap ${name} in namespace ${namespace}`); - } - catch (createError) { - console.error(`βœ— Failed to create/update ConfigMap ${name} in namespace ${namespace}:`, createError instanceof Error ? createError.message : createError); - throw createError; - } - } - } - /** - * Create or update a Secret from a plain object - */ - async applySecretFromObject(name, data, namespace) { - // Create proper Secret structure - const fullSecret = { - apiVersion: "v1", - kind: "Secret", - metadata: { - name, - namespace, - }, - stringData: data.stringData, - }; - try { - await this._k8sApi.replaceNamespacedSecret({ - name, - namespace, - body: fullSecret, - }); - console.log(`βœ“ Updated Secret ${name} in namespace ${namespace}`); - } - catch { - // If replace fails (likely 404), try to create - try { - await this._k8sApi.createNamespacedSecret({ - namespace, - body: fullSecret, - }); - console.log(`βœ“ Created Secret ${name} in namespace ${namespace}`); - } - catch (createError) { - console.error(`βœ— Failed to create/update Secret ${name} in namespace ${namespace}:`, createError instanceof Error ? createError.message : createError); - throw createError; - } - } - } - /** - * Delete a namespace and wait for it to be fully terminated - */ - async deleteNamespace(namespace, waitForDeletion = true, timeoutSeconds = 180) { - try { - await this._k8sApi.deleteNamespace({ name: namespace }); - console.log(`[K8sHelper] Deleting namespace ${namespace}...`); - } - catch (error) { - // Ignore if namespace doesn't exist (already deleted), but throw other errors - if (this._isNotFoundError(error)) { - console.log(`βœ“ Namespace ${namespace} already deleted or doesn't exist`); - return; - } - else { - console.error(`βœ— Failed to delete namespace ${namespace}:`, error instanceof Error ? error.message : error); - throw error; - } - } - if (waitForDeletion) { - await this._waitForNamespaceDeletion(namespace, timeoutSeconds); - } - } - /** - * Wait for a namespace to be fully deleted - */ - async _waitForNamespaceDeletion(namespace, timeoutSeconds = 180) { - const startTime = Date.now(); - const timeoutMs = timeoutSeconds * 1000; - const pollIntervalMs = 3000; - while (Date.now() - startTime < timeoutMs) { - try { - const ns = await this._k8sApi.readNamespace({ name: namespace }); - const phase = ns.status?.phase; - // Namespace still exists, wait and retry - if (phase === "Terminating") { - // Only log occasionally to avoid spam - const elapsed = Math.round((Date.now() - startTime) / 1000); - if (elapsed % 10 === 0) { - console.log(`[K8sHelper] Namespace ${namespace} still terminating (${elapsed}s)...`); - } - } - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - } - catch (error) { - // Check for 404 in various error formats from different k8s client versions - if (this._isNotFoundError(error)) { - console.log(`βœ“ Namespace ${namespace} fully deleted`); - return; - } - throw error; - } - } - throw new Error(`Timeout waiting for namespace ${namespace} to be deleted after ${timeoutSeconds}s`); - } - /** - * Check if an error is a "not found" (404) error. - * Handles different error formats from various k8s client versions. - */ - _isNotFoundError(error) { - if (!error) - return false; - // Check error message for "404" or "not found" - if (error instanceof Error) { - const msg = error.message.toLowerCase(); - if (msg.includes("404") || msg.includes("not found")) { - return true; - } - } - // Check various object properties for 404 status codes - const err = error; - return (err.body?.code === 404 || - err.response?.statusCode === 404 || - err.statusCode === 404 || - err.code === 404); - } - /** - * Check if a StatefulSet is ready (all replicas are available) - */ - async isStatefulSetReady(namespace, name) { - try { - const statefulSet = await this._appsApi.readNamespacedStatefulSet({ - name, - namespace, - }); - const replicas = statefulSet.spec?.replicas ?? 1; - const readyReplicas = statefulSet.status?.readyReplicas ?? 0; - return readyReplicas >= replicas; - } - catch { - return false; - } - } - /** - * Wait for a StatefulSet to be ready (all replicas available) - */ - async waitForStatefulSetReady(namespace, name, timeoutSeconds = 300, pollIntervalMs = 5000) { - const startTime = Date.now(); - const timeoutMs = timeoutSeconds * 1000; - while (Date.now() - startTime < timeoutMs) { - if (await this.isStatefulSetReady(namespace, name)) { - console.log(`βœ“ StatefulSet ${name} is ready`); - return true; - } - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - } - throw new Error(`StatefulSet ${name} in namespace ${namespace} not ready after ${timeoutSeconds}s`); - } - /** - * Get the cluster's ingress domain from OpenShift config - * Equivalent to: oc get ingresses.config.openshift.io cluster -o jsonpath='{.spec.domain}' - */ - async getClusterIngressDomain() { - try { - const ingress = await this._customObjectsApi.getClusterCustomObject({ - group: "config.openshift.io", - version: "v1", - plural: "ingresses", - name: "cluster", - }); - const domain = ingress.spec?.domain; - if (!domain) { - throw new Error("Ingress domain not found in cluster config"); - } - return domain; - } - catch (error) { - throw new Error(`Failed to get cluster ingress domain: ${error instanceof Error ? error.message : error}`, { cause: error }); - } - } - /** - * Get the URL/location of an OpenShift Route by name - * - * @param namespace - The namespace to search in - * @param name - The route name - * @returns The route URL (e.g., https://myapp.apps.cluster.example.com) - */ - async getRouteLocation(namespace, name) { - try { - const route = await this._customObjectsApi.getNamespacedCustomObject({ - group: "route.openshift.io", - version: "v1", - namespace, - plural: "routes", - name, - }); - return this._extractRouteUrl(route, name); - } - catch (error) { - throw new Error(`Failed to get route ${name} in namespace ${namespace}: ${error instanceof Error ? error.message : error}`, { cause: error }); - } - } - /** - * Extract the URL from a route object - */ - _extractRouteUrl(route, routeName) { - const routeObj = route; - // Try to get host from spec first, then from status - const host = routeObj.spec?.host || routeObj.status?.ingress?.[0]?.host; - if (!host) { - throw new Error(`Route ${routeName} does not have a host configured`); - } - // Determine protocol based on TLS configuration - const protocol = routeObj.spec?.tls ? "https" : "http"; - return `${protocol}://${host}`; - } - /** - * Failure states that indicate a pod will not recover without intervention - */ - static failureReasons = new Set([ - "CrashLoopBackOff", - "Error", - "ImagePullBackOff", - "ErrImagePull", - "InvalidImageName", - "CreateContainerConfigError", - "CreateContainerError", - ]); - /** - * Wait for pods matching a label selector to be ready, with early failure detection. - * Fails fast when it detects unrecoverable states like CrashLoopBackOff. - * - * @param namespace - Namespace to watch - * @param labelSelector - Label selector (e.g., "app=myapp") - * @param timeoutSeconds - Maximum time to wait (default: 300) - * @param pollIntervalMs - How often to check pod status (default: 5000) - */ - async waitForPodsWithFailureDetection(namespace, labelSelector, timeoutSeconds = 500, pollIntervalMs = 5000) { - const startTime = Date.now(); - const timeoutMs = timeoutSeconds * 1000; - console.log(`[K8sHelper] Waiting for pods (${labelSelector}) in ${namespace}...`); - while (Date.now() - startTime < timeoutMs) { - let pods; - try { - pods = (await this._k8sApi.listNamespacedPod({ namespace, labelSelector })).items; - } - catch (err) { - console.log(`[K8sHelper] API error, retrying: ${err}`); - await new Promise((r) => setTimeout(r, pollIntervalMs)); - continue; - } - if (pods.length === 0) { - await new Promise((r) => setTimeout(r, pollIntervalMs)); - continue; - } - for (const pod of pods) { - const podName = pod.metadata?.name || "unknown"; - const failure = this._checkPodFailure(pod); - if (failure) { - console.log(`[K8sHelper] Pod ${podName} failed: ${failure.reason}`); - try { - if (failure.container) { - await $ `oc logs ${podName} -n ${namespace} -c ${failure.container} --tail=100`; - } - else { - await $ `oc logs ${podName} -n ${namespace} --tail=100`; - } - } - catch { - // Ignore log fetch errors - } - throw new Error(`Pod ${podName} failed: ${failure.reason}`); - } - } - // Check if all pods are ready - const allReady = pods.every((pod) => { - const ready = pod.status?.conditions?.find((c) => c.type === "Ready"); - return ready?.status === "True"; - }); - if (allReady) { - console.log(`[K8sHelper] All ${pods.length} pod(s) ready in ${namespace}`); - return; - } - // Log pod status every 20 seconds - const elapsedSec = Math.floor((Date.now() - startTime) / 1000); - if (elapsedSec > 0 && elapsedSec % 20 === 0) { - try { - await $ `oc get pods -n ${namespace} -l ${labelSelector}`; - } - catch { - // Ignore errors - } - } - await new Promise((r) => setTimeout(r, pollIntervalMs)); - } - // Timeout reached - print diagnostics to stdio before throwing - console.log(`\n[K8sHelper] ═══ Pod Diagnostics (timeout reached) ═══`); - try { - console.log(`\n[K8sHelper] ─── Pod Status ───`); - await $ `oc get pods -n ${namespace} -l ${labelSelector} -o wide`; - console.log(`\n[K8sHelper] ─── Pod Logs ───`); - await $ `oc logs -n ${namespace} -l ${labelSelector} --all-containers --tail=100 2>&1 || true`; - } - catch { - // Ignore errors from diagnostic commands - } - console.log(`\n[K8sHelper] ═══ End Pod Diagnostics ═══\n`); - throw new Error(`Timeout waiting for pods (${labelSelector}) after ${timeoutSeconds}s`); - } - /** - * Collects diagnostic logs for all resources in a namespace and saves them as files. - * Uses kubectl for cross-platform compatibility (works on OpenShift, EKS, GKE, etc.). - * OpenShift-specific resources (routes) are collected on a best-effort basis. - * - * @param namespace - Namespace to collect diagnostics from - * @param outputDir - Directory to write log files to (defaults to playwright-report/logs/) - */ - async collectDiagnosticLogs(namespace, outputDir = path.join("node_modules", ".cache", "e2e-test-results", "logs", namespace)) { - fs.mkdirSync(outputDir, { recursive: true }); - console.log(`[K8sHelper] Collecting diagnostic logs for "${namespace}" β†’ ${outputDir}`); - const quiet = $({ - stdio: ["pipe", "pipe", "pipe"], - timeout: "20s", - }); - const save = async (filePath, cmd) => { - try { - const result = await cmd; - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, result.stdout); - } - catch { - // ignore β€” resource type may not exist on this cluster - } - }; - await Promise.allSettled([ - save(path.join(outputDir, "events.txt"), quiet `kubectl get events -n ${namespace} --sort-by='.lastTimestamp'`), - save(path.join(outputDir, "pods.txt"), quiet `kubectl get pods -n ${namespace} -o wide`), - save(path.join(outputDir, "describe-pods.txt"), quiet `kubectl describe pods -n ${namespace}`), - save(path.join(outputDir, "deployments.txt"), quiet `kubectl get deployments -n ${namespace} -o wide`), - save(path.join(outputDir, "describe-deployments.txt"), quiet `kubectl describe deployments -n ${namespace}`), - save(path.join(outputDir, "statefulsets.txt"), quiet `kubectl get statefulsets -n ${namespace} -o wide`), - save(path.join(outputDir, "routes.txt"), quiet `kubectl get routes -n ${namespace} -o wide`), - ]); - try { - const pods = (await this._k8sApi.listNamespacedPod({ namespace })).items; - const saveLogs = async (filePath, cmd) => { - try { - const result = await cmd; - if (result.stdout.trim()) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, result.stdout); - } - } - catch { - // ignore β€” container may not have started or no previous logs - } - }; - await Promise.allSettled(pods - .filter((pod) => pod.metadata?.name) - .flatMap((pod) => { - const podName = pod.metadata.name; - const podDir = path.join(outputDir, "pods", podName); - const containers = [ - ...(pod.spec?.initContainers ?? []), - ...(pod.spec?.containers ?? []), - ]; - return containers - .filter((c) => c.name) - .flatMap((c) => [ - saveLogs(path.join(podDir, `${c.name}.log`), quiet `kubectl logs ${podName} -n ${namespace} -c ${c.name}`), - saveLogs(path.join(podDir, `${c.name}.previous.log`), quiet `kubectl logs ${podName} -n ${namespace} -c ${c.name} --previous`), - ]); - })); - } - catch { - // ignore - } - } - /** - * Check if a pod is in a failure state. Returns failure info or null if healthy. - */ - _checkPodFailure(pod) { - // Check init containers first - for (const cs of pod.status?.initContainerStatuses || []) { - const reason = cs.state?.waiting?.reason; - if (reason && KubernetesClientHelper.failureReasons.has(reason)) { - return { reason: `Init:${reason}`, container: cs.name }; - } - if (cs.state?.terminated?.exitCode && - cs.state.terminated.exitCode !== 0) { - return { - reason: `Init:Error (exit ${cs.state.terminated.exitCode})`, - container: cs.name, - }; - } - } - // Check main containers - for (const cs of pod.status?.containerStatuses || []) { - const reason = cs.state?.waiting?.reason; - if (reason && KubernetesClientHelper.failureReasons.has(reason)) { - return { reason, container: cs.name }; - } - } - return null; - } -} -export { KubernetesClientHelper }; diff --git a/dist/utils/merge-yamls.d.ts b/dist/utils/merge-yamls.d.ts deleted file mode 100644 index c6d3443..0000000 --- a/dist/utils/merge-yamls.d.ts +++ /dev/null @@ -1,53 +0,0 @@ -import yaml from "js-yaml"; -/** - * Array merge strategy options for YAML merging. - */ -export type ArrayMergeStrategy = "replace" | "concat" | { - byKey: string; - /** Optional: normalize key for matching so different values (e.g. OCI vs local path) map to the same entry. Source wins when merging. */ - normalizeKey?: (item: unknown) => string; -}; -/** - * Options for YAML merging. - */ -export interface MergeOptions { - /** - * Strategy for merging arrays. - * - "replace": Replace arrays entirely (default) - * - "concat": Concatenate arrays - * - { byKey: "keyName" }: Merge arrays of objects by a specific key - * - { byKey: "keyName", normalizeKey }: Same, but match by normalized key (e.g. for plugin deduplication) - */ - arrayMergeStrategy?: ArrayMergeStrategy; -} -/** - * Deeply merges two YAML-compatible objects. - * Array handling is controlled by the arrayMergeStrategy option. - */ -export declare function deepMerge(target: Record, source: Record, options?: MergeOptions): Record; -/** - * Merge multiple YAML files into one object. - * - * @param paths List of YAML file paths (base first, overlays last) - * @param options Optional merge options (e.g., arrayMergeStrategy) - * @returns Merged YAML object - */ -export declare function mergeYamlFiles(paths: string[], options?: MergeOptions): Promise>; -/** - * Merge multiple YAML files if they exist. - * - * @param paths List of YAML file paths - * @param options Optional merge options (e.g., arrayMergeStrategy) - * @returns Merged YAML object - */ -export declare function mergeYamlFilesIfExists(paths: string[], options?: MergeOptions): Promise>; -/** - * Merge multiple YAML files and write the result to an output file. - * - * @param inputPaths List of input YAML files - * @param outputPath Output YAML file path - * @param dumpOptions Optional dump formatting - * @param mergeOptions Optional merge options (e.g., arrayMergeStrategy) - */ -export declare function mergeYamlFilesToFile(inputPaths: string[], outputPath: string, dumpOptions?: yaml.DumpOptions, mergeOptions?: MergeOptions): Promise; -//# sourceMappingURL=merge-yamls.d.ts.map \ No newline at end of file diff --git a/dist/utils/merge-yamls.d.ts.map b/dist/utils/merge-yamls.d.ts.map deleted file mode 100644 index 933401c..0000000 --- a/dist/utils/merge-yamls.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"merge-yamls.d.ts","sourceRoot":"","sources":["../../src/utils/merge-yamls.ts"],"names":[],"mappings":"AACA,OAAO,IAAI,MAAM,SAAS,CAAC;AAG3B;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAC1B,SAAS,GACT,QAAQ,GACR;IACE,KAAK,EAAE,MAAM,CAAC;IACd,yIAAyI;IACzI,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,MAAM,CAAC;CAC1C,CAAC;AAEN;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;CACzC;AA2DD;;;GAGG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,OAAO,GAAE,YAAiB,GACzB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAkBzB;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAClC,KAAK,EAAE,MAAM,EAAE,EACf,OAAO,GAAE,YAAiB,GACzB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAUlC;AAED;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,MAAM,EAAE,EACf,OAAO,GAAE,YAAiB,GACzB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CASlC;AAED;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CACxC,UAAU,EAAE,MAAM,EAAE,EACpB,UAAU,EAAE,MAAM,EAClB,WAAW,GAAE,IAAI,CAAC,WAA+B,EACjD,YAAY,GAAE,YAAiB,GAC9B,OAAO,CAAC,IAAI,CAAC,CAKf"} \ No newline at end of file diff --git a/dist/utils/merge-yamls.js b/dist/utils/merge-yamls.js deleted file mode 100644 index c9590b9..0000000 --- a/dist/utils/merge-yamls.js +++ /dev/null @@ -1,107 +0,0 @@ -import fs from "fs-extra"; -import yaml from "js-yaml"; -import mergeWith from "lodash.mergewith"; -/** - * Returns the merge key for an item: normalized if normalizeKey is provided, else raw key value. - * Returns null if the item is not an object or has no key (and no normalizer is provided). - */ -function getMergeKey(item, key, normalizeKey) { - if (typeof item !== "object" || item === null) { - return null; - } - if (normalizeKey) { - return normalizeKey(item); - } - if (key in item) { - return String(item[key]); - } - return null; -} -/** - * Merges two arrays of objects by a specific key (optionally normalized). - * Objects with matching keys are deeply merged, new objects are appended. Source wins. - */ -function mergeArraysByKey(target, source, keyStrategy, mergeOptions) { - const { byKey: key, normalizeKey } = keyStrategy; - const result = [...target]; - for (const srcItem of source) { - const srcKeyValue = getMergeKey(srcItem, key, normalizeKey); - if (srcKeyValue === null) { - result.push(srcItem); - continue; - } - const existingIndex = result.findIndex((item) => getMergeKey(item, key, normalizeKey) === srcKeyValue); - if (existingIndex !== -1) { - result[existingIndex] = deepMerge(result[existingIndex], srcItem, mergeOptions); - } - else { - result.push(srcItem); - } - } - return result; -} -/** - * Deeply merges two YAML-compatible objects. - * Array handling is controlled by the arrayMergeStrategy option. - */ -export function deepMerge(target, source, options = {}) { - const strategy = options.arrayMergeStrategy ?? "replace"; - return mergeWith({ ...target }, source, (objValue, srcValue) => { - if (Array.isArray(objValue) && Array.isArray(srcValue)) { - if (strategy === "replace") { - return srcValue; - } - else if (strategy === "concat") { - return [...objValue, ...srcValue]; - } - else if (typeof strategy === "object" && "byKey" in strategy) { - return mergeArraysByKey(objValue, srcValue, strategy, options); - } - } - }); -} -/** - * Merge multiple YAML files into one object. - * - * @param paths List of YAML file paths (base first, overlays last) - * @param options Optional merge options (e.g., arrayMergeStrategy) - * @returns Merged YAML object - */ -export async function mergeYamlFiles(paths, options = {}) { - let merged = {}; - for (const path of paths) { - const content = await fs.readFile(path, "utf8"); - const parsed = (yaml.load(content) || {}); - merged = deepMerge(merged, parsed, options); - } - return merged; -} -/** - * Merge multiple YAML files if they exist. - * - * @param paths List of YAML file paths - * @param options Optional merge options (e.g., arrayMergeStrategy) - * @returns Merged YAML object - */ -export async function mergeYamlFilesIfExists(paths, options = {}) { - return await mergeYamlFiles(paths.filter((path) => { - const exists = fs.existsSync(path); - if (!exists) - console.log(`YAML file ${path} does not exist`); - return exists; - }), options); -} -/** - * Merge multiple YAML files and write the result to an output file. - * - * @param inputPaths List of input YAML files - * @param outputPath Output YAML file path - * @param dumpOptions Optional dump formatting - * @param mergeOptions Optional merge options (e.g., arrayMergeStrategy) - */ -export async function mergeYamlFilesToFile(inputPaths, outputPath, dumpOptions = { lineWidth: -1 }, mergeOptions = {}) { - const merged = await mergeYamlFiles(inputPaths, mergeOptions); - const yamlString = yaml.dump(merged, dumpOptions); - await fs.outputFile(outputPath, yamlString); - console.log(`Merged ${inputPaths.length} YAML files into ${outputPath}`); -} diff --git a/dist/utils/merge-yamls.test.d.ts b/dist/utils/merge-yamls.test.d.ts deleted file mode 100644 index 2ac75ee..0000000 --- a/dist/utils/merge-yamls.test.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {}; -//# sourceMappingURL=merge-yamls.test.d.ts.map \ No newline at end of file diff --git a/dist/utils/merge-yamls.test.d.ts.map b/dist/utils/merge-yamls.test.d.ts.map deleted file mode 100644 index be1e18a..0000000 --- a/dist/utils/merge-yamls.test.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"merge-yamls.test.d.ts","sourceRoot":"","sources":["../../src/utils/merge-yamls.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/utils/merge-yamls.test.js b/dist/utils/merge-yamls.test.js deleted file mode 100644 index bbf81f9..0000000 --- a/dist/utils/merge-yamls.test.js +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert"; -import { deepMerge } from "./merge-yamls.js"; -import { getNormalizedPluginMergeKey } from "./plugin-metadata.js"; -describe("deepMerge with arrayMergeStrategy byKey", () => { - it("keeps two plugin entries when package values differ and no normalizeKey", () => { - const target = { - plugins: [ - { - package: "oci://ghcr.io/org/repo/backstage-community-plugin-catalog-backend-module-keycloak:tag!alias", - disabled: false, - }, - ], - }; - const source = { - plugins: [ - { - package: "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic", - disabled: false, - }, - ], - }; - const result = deepMerge(target, source, { - arrayMergeStrategy: { byKey: "package" }, - }); - const plugins = result.plugins; - assert.strictEqual(plugins.length, 2, "without normalizeKey both entries are kept"); - }); - it("merges into one plugin when normalizeKey maps both to same key and source wins", () => { - const target = { - plugins: [ - { - package: "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic", - disabled: false, - }, - ], - }; - const source = { - plugins: [ - { - package: "oci://ghcr.io/org/repo/backstage-community-plugin-catalog-backend-module-keycloak:pr_1__1.0!keycloak", - disabled: false, - }, - ], - }; - const normalizeKey = (item) => getNormalizedPluginMergeKey(item); - const result = deepMerge(target, source, { - arrayMergeStrategy: { byKey: "package", normalizeKey }, - }); - const plugins = result.plugins; - assert.strictEqual(plugins.length, 1, "same normalized key yields one entry"); - assert.ok(plugins[0].package?.startsWith("oci://"), "source (OCI) wins over target (local path)"); - }); -}); diff --git a/dist/utils/plugin-metadata.d.ts b/dist/utils/plugin-metadata.d.ts deleted file mode 100644 index 6c6d40e..0000000 --- a/dist/utils/plugin-metadata.d.ts +++ /dev/null @@ -1,96 +0,0 @@ -export interface PluginMetadata { - packagePath: string; - pluginConfig: Record; - packageName: string; - sourceFile: string; -} -export interface PluginEntry { - package: string; - disabled?: boolean; - pluginConfig?: Record; - [key: string]: unknown; -} -export interface DynamicPluginsConfig { - plugins?: PluginEntry[]; - includes?: string[]; - [key: string]: unknown; -} -/** - * Detects if we're running in a nightly/periodic job context. - * Controls the entire nightly vs PR routing in deployment: - * - Nightly: uses metadata OCI refs (latest published versions), skips metadata injection - * - PR/local: uses metadata + OCI URL replacement - * - * Returns true when: - * - JOB_NAME contains "periodic-" (OpenShift CI nightly/periodic jobs), OR - * - E2E_NIGHTLY_MODE is set (manual override for local testing) - */ -export declare function isNightlyJob(): boolean; -/** - * Extracts the plugin name from a package path or OCI reference. - * Strips the `-dynamic` suffix so local paths and OCI refs for the same - * logical plugin produce the same key. - * - * Handles various formats: - * - Local path: ./dynamic-plugins/dist/backstage-community-plugin-tech-radar-dynamic - * - OCI with alias: oci://quay.io/rhdh/plugin@sha256:...!backstage-community-plugin-tech-radar - * - OCI without alias: oci://quay.io/rhdh/backstage-community-plugin-tech-radar:tag - */ -export declare function extractPluginName(packageRef: string): string; -export declare const DEFAULT_METADATA_PATH = "../metadata"; -export declare function getMetadataDirectory(metadataPath?: string): string | null; -export declare function parseMetadataFile(filePath: string): Promise; -export declare function parseAllMetadataFiles(metadataDir: string): Promise>; -/** - * Resolves plugin package references to their target OCI URLs where applicable. - * - * Resolution priority for each plugin: - * 1. PR OCI URL β€” if GIT_PR_NUMBER set and a PR image was published for this plugin - * 2. Metadata OCI ref β€” uses dynamicArtifact from metadata (latest published version) - * 3. Unchanged β€” local paths, npm packages, or other formats kept as-is - */ -/** - * Returns a stable merge key for a plugin entry so OCI and local path for the same - * logical plugin match when merging dynamic-plugins configs. Strips a trailing - * "-dynamic" so e.g. backstage-community-plugin-catalog-backend-module-keycloak-dynamic - * and ...-keycloak (from OCI) map to the same key. - */ -export declare function getNormalizedPluginMergeKey(entry: { - package?: string; -}): string; -/** - * Generates dynamic-plugins configuration for wrapper plugins - * that need to be disabled. Each plugin entry contains: - * - package: ./dynamic-plugins/dist/$plugin-name - * - disabled: true - * - * @param plugins list of wrapper plugin names - * @returns Dynamic plugins configuration that disables listed wrapper plugins - */ -export declare function disablePluginWrappers(plugins: string[]): DynamicPluginsConfig; -/** - * Auto-generates plugin entries from workspace metadata files. - * Creates raw entries with local paths and disabled: false. - * Does NOT include pluginConfig β€” that's handled by processPluginsForDeployment. - * - * @param metadataPath Optional custom path to metadata directory - * @returns Plugin entries discovered from metadata - */ -export declare function generatePluginsFromMetadata(metadataPath?: string): Promise; -/** - * Processes a dynamic plugins configuration for deployment. - * Single entry point for both PR and nightly flows. - * - * Operations (in order): - * 1. Inject appConfigExamples from metadata (PR mode only, unless RHDH_SKIP_PLUGIN_METADATA_INJECTION is set) - * 2. Resolve all packages to OCI references: - * - PR with GIT_PR_NUMBER: workspace plugins in PR build β†’ pr_ tags, rest unchanged - * - PR without GIT_PR_NUMBER: OCI plugins with metadata β†’ metadata refs, rest unchanged - * - Nightly: OCI plugins with metadata β†’ metadata refs, rest unchanged - * - * @param config The merged dynamic plugins configuration - * @param metadataPath Optional custom path to metadata directory - * @returns Processed configuration ready for deployment - */ -export declare function processPluginsForDeployment(config: DynamicPluginsConfig, metadataPath?: string): Promise; -//# sourceMappingURL=plugin-metadata.d.ts.map \ No newline at end of file diff --git a/dist/utils/plugin-metadata.d.ts.map b/dist/utils/plugin-metadata.d.ts.map deleted file mode 100644 index 5b8bf7a..0000000 --- a/dist/utils/plugin-metadata.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"plugin-metadata.d.ts","sourceRoot":"","sources":["../../src/utils/plugin-metadata.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACpB;AAaD,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAID;;;;;;;;;GASG;AACH,wBAAgB,YAAY,IAAI,OAAO,CAqBtC;AAID;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAI5D;AAYD,eAAO,MAAM,qBAAqB,gBAAgB,CAAC;AAEnD,wBAAgB,oBAAoB,CAClC,YAAY,GAAE,MAA8B,GAC3C,MAAM,GAAG,IAAI,CAQf;AAED,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,cAAc,CAAC,CAyBzB;AAED,wBAAsB,qBAAqB,CACzC,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,CAwBtC;AA2JD;;;;;;;GAOG;AACH;;;;;GAKG;AACH,wBAAgB,2BAA2B,CAAC,KAAK,EAAE;IACjD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,MAAM,CAMT;AA0GD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,oBAAoB,CAW7E;AAED;;;;;;;GAOG;AACH,wBAAsB,2BAA2B,CAC/C,YAAY,GAAE,MAA8B,GAC3C,OAAO,CAAC,oBAAoB,CAAC,CAwB/B;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,2BAA2B,CAC/C,MAAM,EAAE,oBAAoB,EAC5B,YAAY,GAAE,MAA8B,GAC3C,OAAO,CAAC,oBAAoB,CAAC,CA6B/B"} \ No newline at end of file diff --git a/dist/utils/plugin-metadata.js b/dist/utils/plugin-metadata.js deleted file mode 100644 index d1ec76b..0000000 --- a/dist/utils/plugin-metadata.js +++ /dev/null @@ -1,364 +0,0 @@ -import fs from "fs-extra"; -import path from "path"; -import yaml from "js-yaml"; -import { glob } from "zx"; -import { deepMerge } from "./merge-yamls.js"; -const OCI_REGISTRY_PREFIX = "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays"; -// ── Detection ───────────────────────────────────────────────────────────────── -/** - * Detects if we're running in a nightly/periodic job context. - * Controls the entire nightly vs PR routing in deployment: - * - Nightly: uses metadata OCI refs (latest published versions), skips metadata injection - * - PR/local: uses metadata + OCI URL replacement - * - * Returns true when: - * - JOB_NAME contains "periodic-" (OpenShift CI nightly/periodic jobs), OR - * - E2E_NIGHTLY_MODE is set (manual override for local testing) - */ -export function isNightlyJob() { - // PR check takes precedence over nightly mode - if (process.env.GIT_PR_NUMBER) { - return false; - } - if (process.env.E2E_NIGHTLY_MODE === "true" || - process.env.E2E_NIGHTLY_MODE === "1") { - console.log("[PluginMetadata] Nightly mode (E2E_NIGHTLY_MODE is set)"); - return true; - } - const jobName = process.env.JOB_NAME || ""; - if (jobName.includes("periodic-")) { - console.log("[PluginMetadata] Nightly mode (periodic job detected)"); - return true; - } - return false; -} -// ── Utilities ───────────────────────────────────────────────────────────────── -/** - * Extracts the plugin name from a package path or OCI reference. - * Strips the `-dynamic` suffix so local paths and OCI refs for the same - * logical plugin produce the same key. - * - * Handles various formats: - * - Local path: ./dynamic-plugins/dist/backstage-community-plugin-tech-radar-dynamic - * - OCI with alias: oci://quay.io/rhdh/plugin@sha256:...!backstage-community-plugin-tech-radar - * - OCI without alias: oci://quay.io/rhdh/backstage-community-plugin-tech-radar:tag - */ -export function extractPluginName(packageRef) { - const ref = packageRef.includes("!") ? packageRef.split("!")[0] : packageRef; - const match = ref.match(/\/([^/:@]+)(?:[:@].*)?$/); - return (match?.[1] || packageRef).replace(/-dynamic$/, ""); -} -/** - * Derives the displayName from a packageName. - * @backstage-community/plugin-tech-radar β†’ backstage-community-plugin-tech-radar - */ -function toDisplayName(packageName) { - return packageName.replace(/^@/, "").replace(/\//g, "-"); -} -// ── Metadata Loading ────────────────────────────────────────────────────────── -export const DEFAULT_METADATA_PATH = "../metadata"; -export function getMetadataDirectory(metadataPath = DEFAULT_METADATA_PATH) { - const resolvedPath = path.resolve(metadataPath); - if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) { - console.log(`[PluginMetadata] Using metadata directory: ${resolvedPath}`); - return resolvedPath; - } - console.log(`[PluginMetadata] Metadata directory not found: ${resolvedPath}`); - return null; -} -export async function parseMetadataFile(filePath) { - const content = await fs.readFile(filePath, "utf8"); - const parsed = yaml.load(content); - const packagePath = parsed?.spec?.dynamicArtifact; - const packageName = parsed?.spec?.packageName; - const pluginConfig = parsed?.spec?.appConfigExamples?.[0]?.content; - if (!packagePath) { - throw new Error(`[PluginMetadata] Missing required field spec.dynamicArtifact in ${filePath}`); - } - if (!packageName) { - throw new Error(`[PluginMetadata] Missing required field spec.packageName in ${filePath}`); - } - return { - packagePath, - pluginConfig: pluginConfig || {}, - packageName, - sourceFile: filePath, - }; -} -export async function parseAllMetadataFiles(metadataDir) { - const pattern = path.join(metadataDir, "*.yaml"); - const files = await glob(pattern); - console.log(`[PluginMetadata] Found ${files.length} metadata files in ${metadataDir}`); - const metadataMap = new Map(); - for (const file of files) { - const metadata = await parseMetadataFile(file); - const pluginName = extractPluginName(metadata.packagePath); - metadataMap.set(pluginName, metadata); - console.log(`[PluginMetadata] Mapped plugin: ${pluginName} <- ${metadata.packagePath}`); - } - console.log(`[PluginMetadata] Successfully parsed ${metadataMap.size} plugin metadata entries`); - return metadataMap; -} -/** - * Loads and validates metadata from the workspace metadata directory. - * @throws Error if metadata directory not found or no valid metadata files - */ -async function loadMetadata(metadataPath) { - const metadataDir = getMetadataDirectory(metadataPath); - if (!metadataDir) { - throw new Error(`[PluginMetadata] Metadata directory not found at: ${path.resolve(metadataPath)}`); - } - const metadataMap = await parseAllMetadataFiles(metadataDir); - if (metadataMap.size === 0) { - throw new Error(`[PluginMetadata] No valid metadata files found in ${metadataDir}`); - } - return [metadataDir, metadataMap]; -} -/** - * Tries to load metadata, returns empty map if not available. - * Used by processPluginsForDeployment where metadata is optional. - */ -async function tryLoadMetadata(metadataPath) { - const metadataDir = getMetadataDirectory(metadataPath); - if (!metadataDir) - return new Map(); - return await parseAllMetadataFiles(metadataDir); -} -// ── PR: Fetch OCI URLs ─────────────────────────────────────────────────────── -/** - * Fetches plugin versions from source repo and builds OCI URL map. - * Only called when GIT_PR_NUMBER is set. - */ -async function getOCIUrlsForPR(workspacePath, prNumber) { - const ociUrls = new Map(); - const sourceJsonPath = path.join(workspacePath, "source.json"); - const pluginsListPath = path.join(workspacePath, "plugins-list.yaml"); - if (!fs.existsSync(sourceJsonPath)) { - throw new Error(`[PluginMetadata] PR build requires source.json but not found at: ${sourceJsonPath}`); - } - if (!fs.existsSync(pluginsListPath)) { - throw new Error(`[PluginMetadata] PR build requires plugins-list.yaml but not found at: ${pluginsListPath}`); - } - const sourceJson = JSON.parse(await fs.readFile(sourceJsonPath, "utf-8")); - const { repo, "repo-ref": ref, "repo-flat": repoFlat } = sourceJson; - if (!repo) { - throw new Error(`[PluginMetadata] source.json is missing required 'repo' field: ${sourceJsonPath}`); - } - if (!ref) { - throw new Error(`[PluginMetadata] source.json is missing required 'repo-ref' field: ${sourceJsonPath}`); - } - const match = repo.match(/github\.com\/(.+?)(?:\.git)?$/); - if (!match) { - throw new Error(`[PluginMetadata] Failed to parse GitHub repo from source.json: ${repo}`); - } - const ownerRepo = match[1]; - const pluginsListContent = await fs.readFile(pluginsListPath, "utf-8"); - const pluginsListData = yaml.load(pluginsListContent); - if (!pluginsListData || typeof pluginsListData !== "object") { - throw new Error(`[PluginMetadata] plugins-list.yaml is empty or invalid: ${pluginsListPath}`); - } - const pluginPaths = Object.keys(pluginsListData); - const workspaceName = path.basename(workspacePath); - console.log(`[PluginMetadata] Fetching versions for ${pluginPaths.length} plugins from source...`); - for (const pluginPath of pluginPaths) { - const pkgJsonPath = repoFlat - ? `${pluginPath}/package.json` - : `workspaces/${workspaceName}/${pluginPath}/package.json`; - const rawUrl = `https://raw.githubusercontent.com/${ownerRepo}/${ref}/${pkgJsonPath}`; - const res = await fetch(rawUrl); - if (!res.ok) { - throw new Error(`[PluginMetadata] Failed to fetch package.json for ${pluginPath}: ${res.status} ${res.statusText}\n` + - ` URL: ${rawUrl}`); - } - const pkgJson = (await res.json()); - if (!pkgJson.name) { - throw new Error(`[PluginMetadata] package.json is missing 'name' field for ${pluginPath}\n` + - ` URL: ${rawUrl}`); - } - if (!pkgJson.version) { - throw new Error(`[PluginMetadata] package.json is missing 'version' field for ${pluginPath}\n` + - ` URL: ${rawUrl}`); - } - const { name, version } = pkgJson; - const displayName = toDisplayName(name); - // TODO(RHDHBUGS-2530): Remove !alias suffix once Konflux builds include - // io.backstage.dynamic-packages annotation. - const ociUrl = `${OCI_REGISTRY_PREFIX}/${displayName}:pr_${prNumber}__${version}!${displayName}`; - ociUrls.set(displayName, ociUrl); - console.log(`[PluginMetadata] ${displayName} -> ${ociUrl}`); - } - return ociUrls; -} -// ── Core: Unified Plugin Processing ────────────────────────────────────────── -/** - * Resolves plugin package references to their target OCI URLs where applicable. - * - * Resolution priority for each plugin: - * 1. PR OCI URL β€” if GIT_PR_NUMBER set and a PR image was published for this plugin - * 2. Metadata OCI ref β€” uses dynamicArtifact from metadata (latest published version) - * 3. Unchanged β€” local paths, npm packages, or other formats kept as-is - */ -/** - * Returns a stable merge key for a plugin entry so OCI and local path for the same - * logical plugin match when merging dynamic-plugins configs. Strips a trailing - * "-dynamic" so e.g. backstage-community-plugin-catalog-backend-module-keycloak-dynamic - * and ...-keycloak (from OCI) map to the same key. - */ -export function getNormalizedPluginMergeKey(entry) { - const pkg = entry?.package; - if (pkg === undefined || pkg === "") { - return ""; - } - return extractPluginName(pkg); -} -async function resolvePluginPackages(plugins, metadataMap, metadataPath) { - // Build PR OCI URLs if applicable - const prNumber = process.env.GIT_PR_NUMBER; - let prOciUrls = null; - if (prNumber) { - console.log(`[PluginMetadata] PR build detected (PR #${prNumber}), fetching OCI URLs...`); - const workspacePath = path.resolve(metadataPath, ".."); - prOciUrls = await getOCIUrlsForPR(workspacePath, prNumber); - } - return plugins.map((plugin) => { - const pkg = plugin.package; - const pluginName = extractPluginName(pkg); - const metadata = metadataMap.get(pluginName); - // 1. With metadata: resolve to PR OCI URL or metadata's dynamicArtifact - if (metadata?.packageName) { - const displayName = toDisplayName(metadata.packageName); - // PR: use PR-specific OCI URL if this plugin is part of the PR build - if (prOciUrls) { - const prUrl = prOciUrls.get(displayName); - if (prUrl) { - console.log(`[PluginMetadata] PR: ${pkg} β†’ ${prUrl}`); - return { ...plugin, package: prUrl }; - } - } - // Use metadata's dynamicArtifact directly (latest published version). - // This is more accurate than {{inherit}} because metadata is updated daily - // while the DPDY in the catalog index may lag behind. - if (metadata.packagePath.startsWith("oci://")) { - console.log(`[PluginMetadata] ${pkg} β†’ ${metadata.packagePath}`); - return { ...plugin, package: metadata.packagePath }; - } - // Wrapper (local path): metadata is the source of truth. - // The user config may have a stale OCI ref from a previous version. - if (pkg !== metadata.packagePath) { - console.log(`[PluginMetadata] ${pkg} β†’ ${metadata.packagePath}`); - } - return { ...plugin, package: metadata.packagePath }; - } - // 2. Local paths (./dynamic-plugins/dist/...) and other formats β€” keep as-is. - // Local paths reference plugins bundled in the RHDH container image and work - // without OCI resolution. When the catalog index moves all plugins to OCI refs, - // they'll be handled by step 1 or 2 above automatically. - return plugin; - }); -} -/** - * Injects plugin configurations from metadata into a dynamic plugins config. - * Metadata config serves as the base, user-provided pluginConfig overrides it. - */ -function injectMetadataConfig(dynamicPluginsConfig, metadataMap) { - if (!dynamicPluginsConfig.plugins) { - return dynamicPluginsConfig; - } - const augmentedPlugins = dynamicPluginsConfig.plugins.map((plugin) => { - const pluginName = extractPluginName(plugin.package); - const metadata = metadataMap.get(pluginName); - if (!metadata) { - console.log(`[PluginMetadata] No metadata found for: ${pluginName} (from ${plugin.package})`); - return plugin; - } - console.log(`[PluginMetadata] Injecting config for: ${pluginName} (from ${plugin.package})`); - const mergedPluginConfig = deepMerge(metadata.pluginConfig, plugin.pluginConfig || {}); - return { - ...plugin, - pluginConfig: mergedPluginConfig, - }; - }); - return { - ...dynamicPluginsConfig, - plugins: augmentedPlugins, - }; -} -// ── Public API ──────────────────────────────────────────────────────────────── -/** - * Generates dynamic-plugins configuration for wrapper plugins - * that need to be disabled. Each plugin entry contains: - * - package: ./dynamic-plugins/dist/$plugin-name - * - disabled: true - * - * @param plugins list of wrapper plugin names - * @returns Dynamic plugins configuration that disables listed wrapper plugins - */ -export function disablePluginWrappers(plugins) { - const pluginConfig = { - plugins: [], - }; - for (const plugin of plugins) { - pluginConfig.plugins.push({ - package: `./dynamic-plugins/dist/${plugin}`, - disabled: true, - }); - } - return pluginConfig; -} -/** - * Auto-generates plugin entries from workspace metadata files. - * Creates raw entries with local paths and disabled: false. - * Does NOT include pluginConfig β€” that's handled by processPluginsForDeployment. - * - * @param metadataPath Optional custom path to metadata directory - * @returns Plugin entries discovered from metadata - */ -export async function generatePluginsFromMetadata(metadataPath = DEFAULT_METADATA_PATH) { - console.log("[PluginMetadata] Auto-generating plugin entries from metadata..."); - const [, metadataMap] = await loadMetadata(metadataPath); - const plugins = []; - for (const [pluginName, metadata] of metadataMap) { - console.log(`[PluginMetadata] Adding plugin: ${pluginName} (${metadata.packagePath})`); - plugins.push({ - package: metadata.packagePath, - disabled: false, - }); - } - console.log(`[PluginMetadata] Generated ${plugins.length} plugin entries from metadata`); - return { plugins }; -} -/** - * Processes a dynamic plugins configuration for deployment. - * Single entry point for both PR and nightly flows. - * - * Operations (in order): - * 1. Inject appConfigExamples from metadata (PR mode only, unless RHDH_SKIP_PLUGIN_METADATA_INJECTION is set) - * 2. Resolve all packages to OCI references: - * - PR with GIT_PR_NUMBER: workspace plugins in PR build β†’ pr_ tags, rest unchanged - * - PR without GIT_PR_NUMBER: OCI plugins with metadata β†’ metadata refs, rest unchanged - * - Nightly: OCI plugins with metadata β†’ metadata refs, rest unchanged - * - * @param config The merged dynamic plugins configuration - * @param metadataPath Optional custom path to metadata directory - * @returns Processed configuration ready for deployment - */ -export async function processPluginsForDeployment(config, metadataPath = DEFAULT_METADATA_PATH) { - if (!config.plugins) - return config; - const metadataMap = await tryLoadMetadata(metadataPath); - let result = { ...config }; - // Inject appConfigExamples from metadata (PR mode only) - if (!isNightlyJob() && - process.env.RHDH_SKIP_PLUGIN_METADATA_INJECTION !== "true" && - metadataMap.size > 0) { - console.log("[PluginMetadata] Injecting metadata configs..."); - result = injectMetadataConfig(result, metadataMap); - } - // Resolve all packages to OCI references - console.log("[PluginMetadata] Resolving plugin packages to OCI..."); - result = { - ...result, - plugins: await resolvePluginPackages(result.plugins, metadataMap, metadataPath), - }; - return result; -} diff --git a/dist/utils/tests/helpers.d.ts b/dist/utils/tests/helpers.d.ts deleted file mode 100644 index 50bde86..0000000 --- a/dist/utils/tests/helpers.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** Saves and restores process.env around each test. */ -export declare function withCleanEnv(): { - save(): void; - restore(): void; -}; -/** Creates a temporary metadata directory with Package CRD YAML files. */ -export declare function createMetadataFixture(plugins: Array<{ - name: string; - packageName: string; - dynamicArtifact: string; - appConfigExamples?: Record; -}>): Promise; -/** - * Creates a workspace-like directory structure with metadata, source.json, - * and plugins-list.yaml. Used for tests that trigger PR OCI URL fetching. - */ -export declare function createWorkspaceFixture(plugins: Array<{ - name: string; - packageName: string; - dynamicArtifact: string; - appConfigExamples?: Record; -}>): Promise<{ - wsDir: string; - metadataDir: string; -}>; -//# sourceMappingURL=helpers.d.ts.map \ No newline at end of file diff --git a/dist/utils/tests/helpers.d.ts.map b/dist/utils/tests/helpers.d.ts.map deleted file mode 100644 index 15e31fa..0000000 --- a/dist/utils/tests/helpers.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/helpers.ts"],"names":[],"mappings":"AAQA,uDAAuD;AACvD,wBAAgB,YAAY;;;EAa3B;AAED,0EAA0E;AAC1E,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,KAAK,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC7C,CAAC,GACD,OAAO,CAAC,MAAM,CAAC,CAyBjB;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,KAAK,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC7C,CAAC,GACD,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC,CAyCjD"} \ No newline at end of file diff --git a/dist/utils/tests/helpers.js b/dist/utils/tests/helpers.js deleted file mode 100644 index f50593d..0000000 --- a/dist/utils/tests/helpers.js +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Shared test helpers for plugin-metadata tests. - */ -import fs from "fs-extra"; -import path from "path"; -import os from "os"; -import yaml from "js-yaml"; -/** Saves and restores process.env around each test. */ -export function withCleanEnv() { - let savedEnv; - return { - save() { - savedEnv = { ...process.env }; - }, - restore() { - for (const key of Object.keys(process.env)) { - if (!(key in savedEnv)) - delete process.env[key]; - } - Object.assign(process.env, savedEnv); - }, - }; -} -/** Creates a temporary metadata directory with Package CRD YAML files. */ -export async function createMetadataFixture(plugins) { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "metadata-test-")); - for (const plugin of plugins) { - const content = { - apiVersion: "extensions.backstage.io/v1alpha1", - kind: "Package", - metadata: { name: plugin.name }, - spec: { - packageName: plugin.packageName, - dynamicArtifact: plugin.dynamicArtifact, - ...(plugin.appConfigExamples - ? { - appConfigExamples: [ - { title: "Default", content: plugin.appConfigExamples }, - ], - } - : {}), - }, - }; - await fs.writeFile(path.join(tmpDir, `${plugin.name}.yaml`), yaml.dump(content)); - } - return tmpDir; -} -/** - * Creates a workspace-like directory structure with metadata, source.json, - * and plugins-list.yaml. Used for tests that trigger PR OCI URL fetching. - */ -export async function createWorkspaceFixture(plugins) { - const wsDir = await fs.mkdtemp(path.join(os.tmpdir(), "workspace-test-")); - const metadataDir = path.join(wsDir, "metadata"); - await fs.mkdir(metadataDir); - /* eslint-disable @typescript-eslint/naming-convention */ - await fs.writeFile(path.join(wsDir, "source.json"), JSON.stringify({ - repo: "https://github.com/test/repo", - "repo-ref": "main", - "repo-flat": false, - })); - /* eslint-enable @typescript-eslint/naming-convention */ - await fs.writeFile(path.join(wsDir, "plugins-list.yaml"), "{}"); - for (const plugin of plugins) { - const content = { - apiVersion: "extensions.backstage.io/v1alpha1", - kind: "Package", - metadata: { name: plugin.name }, - spec: { - packageName: plugin.packageName, - dynamicArtifact: plugin.dynamicArtifact, - ...(plugin.appConfigExamples - ? { - appConfigExamples: [ - { title: "Default", content: plugin.appConfigExamples }, - ], - } - : {}), - }, - }; - await fs.writeFile(path.join(metadataDir, `${plugin.name}.yaml`), yaml.dump(content)); - } - return { wsDir, metadataDir }; -} diff --git a/dist/utils/tests/plugin-metadata.fixtures.test.d.ts b/dist/utils/tests/plugin-metadata.fixtures.test.d.ts deleted file mode 100644 index b1a7a32..0000000 --- a/dist/utils/tests/plugin-metadata.fixtures.test.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {}; -//# sourceMappingURL=plugin-metadata.fixtures.test.d.ts.map \ No newline at end of file diff --git a/dist/utils/tests/plugin-metadata.fixtures.test.d.ts.map b/dist/utils/tests/plugin-metadata.fixtures.test.d.ts.map deleted file mode 100644 index e2580bd..0000000 --- a/dist/utils/tests/plugin-metadata.fixtures.test.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"plugin-metadata.fixtures.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/plugin-metadata.fixtures.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/utils/tests/plugin-metadata.fixtures.test.js b/dist/utils/tests/plugin-metadata.fixtures.test.js deleted file mode 100644 index ebbb32a..0000000 --- a/dist/utils/tests/plugin-metadata.fixtures.test.js +++ /dev/null @@ -1,563 +0,0 @@ -/** - * Realistic workspace fixture tests β€” based on actual workspace configurations. - * Each test simulates a real workspace's dynamic-plugins.yaml pattern. - */ -/* eslint-disable @typescript-eslint/naming-convention -- test fixtures use real plugin config keys with dots/dashes */ -import { describe, it, beforeEach, afterEach } from "node:test"; -import assert from "node:assert"; -import fs from "fs-extra"; -import { processPluginsForDeployment, generatePluginsFromMetadata, } from "../plugin-metadata.js"; -import { withCleanEnv, createMetadataFixture } from "./helpers.js"; -describe("processPluginsForDeployment β€” workspace fixtures", () => { - const env = withCleanEnv(); - beforeEach(() => env.save()); - afterEach(() => env.restore()); - // ── argocd-like ───────────────────────────────────────────────────────── - describe("argocd-like workspace (OCI with aliases + local kubernetes)", () => { - it("resolves OCI plugins to metadata refs and keeps local plugins unchanged", async () => { - delete process.env.GIT_PR_NUMBER; - delete process.env.E2E_NIGHTLY_MODE; - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-argocd", - packageName: "@backstage-community/plugin-argocd", - dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-argocd:bs_1.45.3__2.4.3!backstage-community-plugin-argocd", - appConfigExamples: { - dynamicPlugins: { - frontend: { - "backstage-community.plugin-argocd": { - mountPoints: [ - { - mountPoint: "entity.page.cd/cards", - importName: "EntityArgocdContent", - }, - ], - }, - }, - }, - }, - }, - { - name: "backstage-community-plugin-argocd-backend", - packageName: "@backstage-community/plugin-argocd-backend", - dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-argocd-backend:bs_1.45.3__1.0.2!backstage-community-plugin-argocd-backend", - }, - ]); - try { - const config = { - plugins: [ - { - package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-argocd:old__tag!backstage-community-plugin-argocd", - disabled: false, - pluginConfig: { - dynamicPlugins: { - frontend: { - "backstage-community.plugin-argocd": { - mountPoints: [ - { - mountPoint: "entity.page.cd/cards", - importName: "CustomArgoContent", - }, - ], - }, - }, - }, - }, - }, - { - package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-argocd-backend:old__tag!backstage-community-plugin-argocd-backend", - disabled: false, - }, - { - package: "./dynamic-plugins/dist/backstage-plugin-kubernetes-backend-dynamic", - disabled: false, - }, - { - package: "./dynamic-plugins/dist/backstage-plugin-kubernetes", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - const plugins = result.plugins; - assert.strictEqual(plugins.length, 4, "must preserve all 4 plugins"); - // OCI argocd frontend β†’ metadata ref - assert.ok(plugins[0].package.includes("bs_1.45.3__2.4.3"), "argocd frontend OCI must resolve to metadata version"); - // User pluginConfig overrides metadata - const frontendConfig = plugins[0].pluginConfig?.dynamicPlugins; - const frontend = frontendConfig?.frontend; - const argoMount = frontend?.["backstage-community.plugin-argocd"]; - const mounts = argoMount?.mountPoints; - assert.strictEqual(mounts?.[0]?.importName, "CustomArgoContent", "user pluginConfig must override metadata mountPoints"); - // OCI argocd backend β†’ metadata ref - assert.ok(plugins[1].package.includes("bs_1.45.3__1.0.2"), "argocd backend OCI must resolve to metadata version"); - // Cross-workspace kubernetes plugins (no metadata) β†’ unchanged - assert.strictEqual(plugins[2].package, "./dynamic-plugins/dist/backstage-plugin-kubernetes-backend-dynamic", "cross-workspace local plugin must stay unchanged"); - assert.strictEqual(plugins[3].package, "./dynamic-plugins/dist/backstage-plugin-kubernetes", "cross-workspace local plugin must stay unchanged"); - } - finally { - await fs.remove(metadataDir); - } - }); - }); - // ── scorecard-like ────────────────────────────────────────────────────── - describe("scorecard-like workspace (disabled plugin + cross-workspace OCI)", () => { - it("preserves disabled flag and handles cross-workspace OCI plugin", async () => { - delete process.env.GIT_PR_NUMBER; - delete process.env.E2E_NIGHTLY_MODE; - const metadataDir = await createMetadataFixture([ - { - name: "rhdh-backstage-plugin-scorecard", - packageName: "@red-hat-developer-hub/backstage-plugin-scorecard", - dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/red-hat-developer-hub-backstage-plugin-scorecard:bs_1.45.3__2.3.5!red-hat-developer-hub-backstage-plugin-scorecard", - }, - ]); - try { - const config = { - plugins: [ - { - package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/red-hat-developer-hub-backstage-plugin-scorecard:old_tag!red-hat-developer-hub-backstage-plugin-scorecard", - disabled: false, - pluginConfig: { dynamicPlugins: { frontend: {} } }, - }, - { - package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/red-hat-developer-hub-backstage-plugin-dynamic-home-page:bs_1.45.3__1.10.3!red-hat-developer-hub-backstage-plugin-dynamic-home-page", - disabled: false, - }, - { - package: "./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-dynamic-home-page", - disabled: true, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - const plugins = result.plugins; - assert.strictEqual(plugins.length, 3, "must preserve all 3 plugins"); - assert.ok(plugins[0].package.includes("bs_1.45.3__2.3.5"), "scorecard must resolve to metadata ref"); - assert.ok(plugins[1].package.includes("bs_1.45.3__1.10.3"), "cross-workspace OCI plugin must keep original tag"); - assert.strictEqual(plugins[2].disabled, true, "disabled flag must be preserved"); - assert.strictEqual(plugins[2].package, "./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-dynamic-home-page", "disabled local path must stay unchanged"); - } - finally { - await fs.remove(metadataDir); - } - }); - }); - // ── github-events-like ────────────────────────────────────────────────── - describe("github-events-like workspace (OCI without aliases + different registries)", () => { - it("resolves each OCI to correct metadata registry in nightly", async () => { - delete process.env.GIT_PR_NUMBER; - process.env.E2E_NIGHTLY_MODE = "true"; - const metadataDir = await createMetadataFixture([ - { - name: "backstage-plugin-events-backend-module-github", - packageName: "@backstage/plugin-events-backend-module-github", - dynamicArtifact: "oci://quay.io/rhdh/backstage-plugin-events-backend-module-github@sha256:c1d17d47aaa", - }, - { - name: "backstage-plugin-catalog-backend-module-github-dynamic", - packageName: "@backstage/plugin-catalog-backend-module-github", - dynamicArtifact: "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-dynamic", - }, - ]); - try { - const config = { - plugins: [ - { - package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-events-backend-module-github:bs_1.45.3__0.4.6", - disabled: false, - pluginConfig: { - events: { http: { topics: ["github"] } }, - }, - }, - { - package: "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-dynamic", - disabled: false, - pluginConfig: { - catalog: { providers: { github: { org: "janus-qe" } } }, - }, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - const plugins = result.plugins; - assert.ok(plugins[0].package.startsWith("oci://quay.io/rhdh/"), "must use quay.io registry from metadata, not the ghcr.io from user config"); - assert.ok(plugins[0].package.includes("@sha256:c1d17d47aaa"), "must preserve digest from metadata"); - assert.deepStrictEqual(plugins[0].pluginConfig, { events: { http: { topics: ["github"] } } }, "nightly must preserve user pluginConfig without metadata injection"); - assert.strictEqual(plugins[1].package, "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-dynamic", "local path from metadata must stay unchanged"); - assert.deepStrictEqual(plugins[1].pluginConfig, { catalog: { providers: { github: { org: "janus-qe" } } } }, "nightly must preserve user pluginConfig for local path plugin"); - } - finally { - await fs.remove(metadataDir); - } - }); - }); - // ── topology-like ─────────────────────────────────────────────────────── - describe("topology-like workspace (all local paths, no OCI)", () => { - it("keeps all local plugins unchanged in both PR and nightly modes", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-topology", - packageName: "@backstage-community/plugin-topology", - dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-topology", - appConfigExamples: { - dynamicPlugins: { - frontend: { - "backstage-community.plugin-topology": { - mountPoints: [{ mountPoint: "entity.page.topology/cards" }], - }, - }, - }, - }, - }, - ]); - try { - const config = { - plugins: [ - { - package: "./dynamic-plugins/dist/backstage-community-plugin-topology", - disabled: false, - }, - { - package: "./dynamic-plugins/dist/backstage-plugin-kubernetes-backend-dynamic", - disabled: false, - }, - { - package: "./dynamic-plugins/dist/backstage-plugin-kubernetes", - disabled: false, - }, - ], - }; - // PR mode - delete process.env.GIT_PR_NUMBER; - delete process.env.E2E_NIGHTLY_MODE; - const prResult = await processPluginsForDeployment(config, metadataDir); - assert.ok(prResult.plugins[0].pluginConfig, "PR mode must inject pluginConfig for topology"); - assert.strictEqual(prResult.plugins[0].package, "./dynamic-plugins/dist/backstage-community-plugin-topology", "local path must stay unchanged in PR mode"); - // Nightly mode - process.env.E2E_NIGHTLY_MODE = "true"; - const nightlyResult = await processPluginsForDeployment({ ...config, plugins: config.plugins.map((p) => ({ ...p })) }, metadataDir); - assert.strictEqual(nightlyResult.plugins[0].pluginConfig, undefined, "nightly must not inject pluginConfig"); - for (const result of [prResult, nightlyResult]) { - for (const plugin of result.plugins) { - assert.ok(plugin.package.startsWith("./dynamic-plugins/dist/"), `all plugins must stay as local paths, got: ${plugin.package}`); - } - } - } - finally { - await fs.remove(metadataDir); - } - }); - }); - // ── global-header-like ────────────────────────────────────────────────── - describe("global-header-like workspace (npm package passthrough)", () => { - it("keeps npm package references with integrity unchanged", async () => { - delete process.env.GIT_PR_NUMBER; - delete process.env.E2E_NIGHTLY_MODE; - const metadataDir = await createMetadataFixture([]); - try { - const npmPackage = "@red-hat-developer-hub/backstage-plugin-global-header-test@0.0.2"; - const config = { - plugins: [ - { - package: npmPackage, - disabled: false, - integrity: "sha512-ABC123...", - pluginConfig: { dynamicPlugins: { frontend: {} } }, - }, - { - package: "./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-global-header", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.strictEqual(result.plugins[0].package, npmPackage, "npm package reference must pass through unchanged"); - assert.strictEqual(result.plugins[0].integrity, "sha512-ABC123...", "integrity hash must be preserved"); - } - finally { - await fs.remove(metadataDir); - } - }); - }); - // ── tech-radar-like (auto-generate) ───────────────────────────────────── - describe("auto-generate from metadata (tech-radar-like, no user config)", () => { - it("generates correct entries from metadata with mixed artifact types", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-tech-radar", - packageName: "@backstage-community/plugin-tech-radar", - dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - appConfigExamples: { - techRadar: { url: "http://example.com/tech-radar" }, - }, - }, - { - name: "backstage-community-plugin-tech-radar-backend-dynamic", - packageName: "@backstage-community/plugin-tech-radar-backend", - dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar-backend-dynamic", - }, - ]); - try { - const generated = await generatePluginsFromMetadata(metadataDir); - assert.strictEqual(generated.plugins.length, 2, "must generate 2 entries"); - for (const plugin of generated.plugins) { - assert.strictEqual(plugin.disabled, false, "generated plugins must be enabled"); - assert.strictEqual(plugin.pluginConfig, undefined, "generated plugins must NOT include pluginConfig"); - } - const packages = generated.plugins.map((p) => p.package).sort(); - assert.ok(packages.includes("./dynamic-plugins/dist/backstage-community-plugin-tech-radar"), "must include tech-radar frontend"); - assert.ok(packages.includes("./dynamic-plugins/dist/backstage-community-plugin-tech-radar-backend-dynamic"), "must include tech-radar backend"); - } - finally { - await fs.remove(metadataDir); - } - }); - }); - // ── orchestrator-like ─────────────────────────────────────────────────── - describe("registry.access.redhat.com plugins (orchestrator-like)", () => { - it("resolves to registry.access.redhat.com from metadata", async () => { - delete process.env.GIT_PR_NUMBER; - delete process.env.E2E_NIGHTLY_MODE; - const metadataDir = await createMetadataFixture([ - { - name: "redhat-backstage-plugin-orchestrator", - packageName: "@redhat/backstage-plugin-orchestrator", - dynamicArtifact: "oci://registry.access.redhat.com/rhdh/red-hat-developer-hub-backstage-plugin-orchestrator@sha256:f40d39fb7599", - }, - ]); - try { - const config = { - plugins: [ - { - package: "oci://ghcr.io/some/other/red-hat-developer-hub-backstage-plugin-orchestrator:some_tag", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.ok(result.plugins[0].package.startsWith("oci://registry.access.redhat.com/rhdh/"), "must use registry.access.redhat.com from metadata"); - assert.ok(result.plugins[0].package.includes("@sha256:f40d39fb7599"), "must preserve digest from metadata"); - } - finally { - await fs.remove(metadataDir); - } - }); - }); - // ── Edge cases ────────────────────────────────────────────────────────── - describe("edge cases", () => { - it("returns config as-is when plugins array is undefined", async () => { - const config = { includes: ["dynamic-plugins.default.yaml"] }; - const result = await processPluginsForDeployment(config); - assert.deepStrictEqual(result, config); - }); - it("handles empty plugins array", async () => { - const metadataDir = await createMetadataFixture([]); - try { - const config = { plugins: [] }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.strictEqual(result.plugins.length, 0); - } - finally { - await fs.remove(metadataDir); - } - }); - it("preserves includes and other top-level fields", async () => { - const metadataDir = await createMetadataFixture([]); - try { - const config = { - includes: ["dynamic-plugins.default.yaml"], - plugins: [], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.deepStrictEqual(result.includes, [ - "dynamic-plugins.default.yaml", - ]); - } - finally { - await fs.remove(metadataDir); - } - }); - it("preserves extra fields on plugin entries (integrity, custom keys)", async () => { - const metadataDir = await createMetadataFixture([]); - try { - const config = { - plugins: [ - { - package: "./dynamic-plugins/dist/some-plugin", - disabled: false, - integrity: "sha512-hash", - customField: "value", - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.strictEqual(result.plugins[0].integrity, "sha512-hash"); - assert.strictEqual(result.plugins[0].customField, "value"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("does not inject pluginConfig for plugins with no appConfigExamples", async () => { - delete process.env.GIT_PR_NUMBER; - delete process.env.E2E_NIGHTLY_MODE; - const metadataDir = await createMetadataFixture([ - { - name: "backstage-plugin-kubernetes-backend-dynamic", - packageName: "@backstage/plugin-kubernetes-backend", - dynamicArtifact: "./dynamic-plugins/dist/backstage-plugin-kubernetes-backend-dynamic", - }, - ]); - try { - const config = { - plugins: [ - { - package: "./dynamic-plugins/dist/backstage-plugin-kubernetes-backend-dynamic", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - const pc = result.plugins[0].pluginConfig; - if (pc) { - assert.deepStrictEqual(pc, {}, "plugins without appConfigExamples must get empty pluginConfig or undefined"); - } - } - finally { - await fs.remove(metadataDir); - } - }); - it("deep merges nested pluginConfig (metadata base + user partial override)", async () => { - delete process.env.GIT_PR_NUMBER; - delete process.env.E2E_NIGHTLY_MODE; - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-argocd", - packageName: "@backstage-community/plugin-argocd", - dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-argocd:bs_1.45.3__2.4.3!backstage-community-plugin-argocd", - appConfigExamples: { - dynamicPlugins: { - frontend: { - "backstage-community.plugin-argocd": { - mountPoints: [ - { - mountPoint: "entity.page.cd/cards", - importName: "ArgoContent", - }, - ], - entityTabs: [{ path: "/cd", title: "CD" }], - }, - }, - }, - }, - }, - ]); - try { - const config = { - plugins: [ - { - package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-argocd:old!backstage-community-plugin-argocd", - disabled: false, - pluginConfig: { - dynamicPlugins: { - frontend: { - "backstage-community.plugin-argocd": { - mountPoints: [ - { - mountPoint: "entity.page.cd/cards", - importName: "CustomArgo", - }, - ], - }, - }, - }, - }, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - const pc = result.plugins[0].pluginConfig; - const dp = pc?.dynamicPlugins; - const fe = dp?.frontend; - const argoConfig = fe?.["backstage-community.plugin-argocd"]; - const mounts = argoConfig?.mountPoints; - assert.strictEqual(mounts?.[0]?.importName, "CustomArgo", "user mountPoints must override metadata mountPoints"); - const tabs = argoConfig?.entityTabs; - assert.ok(tabs, "entityTabs from metadata must be preserved when user doesn't override"); - assert.strictEqual(tabs?.[0]?.path, "/cd", "entityTabs must come from metadata base"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("handles non-existent metadata directory gracefully", async () => { - delete process.env.GIT_PR_NUMBER; - delete process.env.E2E_NIGHTLY_MODE; - const config = { - plugins: [ - { - package: "./dynamic-plugins/dist/some-plugin", - disabled: false, - pluginConfig: { key: "value" }, - }, - ], - }; - const result = await processPluginsForDeployment(config, "/tmp/nonexistent-metadata-dir-12345"); - assert.strictEqual(result.plugins[0].package, "./dynamic-plugins/dist/some-plugin", "plugin must pass through when metadata dir doesn't exist"); - assert.deepStrictEqual(result.plugins[0].pluginConfig, { key: "value" }, "pluginConfig must be preserved when metadata dir doesn't exist"); - }); - it("config has local path but metadata has OCI artifact for same plugin", async () => { - delete process.env.GIT_PR_NUMBER; - delete process.env.E2E_NIGHTLY_MODE; - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-tekton", - packageName: "@backstage-community/plugin-tekton", - dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:bs_1.45.3__3.33.3!backstage-community-plugin-tekton", - }, - ]); - try { - const config = { - plugins: [ - { - package: "./dynamic-plugins/dist/backstage-community-plugin-tekton", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.ok(result.plugins[0].package.startsWith("oci://"), "local path in config must be resolved to OCI when metadata has OCI dynamicArtifact"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("shared OCI image with alias (redhat-resource-optimization pattern)", async () => { - delete process.env.GIT_PR_NUMBER; - delete process.env.E2E_NIGHTLY_MODE; - const metadataDir = await createMetadataFixture([ - { - name: "redhat-resource-optimization", - packageName: "@red-hat-developer-hub/plugin-redhat-resource-optimization", - dynamicArtifact: "oci://quay.io/redhat-resource-optimization/dynamic-plugins:1.3.2!red-hat-developer-hub-plugin-redhat-resource-optimization", - }, - ]); - try { - const config = { - plugins: [ - { - package: "oci://quay.io/redhat-resource-optimization/dynamic-plugins:old_tag!red-hat-developer-hub-plugin-redhat-resource-optimization", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.strictEqual(result.plugins[0].package, "oci://quay.io/redhat-resource-optimization/dynamic-plugins:1.3.2!red-hat-developer-hub-plugin-redhat-resource-optimization", "shared OCI image must resolve to metadata version with alias preserved"); - } - finally { - await fs.remove(metadataDir); - } - }); - }); -}); diff --git a/dist/utils/tests/plugin-metadata.nightly.test.d.ts b/dist/utils/tests/plugin-metadata.nightly.test.d.ts deleted file mode 100644 index 48822d4..0000000 --- a/dist/utils/tests/plugin-metadata.nightly.test.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {}; -//# sourceMappingURL=plugin-metadata.nightly.test.d.ts.map \ No newline at end of file diff --git a/dist/utils/tests/plugin-metadata.nightly.test.d.ts.map b/dist/utils/tests/plugin-metadata.nightly.test.d.ts.map deleted file mode 100644 index 0b9a1cd..0000000 --- a/dist/utils/tests/plugin-metadata.nightly.test.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"plugin-metadata.nightly.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/plugin-metadata.nightly.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/utils/tests/plugin-metadata.nightly.test.js b/dist/utils/tests/plugin-metadata.nightly.test.js deleted file mode 100644 index c92a6ae..0000000 --- a/dist/utils/tests/plugin-metadata.nightly.test.js +++ /dev/null @@ -1,206 +0,0 @@ -/** - * Nightly mode tests β€” isNightlyJob detection and nightly plugin resolution. - */ -import { describe, it, beforeEach, afterEach } from "node:test"; -import assert from "node:assert"; -import fs from "fs-extra"; -import { isNightlyJob, processPluginsForDeployment, } from "../plugin-metadata.js"; -import { withCleanEnv, createMetadataFixture } from "./helpers.js"; -// ── isNightlyJob ───────────────────────────────────────────────────────────── -describe("isNightlyJob", () => { - const env = withCleanEnv(); - beforeEach(() => env.save()); - afterEach(() => env.restore()); - it("returns false with no env vars set", () => { - delete process.env.E2E_NIGHTLY_MODE; - delete process.env.JOB_NAME; - delete process.env.GIT_PR_NUMBER; - assert.strictEqual(isNightlyJob(), false); - }); - it("returns true when E2E_NIGHTLY_MODE is 'true'", () => { - delete process.env.GIT_PR_NUMBER; - process.env.E2E_NIGHTLY_MODE = "true"; - assert.strictEqual(isNightlyJob(), true); - }); - it("returns true when E2E_NIGHTLY_MODE is '1'", () => { - delete process.env.GIT_PR_NUMBER; - process.env.E2E_NIGHTLY_MODE = "1"; - assert.strictEqual(isNightlyJob(), true); - }); - it("returns false when E2E_NIGHTLY_MODE is 'false' (strict check)", () => { - delete process.env.GIT_PR_NUMBER; - process.env.E2E_NIGHTLY_MODE = "false"; - assert.strictEqual(isNightlyJob(), false, "'false' string must not trigger nightly mode"); - }); - it("returns false when E2E_NIGHTLY_MODE is empty string", () => { - delete process.env.GIT_PR_NUMBER; - process.env.E2E_NIGHTLY_MODE = ""; - assert.strictEqual(isNightlyJob(), false, "empty string must not trigger nightly mode"); - }); - it("returns true when JOB_NAME contains 'periodic-'", () => { - delete process.env.GIT_PR_NUMBER; - delete process.env.E2E_NIGHTLY_MODE; - process.env.JOB_NAME = "periodic-ci-overlay-e2e-nightly"; - assert.strictEqual(isNightlyJob(), true); - }); - it("returns false when JOB_NAME contains 'periodic' without trailing dash", () => { - delete process.env.GIT_PR_NUMBER; - delete process.env.E2E_NIGHTLY_MODE; - process.env.JOB_NAME = "run-periodically"; - assert.strictEqual(isNightlyJob(), false, "'periodic' without dash must not trigger nightly mode"); - }); - it("returns false when GIT_PR_NUMBER is set (PR takes precedence)", () => { - process.env.GIT_PR_NUMBER = "42"; - process.env.E2E_NIGHTLY_MODE = "true"; - assert.strictEqual(isNightlyJob(), false, "GIT_PR_NUMBER must take precedence over nightly mode"); - }); - it("returns false when GIT_PR_NUMBER is set even with periodic JOB_NAME", () => { - process.env.GIT_PR_NUMBER = "42"; - process.env.JOB_NAME = "periodic-ci-overlay-e2e-nightly"; - assert.strictEqual(isNightlyJob(), false, "GIT_PR_NUMBER must take precedence over periodic job detection"); - }); -}); -// ── Nightly resolution scenarios ───────────────────────────────────────────── -describe("processPluginsForDeployment β€” nightly mode", () => { - const env = withCleanEnv(); - beforeEach(() => { - env.save(); - delete process.env.GIT_PR_NUMBER; - process.env.E2E_NIGHTLY_MODE = "true"; - }); - afterEach(() => env.restore()); - it("skips metadata injection in nightly mode", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-tech-radar", - packageName: "@backstage-community/plugin-tech-radar", - dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - appConfigExamples: { - techRadar: { url: "http://default.example.com" }, - }, - }, - ]); - try { - const config = { - plugins: [ - { - package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.strictEqual(result.plugins[0].pluginConfig, undefined, "nightly mode must NOT inject metadata pluginConfig"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("preserves user-provided pluginConfig in nightly mode", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-tech-radar", - packageName: "@backstage-community/plugin-tech-radar", - dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - appConfigExamples: { - techRadar: { url: "http://metadata.example.com" }, - }, - }, - ]); - try { - const userPluginConfig = { - techRadar: { url: "http://user.example.com" }, - }; - const config = { - plugins: [ - { - package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - disabled: false, - pluginConfig: userPluginConfig, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.deepStrictEqual(result.plugins[0].pluginConfig, userPluginConfig, "nightly mode must preserve user pluginConfig exactly as-is"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("resolves OCI plugin to metadata dynamicArtifact in nightly", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-tekton", - packageName: "@backstage-community/plugin-tekton", - dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:bs_1.45.3__3.33.3!backstage-community-plugin-tekton", - }, - ]); - try { - const config = { - plugins: [ - { - package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:old_stale_tag!backstage-community-plugin-tekton", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.ok(result.plugins[0].package.includes("bs_1.45.3__3.33.3"), "nightly must resolve to metadata dynamicArtifact (latest published version)"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("resolves wrapper plugin to wrapper path when user config has stale OCI ref", async () => { - // Reproduces: metadata says plugin is a wrapper (local path), but user's - // dynamic-plugins.yaml has a hardcoded OCI ref from a previous version. - // In nightly mode, the plugin should resolve to the wrapper path from - // metadata, not pass through the stale OCI ref unchanged. - const metadataDir = await createMetadataFixture([ - { - name: "backstage-plugin-catalog-backend-module-github-org", - packageName: "@backstage/plugin-catalog-backend-module-github-org", - dynamicArtifact: "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-org-dynamic", - }, - ]); - try { - const config = { - plugins: [ - { - package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-catalog-backend-module-github-org:bs_1.45.3__0.3.16", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.strictEqual(result.plugins[0].package, "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-org-dynamic", "when metadata has a wrapper path, nightly must resolve to wrapper β€” not pass through stale OCI ref from user config"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("keeps local path plugins unchanged in nightly", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "red-hat-developer-hub-backstage-plugin-quickstart", - packageName: "@red-hat-developer-hub/backstage-plugin-quickstart", - dynamicArtifact: "./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-quickstart", - }, - ]); - try { - const config = { - plugins: [ - { - package: "./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-quickstart", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.strictEqual(result.plugins[0].package, "./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-quickstart", "local path plugins must not be converted to OCI in nightly"); - } - finally { - await fs.remove(metadataDir); - } - }); -}); diff --git a/dist/utils/tests/plugin-metadata.pr.test.d.ts b/dist/utils/tests/plugin-metadata.pr.test.d.ts deleted file mode 100644 index 5560052..0000000 --- a/dist/utils/tests/plugin-metadata.pr.test.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {}; -//# sourceMappingURL=plugin-metadata.pr.test.d.ts.map \ No newline at end of file diff --git a/dist/utils/tests/plugin-metadata.pr.test.d.ts.map b/dist/utils/tests/plugin-metadata.pr.test.d.ts.map deleted file mode 100644 index cbd9066..0000000 --- a/dist/utils/tests/plugin-metadata.pr.test.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"plugin-metadata.pr.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/plugin-metadata.pr.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/utils/tests/plugin-metadata.pr.test.js b/dist/utils/tests/plugin-metadata.pr.test.js deleted file mode 100644 index c6dbed8..0000000 --- a/dist/utils/tests/plugin-metadata.pr.test.js +++ /dev/null @@ -1,351 +0,0 @@ -/** - * PR mode tests β€” metadata injection, OCI resolution, skip injection, precedence. - */ -import { describe, it, beforeEach, afterEach } from "node:test"; -import assert from "node:assert"; -import fs from "fs-extra"; -import { isNightlyJob, processPluginsForDeployment, } from "../plugin-metadata.js"; -import { withCleanEnv, createMetadataFixture, createWorkspaceFixture, } from "./helpers.js"; -describe("processPluginsForDeployment β€” PR mode", () => { - const env = withCleanEnv(); - beforeEach(() => { - env.save(); - delete process.env.E2E_NIGHTLY_MODE; - delete process.env.JOB_NAME; - delete process.env.GIT_PR_NUMBER; - }); - afterEach(() => env.restore()); - it("injects appConfigExamples from metadata as base pluginConfig", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-tech-radar", - packageName: "@backstage-community/plugin-tech-radar", - dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - appConfigExamples: { - techRadar: { url: "http://default.example.com" }, - }, - }, - ]); - try { - const config = { - plugins: [ - { - package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.deepStrictEqual(result.plugins[0].pluginConfig, { techRadar: { url: "http://default.example.com" } }, "metadata appConfigExamples must be injected as pluginConfig in PR mode"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("user pluginConfig overrides metadata appConfigExamples", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-tech-radar", - packageName: "@backstage-community/plugin-tech-radar", - dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - appConfigExamples: { - techRadar: { url: "http://default.example.com" }, - }, - }, - ]); - try { - const config = { - plugins: [ - { - package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - disabled: false, - pluginConfig: { - techRadar: { url: "http://custom.example.com" }, - }, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.strictEqual(result.plugins[0].pluginConfig - .techRadar && - result.plugins[0].pluginConfig - .techRadar.url, "http://custom.example.com", "user pluginConfig must override metadata defaults"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("resolves OCI plugin to metadata dynamicArtifact when no PR number", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-tekton", - packageName: "@backstage-community/plugin-tekton", - dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:bs_1.45.3__3.33.3!backstage-community-plugin-tekton", - }, - ]); - try { - const config = { - plugins: [ - { - package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:old_tag!backstage-community-plugin-tekton", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.strictEqual(result.plugins[0].package, "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:bs_1.45.3__3.33.3!backstage-community-plugin-tekton", "OCI plugin must be resolved to metadata dynamicArtifact"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("keeps local path plugins unchanged", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-tech-radar", - packageName: "@backstage-community/plugin-tech-radar", - dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - }, - ]); - try { - const config = { - plugins: [ - { - package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.strictEqual(result.plugins[0].package, "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", "local path plugins must stay unchanged"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("keeps plugins without metadata unchanged", async () => { - const metadataDir = await createMetadataFixture([]); - try { - const keycloakPackage = "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic"; - const config = { - plugins: [{ package: keycloakPackage, disabled: false }], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.strictEqual(result.plugins[0].package, keycloakPackage, "plugins not in workspace metadata must stay unchanged"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("resolves OCI plugin from different registry using metadata ref", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "backstage-plugin-events-backend-module-github", - packageName: "@backstage/plugin-events-backend-module-github", - dynamicArtifact: "oci://quay.io/rhdh/backstage-plugin-events-backend-module-github@sha256:abc123", - }, - ]); - try { - const config = { - plugins: [ - { - package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-events-backend-module-github:bs_1.45.3__0.4.6", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.ok(result.plugins[0].package.startsWith("oci://quay.io/rhdh/"), "must use the actual registry from metadata, not hardcoded ghcr.io"); - assert.strictEqual(result.plugins[0].package, "oci://quay.io/rhdh/backstage-plugin-events-backend-module-github@sha256:abc123", "must use metadata dynamicArtifact exactly"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("skips injection when RHDH_SKIP_PLUGIN_METADATA_INJECTION is 'true'", async () => { - process.env.RHDH_SKIP_PLUGIN_METADATA_INJECTION = "true"; - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-tech-radar", - packageName: "@backstage-community/plugin-tech-radar", - dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - appConfigExamples: { - techRadar: { url: "http://default.example.com" }, - }, - }, - ]); - try { - const config = { - plugins: [ - { - package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.strictEqual(result.plugins[0].pluginConfig, undefined, "pluginConfig must not be injected when RHDH_SKIP_PLUGIN_METADATA_INJECTION=true"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("does not skip injection when RHDH_SKIP_PLUGIN_METADATA_INJECTION is 'false'", async () => { - process.env.RHDH_SKIP_PLUGIN_METADATA_INJECTION = "false"; - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-tech-radar", - packageName: "@backstage-community/plugin-tech-radar", - dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - appConfigExamples: { techRadar: { url: "http://example.com" } }, - }, - ]); - try { - const config = { - plugins: [ - { - package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.ok(result.plugins[0].pluginConfig, "pluginConfig must be injected when RHDH_SKIP_PLUGIN_METADATA_INJECTION='false' (strict check)"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("resolves mixed plugin types correctly in a single config", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-tekton", - packageName: "@backstage-community/plugin-tekton", - dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:bs_1.45.3__3.33.3!backstage-community-plugin-tekton", - }, - { - name: "backstage-community-plugin-tech-radar", - packageName: "@backstage-community/plugin-tech-radar", - dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - }, - ]); - try { - const config = { - plugins: [ - { - package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:old_tag!backstage-community-plugin-tekton", - disabled: false, - }, - { - package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - disabled: false, - }, - { - package: "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - const plugins = result.plugins; - assert.ok(plugins[0].package.includes("bs_1.45.3__3.33.3"), "OCI plugin with metadata must resolve to metadata dynamicArtifact"); - assert.strictEqual(plugins[1].package, "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", "local path plugin with metadata must stay unchanged"); - assert.strictEqual(plugins[2].package, "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic", "plugin without metadata must stay unchanged"); - } - finally { - await fs.remove(metadataDir); - } - }); - // ── -dynamic suffix normalization ──────────────────────────────────────── - describe("-dynamic suffix normalization", () => { - it("resolves OCI plugin to metadata when dynamicArtifact has -dynamic suffix", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "backstage-plugin-catalog-backend-module-github", - packageName: "@backstage/plugin-catalog-backend-module-github", - dynamicArtifact: "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-dynamic", - appConfigExamples: { - catalog: { providers: { github: { org: "test" } } }, - }, - }, - ]); - try { - const config = { - plugins: [ - { - package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-catalog-backend-module-github:bs_1.45.3__0.11.2", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.ok(result.plugins[0].pluginConfig, "metadata config must be injected even when dynamicArtifact has -dynamic suffix but OCI URL does not"); - assert.deepStrictEqual(result.plugins[0].pluginConfig, { catalog: { providers: { github: { org: "test" } } } }, "injected config must match metadata appConfigExamples"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("keeps local -dynamic path unchanged when metadata also has -dynamic", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "backstage-plugin-catalog-backend-module-github", - packageName: "@backstage/plugin-catalog-backend-module-github", - dynamicArtifact: "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-dynamic", - }, - ]); - try { - const config = { - plugins: [ - { - package: "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-dynamic", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.strictEqual(result.plugins[0].package, "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-dynamic", "local path with -dynamic must stay unchanged"); - } - finally { - await fs.remove(metadataDir); - } - }); - }); - // ── PR vs nightly precedence ──────────────────────────────────────────── - describe("PR vs nightly precedence", () => { - it("isNightlyJob returns false when both GIT_PR_NUMBER and E2E_NIGHTLY_MODE are set", () => { - process.env.GIT_PR_NUMBER = "42"; - process.env.E2E_NIGHTLY_MODE = "true"; - assert.strictEqual(isNightlyJob(), false, "GIT_PR_NUMBER must make isNightlyJob return false"); - }); - it("injects metadata config when GIT_PR_NUMBER is set (PR mode despite nightly env)", async () => { - process.env.GIT_PR_NUMBER = "42"; - process.env.E2E_NIGHTLY_MODE = "true"; - const { wsDir, metadataDir } = await createWorkspaceFixture([ - { - name: "backstage-community-plugin-tech-radar", - packageName: "@backstage-community/plugin-tech-radar", - dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - appConfigExamples: { - techRadar: { url: "http://default.example.com" }, - }, - }, - ]); - try { - const config = { - plugins: [ - { - package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - disabled: false, - }, - ], - }; - const result = await processPluginsForDeployment(config, metadataDir); - assert.ok(result.plugins[0].pluginConfig, "metadata injection must happen when GIT_PR_NUMBER is set (PR takes precedence over nightly)"); - } - finally { - await fs.remove(wsDir); - } - }); - }); -}); diff --git a/dist/utils/tests/plugin-metadata.test.d.ts b/dist/utils/tests/plugin-metadata.test.d.ts deleted file mode 100644 index b92c62b..0000000 --- a/dist/utils/tests/plugin-metadata.test.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {}; -//# sourceMappingURL=plugin-metadata.test.d.ts.map \ No newline at end of file diff --git a/dist/utils/tests/plugin-metadata.test.d.ts.map b/dist/utils/tests/plugin-metadata.test.d.ts.map deleted file mode 100644 index 237aa0f..0000000 --- a/dist/utils/tests/plugin-metadata.test.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"plugin-metadata.test.d.ts","sourceRoot":"","sources":["../../../src/utils/tests/plugin-metadata.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/utils/tests/plugin-metadata.test.js b/dist/utils/tests/plugin-metadata.test.js deleted file mode 100644 index c5a9ade..0000000 --- a/dist/utils/tests/plugin-metadata.test.js +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Pure utility function tests β€” no env vars, no file system fixtures. - * Tests: extractPluginName, getNormalizedPluginMergeKey, disablePluginWrappers, generatePluginsFromMetadata - */ -import { describe, it } from "node:test"; -import assert from "node:assert"; -import fs from "fs-extra"; -import { extractPluginName, getNormalizedPluginMergeKey, disablePluginWrappers, generatePluginsFromMetadata, } from "../plugin-metadata.js"; -import { createMetadataFixture } from "./helpers.js"; -// ── extractPluginName ──────────────────────────────────────────────────────── -describe("extractPluginName", () => { - it("extracts name from OCI URL with tag and alias", () => { - assert.strictEqual(extractPluginName("oci://ghcr.io/org/repo/backstage-community-plugin-keycloak:pr_1__1.0!alias"), "backstage-community-plugin-keycloak"); - }); - it("extracts name from OCI URL with digest and alias", () => { - assert.strictEqual(extractPluginName("oci://quay.io/rhdh/backstage-plugin-events-backend-module-github@sha256:abc123!backstage-plugin-events-backend-module-github"), "backstage-plugin-events-backend-module-github"); - }); - it("extracts name from OCI URL with tag only (no alias)", () => { - assert.strictEqual(extractPluginName("oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:bs_1.45.3__3.33.3"), "backstage-community-plugin-tekton"); - }); - it("extracts name from local path and strips -dynamic suffix", () => { - assert.strictEqual(extractPluginName("./dynamic-plugins/dist/backstage-community-plugin-keycloak-dynamic"), "backstage-community-plugin-keycloak"); - }); - it("extracts name from OCI URL with digest but no alias", () => { - assert.strictEqual(extractPluginName("oci://quay.io/rhdh/backstage-plugin-events-backend-module-github@sha256:c1d17d47aaa"), "backstage-plugin-events-backend-module-github"); - }); - it("uses alias when alias differs from image name (redhat-resource-optimization pattern)", () => { - assert.strictEqual(extractPluginName("oci://quay.io/redhat-resource-optimization/dynamic-plugins:1.3.2!red-hat-developer-hub-plugin-redhat-resource-optimization"), "dynamic-plugins", "when alias is present, extractPluginName strips alias first and extracts from OCI path"); - }); - it("extracts name from npm package reference", () => { - assert.strictEqual(extractPluginName("@red-hat-developer-hub/backstage-plugin-global-header-test@0.0.2"), "backstage-plugin-global-header-test", "must extract plugin name from npm @scope/name@version format"); - }); -}); -// ── getNormalizedPluginMergeKey ─────────────────────────────────────────────── -describe("getNormalizedPluginMergeKey", () => { - it("returns same key for OCI and local -dynamic variant of the same plugin", () => { - const oci = getNormalizedPluginMergeKey({ - package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-catalog-backend-module-keycloak:pr_1980__3.16.0!backstage-community-plugin-catalog-backend-module-keycloak", - }); - const local = getNormalizedPluginMergeKey({ - package: "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic", - }); - assert.strictEqual(oci, local, "same logical plugin has same merge key"); - assert.strictEqual(oci, "backstage-community-plugin-catalog-backend-module-keycloak"); - }); - it("returns different keys for different plugins", () => { - const keycloak = getNormalizedPluginMergeKey({ - package: "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic", - }); - const techRadar = getNormalizedPluginMergeKey({ - package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar-dynamic", - }); - assert.notStrictEqual(keycloak, techRadar); - }); - it("returns empty string for missing or empty package", () => { - assert.strictEqual(getNormalizedPluginMergeKey({}), ""); - assert.strictEqual(getNormalizedPluginMergeKey({ package: "" }), ""); - assert.strictEqual(getNormalizedPluginMergeKey({ package: undefined }), ""); - }); -}); -// ── disablePluginWrappers ──────────────────────────────────────────────────── -describe("disablePluginWrappers", () => { - it("returns empty plugins array for empty input", () => { - const result = disablePluginWrappers([]); - assert.deepStrictEqual(result, { plugins: [] }); - }); - it("creates disabled entries with correct local path format", () => { - const result = disablePluginWrappers([ - "backstage-community-plugin-tech-radar", - "backstage-plugin-kubernetes", - ]); - assert.strictEqual(result.plugins.length, 2); - assert.deepStrictEqual(result.plugins[0], { - package: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - disabled: true, - }); - assert.deepStrictEqual(result.plugins[1], { - package: "./dynamic-plugins/dist/backstage-plugin-kubernetes", - disabled: true, - }); - }); -}); -// ── generatePluginsFromMetadata ────────────────────────────────────────────── -describe("generatePluginsFromMetadata", () => { - it("generates entries from metadata with package set to dynamicArtifact", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-tech-radar", - packageName: "@backstage-community/plugin-tech-radar", - dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - appConfigExamples: { techRadar: { url: "http://example.com" } }, - }, - ]); - try { - const result = await generatePluginsFromMetadata(metadataDir); - assert.strictEqual(result.plugins.length, 1); - assert.strictEqual(result.plugins[0].package, "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", "package must be the dynamicArtifact from metadata"); - assert.strictEqual(result.plugins[0].disabled, false); - assert.strictEqual(result.plugins[0].pluginConfig, undefined, "generatePluginsFromMetadata must NOT include pluginConfig"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("generates entries for OCI-referenced plugins", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-tekton", - packageName: "@backstage-community/plugin-tekton", - dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:bs_1.45.3__3.33.3!backstage-community-plugin-tekton", - }, - ]); - try { - const result = await generatePluginsFromMetadata(metadataDir); - assert.strictEqual(result.plugins.length, 1); - assert.ok(result.plugins[0].package.startsWith("oci://"), "OCI artifact must be preserved as package"); - } - finally { - await fs.remove(metadataDir); - } - }); - it("generates entries for mixed local and OCI artifacts", async () => { - const metadataDir = await createMetadataFixture([ - { - name: "backstage-community-plugin-tech-radar", - packageName: "@backstage-community/plugin-tech-radar", - dynamicArtifact: "./dynamic-plugins/dist/backstage-community-plugin-tech-radar", - }, - { - name: "backstage-community-plugin-tekton", - packageName: "@backstage-community/plugin-tekton", - dynamicArtifact: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-tekton:bs_1.45.3__3.33.3!backstage-community-plugin-tekton", - }, - { - name: "backstage-plugin-events-backend-module-github", - packageName: "@backstage/plugin-events-backend-module-github", - dynamicArtifact: "oci://quay.io/rhdh/backstage-plugin-events-backend-module-github@sha256:abc123", - }, - ]); - try { - const result = await generatePluginsFromMetadata(metadataDir); - assert.strictEqual(result.plugins.length, 3, "must generate 3 entries"); - const packages = result.plugins.map((p) => p.package).sort(); - assert.ok(packages.some((p) => p.startsWith("./")), "must include local path artifact"); - assert.ok(packages.some((p) => p.startsWith("oci://ghcr.io/")), "must include ghcr.io OCI"); - assert.ok(packages.some((p) => p.startsWith("oci://quay.io/")), "must include quay.io OCI"); - for (const plugin of result.plugins) { - assert.strictEqual(plugin.disabled, false); - assert.strictEqual(plugin.pluginConfig, undefined); - } - } - finally { - await fs.remove(metadataDir); - } - }); -}); diff --git a/dist/utils/vault.d.ts b/dist/utils/vault.d.ts deleted file mode 100644 index 69a5f52..0000000 --- a/dist/utils/vault.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Loads secrets from HashiCorp Vault into process.env. - * Only runs when `VAULT=1` or `VAULT=true` is set. Handles OIDC login automatically. - * - * Fetches secrets from: - * - Global path: `/global` - * - Per-workspace paths: `/workspaces/` - * - * Configure via env vars: - * - `VAULT_ADDR` β€” Vault server URL (default: https://vault.ci.openshift.org) - * - `VAULT_BASE_PATH` β€” Base path in Vault (default: selfservice/rhdh-plugin-export-overlays) - * - * Security: Only key names are logged, never secret values. - */ -export declare function loadLocalVaultSecrets(): Promise; -//# sourceMappingURL=vault.d.ts.map \ No newline at end of file diff --git a/dist/utils/vault.d.ts.map b/dist/utils/vault.d.ts.map deleted file mode 100644 index e884a72..0000000 --- a/dist/utils/vault.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"vault.d.ts","sourceRoot":"","sources":["../../src/utils/vault.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;GAaG;AACH,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC,CAiE3D"} \ No newline at end of file diff --git a/dist/utils/vault.js b/dist/utils/vault.js deleted file mode 100644 index 8ba32dd..0000000 --- a/dist/utils/vault.js +++ /dev/null @@ -1,94 +0,0 @@ -import { $ } from "./bash.js"; -const VAULT_ADDR_DEFAULT = "https://vault.ci.openshift.org"; -const VAULT_BASE_PATH_DEFAULT = "selfservice/rhdh-plugin-export-overlays"; -/** - * Loads secrets from HashiCorp Vault into process.env. - * Only runs when `VAULT=1` or `VAULT=true` is set. Handles OIDC login automatically. - * - * Fetches secrets from: - * - Global path: `/global` - * - Per-workspace paths: `/workspaces/` - * - * Configure via env vars: - * - `VAULT_ADDR` β€” Vault server URL (default: https://vault.ci.openshift.org) - * - `VAULT_BASE_PATH` β€” Base path in Vault (default: selfservice/rhdh-plugin-export-overlays) - * - * Security: Only key names are logged, never secret values. - */ -export async function loadLocalVaultSecrets() { - if (process.env.VAULT !== "1" && process.env.VAULT !== "true") - return; - const vaultAddr = process.env.VAULT_ADDR || VAULT_ADDR_DEFAULT; - const basePath = process.env.VAULT_BASE_PATH || VAULT_BASE_PATH_DEFAULT; - process.env.VAULT_ADDR = vaultAddr; - // Check vault CLI is installed - const whichResult = await vaultCmd `command -v vault`; - if (whichResult.exitCode !== 0) { - throw new Error("vault CLI not found. Install from https://developer.hashicorp.com/vault/downloads"); - } - // Check if already logged in - const tokenCheck = await vaultCmd `vault token lookup`; - if (tokenCheck.exitCode !== 0) { - console.log("Vault: not logged in, starting OIDC login..."); - // vault login needs inherited stdio for browser-based OIDC flow - await $ `vault login -no-print -method=oidc`; - const retryCheck = await vaultCmd `vault token lookup`; - if (retryCheck.exitCode !== 0) { - throw new Error("Vault login failed. Run manually:\n export VAULT_ADDR='" + - vaultAddr + - "'\n vault login -method=oidc"); - } - } - // Check access by fetching global secrets first - const globalResult = await vaultCmd `vault kv get -format=json -mount=kv ${basePath}/global`; - if (globalResult.stderr.includes("permission denied")) { - console.log("Vault: permission denied. Request access in Slack: #rhdh-e2e-tests"); - return; - } - console.log("Loading secrets from vault..."); - // Load global secrets - loadSecretsFromResult(globalResult, "global"); - // List and fetch per-workspace secrets - const listResult = await vaultCmd `vault kv list -format=json -mount=kv ${basePath}/workspaces`; - if (listResult.exitCode === 0) { - const workspaces = JSON.parse(listResult.stdout); - await Promise.all(workspaces.map((ws) => { - const name = ws.replace(/\/$/, ""); - return exportSecretsFromPath(`${basePath}/workspaces/${name}`, name); - })); - } - else { - console.log(" No workspace-specific secrets found"); - } - console.log("Vault secrets loaded successfully."); -} -/** Runs a shell command with piped stdio and nothrow, for capturing vault CLI output. */ -const vaultCmd = $({ - stdio: ["pipe", "pipe", "pipe"], - nothrow: true, -}); -async function exportSecretsFromPath(vaultPath, label) { - const result = await vaultCmd `vault kv get -format=json -mount=kv ${vaultPath}`; - loadSecretsFromResult(result, label); -} -function loadSecretsFromResult(result, label) { - if (result.exitCode !== 0) { - console.log(` No secrets at: ${label}`); - return; - } - const json = JSON.parse(result.stdout); - const secrets = json?.data?.data; - if (!secrets) { - console.log(` No secrets at: ${label}`); - return; - } - console.log(` From: ${label}`); - for (const [key, value] of Object.entries(secrets)) { - if (key.startsWith("secretsync/")) - continue; - if (!key.startsWith("VAULT_")) - continue; - const safeKey = key.replace(/[.\-/]/g, "_"); - process.env[safeKey] = value; - } -} diff --git a/dist/utils/workspace-paths.d.ts b/dist/utils/workspace-paths.d.ts deleted file mode 100644 index 2725477..0000000 --- a/dist/utils/workspace-paths.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Static utility for resolving paths relative to a workspace's e2e-tests directory. - * Uses `test.info().project.testDir` to determine the workspace location β€” - * works correctly whether Playwright runs from the workspace or from the repo root. - * - * @example - * ```typescript - * import { WorkspacePaths } from '@red-hat-developer-hub/e2e-test-utils/utils'; - * - * // One-liner to resolve a config file path - * const configPath = WorkspacePaths.resolve("tests/config/rbac-configmap.yaml"); - * - * // Access well-known directories - * WorkspacePaths.e2eRoot; // /abs/path/workspaces/acr/e2e-tests - * WorkspacePaths.workspaceRoot; // /abs/path/workspaces/acr - * WorkspacePaths.metadataDir; // /abs/path/workspaces/acr/metadata - * WorkspacePaths.configDir; // /abs/path/workspaces/acr/e2e-tests/tests/config - * ``` - */ -export declare class WorkspacePaths { - private constructor(); - /** The workspace's e2e-tests directory, derived from the current test's project testDir. */ - static get e2eRoot(): string; - /** Resolve a relative path from the e2e-tests directory. */ - static resolve(relativePath: string): string; - /** The workspace root directory (parent of e2e-tests). */ - static get workspaceRoot(): string; - /** The metadata directory. e.g., `workspaces/acr/metadata` */ - static get metadataDir(): string; - /** The tests/config directory. e.g., `workspaces/acr/e2e-tests/tests/config` */ - static get configDir(): string; - /** Default app-config path: `tests/config/app-config-rhdh.yaml` */ - static get appConfig(): string; - /** Default secrets path: `tests/config/rhdh-secrets.yaml` */ - static get secrets(): string; - /** Default dynamic plugins path: `tests/config/dynamic-plugins.yaml` */ - static get dynamicPlugins(): string; - /** Default Helm value file path: `tests/config/value_file.yaml` */ - static get valueFile(): string; - /** Default operator subscription path: `tests/config/subscription.yaml` */ - static get subscription(): string; -} -//# sourceMappingURL=workspace-paths.d.ts.map \ No newline at end of file diff --git a/dist/utils/workspace-paths.d.ts.map b/dist/utils/workspace-paths.d.ts.map deleted file mode 100644 index 93c1d67..0000000 --- a/dist/utils/workspace-paths.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"workspace-paths.d.ts","sourceRoot":"","sources":["../../src/utils/workspace-paths.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,cAAc;IACzB,OAAO;IAEP,4FAA4F;IAC5F,MAAM,KAAK,OAAO,IAAI,MAAM,CAE3B;IAED,4DAA4D;IAC5D,MAAM,CAAC,OAAO,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM;IAI5C,0DAA0D;IAC1D,MAAM,KAAK,aAAa,IAAI,MAAM,CAEjC;IAED,8DAA8D;IAC9D,MAAM,KAAK,WAAW,IAAI,MAAM,CAE/B;IAED,gFAAgF;IAChF,MAAM,KAAK,SAAS,IAAI,MAAM,CAE7B;IAID,mEAAmE;IACnE,MAAM,KAAK,SAAS,IAAI,MAAM,CAE7B;IAED,6DAA6D;IAC7D,MAAM,KAAK,OAAO,IAAI,MAAM,CAE3B;IAED,wEAAwE;IACxE,MAAM,KAAK,cAAc,IAAI,MAAM,CAElC;IAED,mEAAmE;IACnE,MAAM,KAAK,SAAS,IAAI,MAAM,CAE7B;IAED,2EAA2E;IAC3E,MAAM,KAAK,YAAY,IAAI,MAAM,CAEhC;CACF"} \ No newline at end of file diff --git a/dist/utils/workspace-paths.js b/dist/utils/workspace-paths.js deleted file mode 100644 index d966c18..0000000 --- a/dist/utils/workspace-paths.js +++ /dev/null @@ -1,65 +0,0 @@ -import path from "path"; -import { test } from "@playwright/test"; -/** - * Static utility for resolving paths relative to a workspace's e2e-tests directory. - * Uses `test.info().project.testDir` to determine the workspace location β€” - * works correctly whether Playwright runs from the workspace or from the repo root. - * - * @example - * ```typescript - * import { WorkspacePaths } from '@red-hat-developer-hub/e2e-test-utils/utils'; - * - * // One-liner to resolve a config file path - * const configPath = WorkspacePaths.resolve("tests/config/rbac-configmap.yaml"); - * - * // Access well-known directories - * WorkspacePaths.e2eRoot; // /abs/path/workspaces/acr/e2e-tests - * WorkspacePaths.workspaceRoot; // /abs/path/workspaces/acr - * WorkspacePaths.metadataDir; // /abs/path/workspaces/acr/metadata - * WorkspacePaths.configDir; // /abs/path/workspaces/acr/e2e-tests/tests/config - * ``` - */ -export class WorkspacePaths { - constructor() { } // Static-only class - /** The workspace's e2e-tests directory, derived from the current test's project testDir. */ - static get e2eRoot() { - return path.resolve(test.info().project.testDir, ".."); - } - /** Resolve a relative path from the e2e-tests directory. */ - static resolve(relativePath) { - return path.resolve(this.e2eRoot, relativePath); - } - /** The workspace root directory (parent of e2e-tests). */ - static get workspaceRoot() { - return path.resolve(this.e2eRoot, ".."); - } - /** The metadata directory. e.g., `workspaces/acr/metadata` */ - static get metadataDir() { - return path.resolve(this.e2eRoot, "../metadata"); - } - /** The tests/config directory. e.g., `workspaces/acr/e2e-tests/tests/config` */ - static get configDir() { - return path.resolve(this.e2eRoot, "tests/config"); - } - // ── Default config file paths ──────────────────────────────────────────── - /** Default app-config path: `tests/config/app-config-rhdh.yaml` */ - static get appConfig() { - return path.resolve(this.e2eRoot, "tests/config/app-config-rhdh.yaml"); - } - /** Default secrets path: `tests/config/rhdh-secrets.yaml` */ - static get secrets() { - return path.resolve(this.e2eRoot, "tests/config/rhdh-secrets.yaml"); - } - /** Default dynamic plugins path: `tests/config/dynamic-plugins.yaml` */ - static get dynamicPlugins() { - return path.resolve(this.e2eRoot, "tests/config/dynamic-plugins.yaml"); - } - /** Default Helm value file path: `tests/config/value_file.yaml` */ - static get valueFile() { - return path.resolve(this.e2eRoot, "tests/config/value_file.yaml"); - } - /** Default operator subscription path: `tests/config/subscription.yaml` */ - static get subscription() { - return path.resolve(this.e2eRoot, "tests/config/subscription.yaml"); - } -} From 1205d22f8d9d086a49f1b835a2be2e3843e10518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20August=C3=ADn?= Date: Thu, 14 May 2026 10:40:47 +0200 Subject: [PATCH 6/8] refactor: extract GitHub popup reauthorization logic into a separate method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dominik AugustΓ­n --- src/playwright/helpers/common.ts | 43 +++++++++++++------------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/src/playwright/helpers/common.ts b/src/playwright/helpers/common.ts index ae30cd1..531d565 100644 --- a/src/playwright/helpers/common.ts +++ b/src/playwright/helpers/common.ts @@ -136,18 +136,7 @@ export class LoginHelper { if (typeof result === "object" && "popup" in result) { // Popup opened β€” handle reauthorization - // TODO this is the same code as checkAndReauthorizeGithubApp's promise body - const popup = result.popup; - await popup.waitForLoadState(); - for (let attempts = 0; attempts < 10 && !popup.isClosed(); attempts++) { - await this.page.waitForTimeout(1000); - } - const locator = popup.locator("button.js-oauth-authorize-btn"); - if (!popup.isClosed() && (await locator.isVisible())) { - await popup.locator("body").click(); - await locator.waitFor(); - await locator.click(); - } + await this.handleGithubPopupReauth(result.popup); } } else { // Perform login if no session file exists, then save the state @@ -165,24 +154,28 @@ export class LoginHelper { async checkAndReauthorizeGithubApp() { await new Promise((resolve) => { this.page.once("popup", async (popup) => { - await popup.waitForLoadState(); - - // Check for popup closure for up to 10 seconds before proceeding - for (let attempts = 0; attempts < 10 && !popup.isClosed(); attempts++) { - await this.page.waitForTimeout(1000); // Using page here because if the popup closes automatically, it throws an error during the wait - } - - const locator = popup.locator("button.js-oauth-authorize-btn"); - if (!popup.isClosed() && (await locator.isVisible())) { - await popup.locator("body").click(); - await locator.waitFor(); - await locator.click(); - } + await this.handleGithubPopupReauth(popup); resolve(); }); }); } + private async handleGithubPopupReauth(popup: Page) { + await popup.waitForLoadState(); + + // Check for popup closure for up to 10 seconds before proceeding + for (let attempts = 0; attempts < 10 && !popup.isClosed(); attempts++) { + await this.page.waitForTimeout(1000); // Using page here because if the popup closes automatically, it throws an error during the wait + } + + const locator = popup.locator("button.js-oauth-authorize-btn"); + if (!popup.isClosed() && (await locator.isVisible())) { + await popup.locator("body").click(); + await locator.waitFor(); + await locator.click(); + } + } + async googleSignIn(email: string) { await new Promise((resolve) => { this.page.once("popup", async (popup) => { From 1873464525af6762141b7d002599ce814fddf52d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20August=C3=ADn?= Date: Thu, 14 May 2026 11:22:14 +0200 Subject: [PATCH 7/8] chore: bump version to 1.1.41 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dominik AugustΓ­n --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d692723..90ebb4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@red-hat-developer-hub/e2e-test-utils", - "version": "1.1.40", + "version": "1.1.41", "description": "Test utilities for RHDH E2E tests", "license": "Apache-2.0", "repository": { From 1f091d98a3f9adbcb4c3cb1d147e559a840dc88e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20August=C3=ADn?= Date: Thu, 14 May 2026 11:22:44 +0200 Subject: [PATCH 8/8] chore: update changelog for version 1.1.41 with fixes, additions, and changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dominik AugustΓ­n --- docs/changelog.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 8f60725..b5179b8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,7 +2,22 @@ All notable changes to this project will be documented in this file. -## [1.1.40] - Current +## [1.1.41] - Current + +### Fixed + +- **GitHub login null-race guard**: `loginAsGithubUser` now throws an explicit error when neither the sidebar nav nor the GitHub popup appears after clicking Sign In, instead of silently continuing with a stale session file. +- Increased GitHub popup wait timeouts. + +### Added + +- **GitHub session reuse auto-login/reauth race**: In case of existing session, handle both auto-login and reauthorization flows by racing a `nav` selector against the `popup` event. + +### Changed + +- **Deduplicate GitHub popup reauth logic**: Extracted shared popup reauthorization code from `loginAsGithubUser` and `checkAndReauthorizeGithubApp` into a private `handleGithubPopupReauth` method to eliminate duplication. + +## [1.1.40] ### Changed