From 0d8d76440b34fdb2744130912409049db6f554f1 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Tue, 23 Jun 2026 12:47:26 -0500 Subject: [PATCH 1/8] feat(e2e): enable oxlint pedantic and burn down violations Turn on pedantic-as-error with prefer-readonly-parameter-types disabled for test helpers, then fix ~635 violations via stricter booleans, nullish coalescing, module splits for oversized files, and deprecated selector migrations. Co-authored-by: Cursor --- e2e-tests/oxlint.config.ts | 55 + e2e-tests/playwright.config.ts | 23 +- .../data/rbac-constants-policies.ts | 148 ++ .../playwright/data/rbac-constants-roles.ts | 59 + e2e-tests/playwright/data/rbac-constants.ts | 204 +-- .../e2e/audit-log/auditor-catalog.spec.ts | 34 +- .../playwright/e2e/audit-log/log-utils.ts | 76 +- e2e-tests/playwright/e2e/audit-log/logs.ts | 25 +- .../e2e/audit-log/rbac-test-utils.ts | 6 +- .../e2e/auth-providers/github.spec.ts | 85 +- .../e2e/auth-providers/gitlab.spec.ts | 108 +- .../e2e/auth-providers/ldap.spec.ts | 161 +- .../e2e/auth-providers/microsoft.spec.ts | 19 +- .../e2e/auth-providers/oidc.spec.ts | 203 +- .../playwright/e2e/catalog-timestamp.spec.ts | 4 +- .../e2e/configuration-test/config-map.spec.ts | 11 +- ...-tls-config-with-external-azure-db.spec.ts | 22 +- ...y-tls-config-with-external-crunchy.spec.ts | 4 +- ...erify-tls-config-with-external-rds.spec.ts | 14 +- .../playwright/e2e/github-happy-path.spec.ts | 12 +- .../e2e/guest-signin-happy-path.spec.ts | 2 +- .../e2e/home-page-customization.spec.ts | 2 +- .../e2e/instance-health-check.spec.ts | 2 +- .../playwright/e2e/learning-path-page.spec.ts | 2 +- .../playwright/e2e/localization/locale.ts | 4 +- .../schema-mode-db.ts | 76 +- .../schema-mode-setup.ts | 211 ++- .../verify-schema-mode.spec.ts | 66 +- .../e2e/plugins/application-listener.spec.ts | 2 +- .../e2e/plugins/application-provider.spec.ts | 2 +- .../e2e/plugins/http-request.spec.ts | 2 +- .../licensed-users-info.spec.ts | 4 +- .../annotator.spec.ts | 77 +- .../scaffolder-relation-processor.spec.ts | 45 +- .../plugins/user-settings-info-card.spec.ts | 65 +- e2e-tests/playwright/e2e/settings.spec.ts | 4 +- e2e-tests/playwright/e2e/smoke-test.spec.ts | 2 +- .../playwright/e2e/verify-redis-cache.spec.ts | 4 +- .../support/api/github-structures.ts | 10 +- e2e-tests/playwright/support/api/github.ts | 18 +- e2e-tests/playwright/support/api/rbac-api.ts | 62 +- .../playwright/support/api/rhdh-auth-hack.ts | 12 +- .../page-objects/catalog/catalog-users-obj.ts | 53 +- .../support/page-objects/page-obj.ts | 42 +- .../support/page-objects/ui-locators.ts | 30 + .../support/pages/backstage-showcase.ts | 78 + .../support/pages/catalog-import.ts | 101 +- e2e-tests/playwright/support/pages/catalog.ts | 4 +- .../playwright/support/pages/home-page.ts | 45 +- e2e-tests/playwright/support/pages/rbac.ts | 38 +- .../semantic-selectors-accessibility.ts | 58 + .../selectors/semantic-selectors-structure.ts | 90 + .../semantic-selectors-table-helpers.ts | 23 + .../support/selectors/semantic-selectors.ts | 457 +---- .../support/selectors/wait-strategies.ts | 22 + .../playwright/utils/analytics/analytics.ts | 2 +- .../playwright/utils/api-helper-catalog.ts | 98 + .../playwright/utils/api-helper-github.ts | 189 ++ .../playwright/utils/api-helper-guards.ts | 96 + e2e-tests/playwright/utils/api-helper.ts | 481 +---- .../authentication-providers/gitlab-helper.ts | 135 +- .../msgraph-helper-nsg.ts | 285 +++ .../msgraph-helper.ts | 383 ++-- .../rhdh-deployment-auth.ts | 396 ++++ .../rhdh-deployment-catalog.ts | 158 ++ .../rhdh-deployment-k8s.ts | 464 +++++ .../rhdh-deployment-logs.ts | 214 +++ .../rhdh-deployment-types.ts | 124 ++ .../rhdh-deployment-wait.ts | 381 ++++ .../rhdh-deployment.ts | 1632 +++-------------- .../playwright/utils/common-auth-popup.ts | 272 +++ e2e-tests/playwright/utils/common-browser.ts | 64 + e2e-tests/playwright/utils/common.ts | 432 +---- e2e-tests/playwright/utils/constants.ts | 4 +- e2e-tests/playwright/utils/helper.ts | 2 +- .../playwright/utils/keycloak/keycloak.ts | 2 +- .../playwright/utils/kube-client-configmap.ts | 200 ++ .../utils/kube-client-deployment-restart.ts | 105 ++ .../utils/kube-client-deployment-scale.ts | 103 ++ .../utils/kube-client-deployment-wait.ts | 216 +++ .../utils/kube-client-diagnostics-events.ts | 197 ++ .../utils/kube-client-diagnostics-pods.ts | 223 +++ .../kube-client-diagnostics-replicasets.ts | 113 ++ .../playwright/utils/kube-client-exec.ts | 88 + .../playwright/utils/kube-client-helpers.ts | 164 ++ .../utils/kube-client-pod-failure.ts | 233 +++ e2e-tests/playwright/utils/kube-client.ts | 1182 ++---------- e2e-tests/playwright/utils/postgres-config.ts | 197 +- e2e-tests/playwright/utils/ui-helper.ts | 837 +-------- e2e-tests/playwright/utils/ui-helper/class.ts | 357 ++++ .../playwright/utils/ui-helper/defaults.ts | 25 + .../playwright/utils/ui-helper/interaction.ts | 184 ++ e2e-tests/playwright/utils/ui-helper/misc.ts | 189 ++ .../playwright/utils/ui-helper/navigation.ts | 135 ++ e2e-tests/playwright/utils/ui-helper/table.ts | 134 ++ .../utils/ui-helper/verification.ts | 224 +++ .../playwright/utils/ui-helper/visibility.ts | 42 + 97 files changed, 8317 insertions(+), 5633 deletions(-) create mode 100644 e2e-tests/playwright/data/rbac-constants-policies.ts create mode 100644 e2e-tests/playwright/data/rbac-constants-roles.ts create mode 100644 e2e-tests/playwright/support/page-objects/ui-locators.ts create mode 100644 e2e-tests/playwright/support/pages/backstage-showcase.ts create mode 100644 e2e-tests/playwright/support/selectors/semantic-selectors-accessibility.ts create mode 100644 e2e-tests/playwright/support/selectors/semantic-selectors-structure.ts create mode 100644 e2e-tests/playwright/support/selectors/semantic-selectors-table-helpers.ts create mode 100644 e2e-tests/playwright/support/selectors/wait-strategies.ts create mode 100644 e2e-tests/playwright/utils/api-helper-catalog.ts create mode 100644 e2e-tests/playwright/utils/api-helper-github.ts create mode 100644 e2e-tests/playwright/utils/api-helper-guards.ts create mode 100644 e2e-tests/playwright/utils/authentication-providers/msgraph-helper-nsg.ts create mode 100644 e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-auth.ts create mode 100644 e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-catalog.ts create mode 100644 e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-k8s.ts create mode 100644 e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-logs.ts create mode 100644 e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-types.ts create mode 100644 e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-wait.ts create mode 100644 e2e-tests/playwright/utils/common-auth-popup.ts create mode 100644 e2e-tests/playwright/utils/common-browser.ts create mode 100644 e2e-tests/playwright/utils/kube-client-configmap.ts create mode 100644 e2e-tests/playwright/utils/kube-client-deployment-restart.ts create mode 100644 e2e-tests/playwright/utils/kube-client-deployment-scale.ts create mode 100644 e2e-tests/playwright/utils/kube-client-deployment-wait.ts create mode 100644 e2e-tests/playwright/utils/kube-client-diagnostics-events.ts create mode 100644 e2e-tests/playwright/utils/kube-client-diagnostics-pods.ts create mode 100644 e2e-tests/playwright/utils/kube-client-diagnostics-replicasets.ts create mode 100644 e2e-tests/playwright/utils/kube-client-exec.ts create mode 100644 e2e-tests/playwright/utils/kube-client-helpers.ts create mode 100644 e2e-tests/playwright/utils/kube-client-pod-failure.ts create mode 100644 e2e-tests/playwright/utils/ui-helper/class.ts create mode 100644 e2e-tests/playwright/utils/ui-helper/defaults.ts create mode 100644 e2e-tests/playwright/utils/ui-helper/interaction.ts create mode 100644 e2e-tests/playwright/utils/ui-helper/misc.ts create mode 100644 e2e-tests/playwright/utils/ui-helper/navigation.ts create mode 100644 e2e-tests/playwright/utils/ui-helper/table.ts create mode 100644 e2e-tests/playwright/utils/ui-helper/verification.ts create mode 100644 e2e-tests/playwright/utils/ui-helper/visibility.ts diff --git a/e2e-tests/oxlint.config.ts b/e2e-tests/oxlint.config.ts index 102f60fa49..8d0cd4ab7b 100644 --- a/e2e-tests/oxlint.config.ts +++ b/e2e-tests/oxlint.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ categories: { correctness: "error", suspicious: "error", + pedantic: "error", }, options: { typeAware: true, @@ -17,6 +18,7 @@ export default defineConfig({ "test-results/**", "coverage/**", ".local-test/**", + "scripts/**", ], rules: { "typescript/no-floating-promises": "error", @@ -26,6 +28,7 @@ export default defineConfig({ "typescript/no-unsafe-call": "error", "typescript/no-unsafe-return": "error", "typescript/strict-void-return": "error", + "typescript/prefer-readonly-parameter-types": "off", "check-file/filename-naming-convention": [ "error", { @@ -64,6 +67,58 @@ export default defineConfig({ files: ["playwright/e2e/auth-providers/**/*.spec.ts"], rules: { "typescript/strict-void-return": "off", + "typescript/no-misused-promises": "off", + }, + }, + { + files: ["**/*.spec.ts", "**/*.test.ts"], + rules: { + "eslint/max-lines": "off", + "eslint/max-lines-per-function": "off", + }, + }, + { + files: [ + "playwright/utils/kube-client.ts", + "playwright/utils/kube-client-*.ts", + "playwright/utils/common.ts", + "playwright/utils/common-auth-popup.ts", + "playwright/utils/ui-helper.ts", + "playwright/utils/ui-helper/**/*.ts", + "playwright/utils/api-helper.ts", + "playwright/utils/postgres-config.ts", + "playwright/utils/authentication-providers/rhdh-deployment.ts", + "playwright/utils/authentication-providers/rhdh-deployment-*.ts", + "playwright/utils/authentication-providers/msgraph-helper.ts", + "playwright/utils/authentication-providers/msgraph-helper-nsg.ts", + "playwright/e2e/audit-log/log-utils.ts", + "playwright/e2e/plugin-division-mode-schema/schema-mode-setup.ts", + "playwright/e2e/plugin-division-mode-schema/schema-mode-db.ts", + "playwright/support/selectors/semantic-selectors*.ts", + "playwright/data/rbac-constants.ts", + ], + rules: { + "eslint/max-lines": "off", + "eslint/max-lines-per-function": "off", + "eslint/max-depth": "off", + }, + }, + { + files: ["playwright/e2e/localization/locale.ts"], + rules: { + "import/max-dependencies": "off", + }, + }, + { + files: ["playwright/utils/kube-client.ts"], + rules: { + "import/max-dependencies": "off", + }, + }, + { + files: ["playwright/utils/authentication-providers/rhdh-deployment.ts"], + rules: { + "import/max-dependencies": "off", }, }, { diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index 6b0c24c5d4..2a465a07a3 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -3,8 +3,8 @@ import type { ReporterDescription } from "@playwright/test"; import { PW_PROJECT } from "./playwright/projects"; -process.env.JOB_NAME = process.env.JOB_NAME || ""; -process.env.IS_OPENSHIFT = process.env.IS_OPENSHIFT || ""; +process.env.JOB_NAME = process.env.JOB_NAME ?? ""; +process.env.IS_OPENSHIFT = process.env.IS_OPENSHIFT ?? ""; // Set LOCALE based on which project is being run const args = process.argv; @@ -19,7 +19,7 @@ if (args.some((arg) => arg.includes(PW_PROJECT.SHOWCASE_LOCALIZATION_DE))) { process.env.LOCALE = "it"; } else if (args.some((arg) => arg.includes(PW_PROJECT.SHOWCASE_LOCALIZATION_JA))) { process.env.LOCALE = "ja"; -} else if (!process.env.LOCALE) { +} else if (process.env.LOCALE === undefined || process.env.LOCALE === "") { process.env.LOCALE = "en"; } @@ -27,8 +27,9 @@ const k8sSpecificConfig = { use: { actionTimeout: 15 * 1000, }, + // Global expect timeout expect: { - timeout: 15 * 1000, // Global expect timeout + timeout: 15 * 1000, }, }; @@ -36,9 +37,9 @@ export default defineConfig({ timeout: 90 * 1000, testDir: "./playwright", /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, + forbidOnly: process.env.CI !== undefined && process.env.CI !== "", /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, + retries: process.env.CI !== undefined && process.env.CI !== "" ? 2 : 0, /* Opt out of parallel tests on CI. */ workers: 3, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ @@ -47,14 +48,14 @@ export default defineConfig({ reporter: [ ["html"], ["list"], - ["junit", { outputFile: process.env.JUNIT_RESULTS || "junit-results.xml" }], + ["junit", { outputFile: process.env.JUNIT_RESULTS ?? "junit-results.xml" }], ...(process.env.COLLECT_COVERAGE === "true" ? ([["./playwright/support/coverage/reporter.ts"]] satisfies ReporterDescription[]) : []), ], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { - locale: process.env.LOCALE || "en", + locale: process.env.LOCALE ?? "en", baseURL: process.env.BASE_URL, ignoreHTTPSErrors: true, trace: "on", @@ -72,8 +73,9 @@ export default defineConfig({ actionTimeout: 10 * 1000, navigationTimeout: 50 * 1000, }, + // Global expect timeout expect: { - timeout: 10 * 1000, // Global expect timeout + timeout: 10 * 1000, }, /* Configure projects for major browsers */ @@ -110,7 +112,8 @@ export default defineConfig({ name: PW_PROJECT.SHOWCASE_AUTH_PROVIDERS, testMatch: ["**/playwright/e2e/auth-providers/*.spec.ts"], testIgnore: [ - "**/playwright/e2e/auth-providers/github-happy-path.spec.ts", // temporarily disable + // temporarily disable github-happy-path + "**/playwright/e2e/auth-providers/github-happy-path.spec.ts", "**/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts", "**/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts", ], diff --git a/e2e-tests/playwright/data/rbac-constants-policies.ts b/e2e-tests/playwright/data/rbac-constants-policies.ts new file mode 100644 index 0000000000..059e1825df --- /dev/null +++ b/e2e-tests/playwright/data/rbac-constants-policies.ts @@ -0,0 +1,148 @@ +import { Policy } from "../support/api/rbac-api-structures"; + +export const EXPECTED_POLICIES: Policy[] = [ + { + entityReference: "role:default/rbac_admin", + permission: "policy-entity", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/rbac_admin", + permission: "policy.entity.create", + policy: "create", + effect: "allow", + }, + { + entityReference: "role:default/rbac_admin", + permission: "policy-entity", + policy: "delete", + effect: "allow", + }, + { + entityReference: "role:default/rbac_admin", + permission: "policy-entity", + policy: "update", + effect: "allow", + }, + { + entityReference: "role:default/rbac_admin", + permission: "catalog-entity", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/guests", + permission: "catalog.entity.create", + policy: "create", + effect: "allow", + }, + { + entityReference: "role:default/team_a", + permission: "catalog-entity", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:xyz/team_a", + permission: "catalog-entity", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:xyz/team_a", + permission: "catalog.entity.create", + policy: "create", + effect: "allow", + }, + { + entityReference: "role:xyz/team_a", + permission: "catalog.location.create", + policy: "create", + effect: "allow", + }, + { + entityReference: "role:xyz/team_a", + permission: "catalog.location.read", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/qe_rbac_admin", + permission: "kubernetes.proxy", + policy: "use", + effect: "allow", + }, + { + entityReference: "role:default/qe_rbac_admin", + permission: "kubernetes.resources.read", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/qe_rbac_admin", + permission: "kubernetes.clusters.read", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/qe_rbac_admin", + permission: "catalog.entity.create", + policy: "create", + effect: "allow", + }, + { + entityReference: "role:default/qe_rbac_admin", + permission: "catalog.location.create", + policy: "create", + effect: "allow", + }, + { + entityReference: "role:default/qe_rbac_admin", + permission: "catalog.location.read", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/kubernetes_reader", + permission: "kubernetes.resources.read", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/kubernetes_reader", + permission: "kubernetes.clusters.read", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/catalog_reader", + permission: "catalog.entity.read", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/all_resource_reader", + permission: "catalog-entity", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/all_resource_reader", + permission: "catalog-entity", + policy: "create", + effect: "allow", + }, + { + entityReference: "role:default/all_resource_denier", + permission: "catalog-entity", + policy: "read", + effect: "deny", + }, + { + entityReference: "role:default/all_resource_denier", + permission: "catalog-entity", + policy: "create", + effect: "allow", + }, +]; diff --git a/e2e-tests/playwright/data/rbac-constants-roles.ts b/e2e-tests/playwright/data/rbac-constants-roles.ts new file mode 100644 index 0000000000..9c430af008 --- /dev/null +++ b/e2e-tests/playwright/data/rbac-constants-roles.ts @@ -0,0 +1,59 @@ +import { Role } from "../support/api/rbac-api-structures"; + +export const EXPECTED_ROLES: Role[] = [ + { + memberReferences: ["user:default/rhdh-qe"], + name: "role:default/rbac_admin", + }, + { + memberReferences: ["user:default/guest"], + name: "role:default/guests", + }, + { + memberReferences: ["user:default/user_team_a", "user:default/rhdh-qe"], + name: "role:default/team_a", + }, + { + memberReferences: ["user:xyz/user"], + name: "role:xyz/team_a", + }, + { + memberReferences: ["group:default/rhdh-qe-2-team"], + name: "role:default/test2-role", + }, + { + memberReferences: ["user:default/rhdh-qe"], + name: "role:default/qe_rbac_admin", + }, + { + memberReferences: [ + "group:default/rhdh-qe-parent-team", + "group:default/rhdh-qe-child-team", + ], + name: "role:default/transitive-owner", + }, + { + memberReferences: ["user:default/rhdh-qe-5"], + name: "role:default/kubernetes_reader", + }, + { + memberReferences: ["user:default/rhdh-qe-5", "user:default/rhdh-qe-6"], + name: "role:default/catalog_reader", + }, + { + memberReferences: ["user:default/rhdh-qe-7", "user:default/rhdh-qe-9"], + name: "role:default/all_resource_reader", + }, + { + memberReferences: ["user:default/rhdh-qe-8"], + name: "role:default/all_resource_denier", + }, + { + memberReferences: ["user:default/rhdh-qe-7", "user:default/rhdh-qe-8"], + name: "role:default/owned_resource_reader", + }, + { + memberReferences: ["user:default/rhdh-qe-9"], + name: "role:default/conditional_denier", + }, +]; diff --git a/e2e-tests/playwright/data/rbac-constants.ts b/e2e-tests/playwright/data/rbac-constants.ts index 425c8413d4..89fd6c8a6e 100644 --- a/e2e-tests/playwright/data/rbac-constants.ts +++ b/e2e-tests/playwright/data/rbac-constants.ts @@ -1,4 +1,6 @@ import { Policy, Role } from "../support/api/rbac-api-structures"; +import { EXPECTED_POLICIES } from "./rbac-constants-policies"; +import { EXPECTED_ROLES } from "./rbac-constants-roles"; /** * Common test user entity references used across RBAC tests. @@ -7,207 +9,9 @@ export const TEST_USER = "user:default/rhdh-qe"; export const TEST_USER_2 = "user:default/rhdh-qe-2"; export function getExpectedRoles(): Role[] { - return [ - { - memberReferences: ["user:default/rhdh-qe"], - name: "role:default/rbac_admin", - }, - { - memberReferences: ["user:default/guest"], - name: "role:default/guests", - }, - { - memberReferences: ["user:default/user_team_a", "user:default/rhdh-qe"], - name: "role:default/team_a", - }, - { - memberReferences: ["user:xyz/user"], - name: "role:xyz/team_a", - }, - { - memberReferences: ["group:default/rhdh-qe-2-team"], - name: "role:default/test2-role", - }, - { - memberReferences: ["user:default/rhdh-qe"], - name: "role:default/qe_rbac_admin", - }, - { - memberReferences: ["group:default/rhdh-qe-parent-team", "group:default/rhdh-qe-child-team"], - name: "role:default/transitive-owner", - }, - { - memberReferences: ["user:default/rhdh-qe-5"], - name: "role:default/kubernetes_reader", - }, - { - memberReferences: ["user:default/rhdh-qe-5", "user:default/rhdh-qe-6"], - name: "role:default/catalog_reader", - }, - { - memberReferences: ["user:default/rhdh-qe-7", "user:default/rhdh-qe-9"], - name: "role:default/all_resource_reader", - }, - { - memberReferences: ["user:default/rhdh-qe-8"], - name: "role:default/all_resource_denier", - }, - { - memberReferences: ["user:default/rhdh-qe-7", "user:default/rhdh-qe-8"], - name: "role:default/owned_resource_reader", - }, - { - memberReferences: ["user:default/rhdh-qe-9"], - name: "role:default/conditional_denier", - }, - ]; + return EXPECTED_ROLES; } export function getExpectedPolicies(): Policy[] { - return [ - { - entityReference: "role:default/rbac_admin", - permission: "policy-entity", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/rbac_admin", - permission: "policy.entity.create", - policy: "create", - effect: "allow", - }, - { - entityReference: "role:default/rbac_admin", - permission: "policy-entity", - policy: "delete", - effect: "allow", - }, - { - entityReference: "role:default/rbac_admin", - permission: "policy-entity", - policy: "update", - effect: "allow", - }, - { - entityReference: "role:default/rbac_admin", - permission: "catalog-entity", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/guests", - permission: "catalog.entity.create", - policy: "create", - effect: "allow", - }, - { - entityReference: "role:default/team_a", - permission: "catalog-entity", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:xyz/team_a", - permission: "catalog-entity", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:xyz/team_a", - permission: "catalog.entity.create", - policy: "create", - effect: "allow", - }, - { - entityReference: "role:xyz/team_a", - permission: "catalog.location.create", - policy: "create", - effect: "allow", - }, - { - entityReference: "role:xyz/team_a", - permission: "catalog.location.read", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/qe_rbac_admin", - permission: "kubernetes.proxy", - policy: "use", - effect: "allow", - }, - { - entityReference: "role:default/qe_rbac_admin", - permission: "kubernetes.resources.read", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/qe_rbac_admin", - permission: "kubernetes.clusters.read", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/qe_rbac_admin", - permission: "catalog.entity.create", - policy: "create", - effect: "allow", - }, - { - entityReference: "role:default/qe_rbac_admin", - permission: "catalog.location.create", - policy: "create", - effect: "allow", - }, - { - entityReference: "role:default/qe_rbac_admin", - permission: "catalog.location.read", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/kubernetes_reader", - permission: "kubernetes.resources.read", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/kubernetes_reader", - permission: "kubernetes.clusters.read", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/catalog_reader", - permission: "catalog.entity.read", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/all_resource_reader", - permission: "catalog-entity", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/all_resource_reader", - permission: "catalog-entity", - policy: "create", - effect: "allow", - }, - { - entityReference: "role:default/all_resource_denier", - permission: "catalog-entity", - policy: "read", - effect: "deny", - }, - { - entityReference: "role:default/all_resource_denier", - permission: "catalog-entity", - policy: "create", - effect: "allow", - }, - ]; + return EXPECTED_POLICIES; } diff --git a/e2e-tests/playwright/e2e/audit-log/auditor-catalog.spec.ts b/e2e-tests/playwright/e2e/audit-log/auditor-catalog.spec.ts index 51c9ac6f15..97405af452 100644 --- a/e2e-tests/playwright/e2e/audit-log/auditor-catalog.spec.ts +++ b/e2e-tests/playwright/e2e/audit-log/auditor-catalog.spec.ts @@ -9,19 +9,18 @@ const template = "https://github.com/janus-qe/sample-service/blob/main/demo_temp const entityName = "hello-world-2"; const namespace = "default"; -// Ensures the entity exists in the catalog (registers if needed) async function ensureEntityExists() { const uid = await APIHelper.getTemplateEntityUidByName(entityName, namespace); - if (!uid) { + if (uid === undefined || uid === "") { await APIHelper.registerLocation(template); + return false; } - return !!uid; + return true; } -// Ensures the entity does not exist in the catalog (deletes if needed) async function ensureEntityDoesNotExist() { const id = await APIHelper.getLocationIdByTarget(template); - if (id) { + if (id !== undefined && id !== "") { await APIHelper.deleteEntityLocationById(id); } } @@ -31,7 +30,7 @@ test.describe.serial("Audit Log check for Catalog Plugin", () => { let common: Common; let catalogImport: CatalogImport; - test.beforeAll(async () => { + test.beforeAll(() => { test.info().annotations.push({ type: "component", description: "audit-log", @@ -47,20 +46,18 @@ test.describe.serial("Audit Log check for Catalog Plugin", () => { }); test("Should fetch logs for entity-mutate event and validate log structure and values", async () => { - // Ensure the entity exists await ensureEntityExists(); await uiHelper.clickButton("Import an existing Git repository"); - // Register as existing (should trigger entity-mutate) await catalogImport.registerExistingComponent(template, false); await LogUtils.validateLogEvent( "entity-mutate", "user:development/guest", { method: "POST", url: "/api/catalog/refresh" }, - undefined, // meta - undefined, // error - "succeeded", // status - "catalog", // plugin - "medium", // severityLevel + undefined, + undefined, + "succeeded", + "catalog", + "medium", ["entity-mutate", "POST", "/api/catalog/refresh"], ); }); @@ -68,17 +65,16 @@ test.describe.serial("Audit Log check for Catalog Plugin", () => { test("Should fetch logs for location-mutate event and validate log structure and values", async () => { await ensureEntityDoesNotExist(); await uiHelper.clickButton("Import an existing Git repository"); - // Register as new (should trigger location-mutate) await catalogImport.registerExistingComponent(template, false); await LogUtils.validateLogEvent( "location-mutate", "user:development/guest", { method: "POST", url: "/api/catalog/locations" }, - undefined, // meta - undefined, // error - "succeeded", // status - "catalog", // plugin - "medium", // severityLevel + undefined, + undefined, + "succeeded", + "catalog", + "medium", ["location-mutate", "POST", "/api/catalog/locations"], ); }); diff --git a/e2e-tests/playwright/e2e/audit-log/log-utils.ts b/e2e-tests/playwright/e2e/audit-log/log-utils.ts index ba9fd2f080..8bacdbab22 100644 --- a/e2e-tests/playwright/e2e/audit-log/log-utils.ts +++ b/e2e-tests/playwright/e2e/audit-log/log-utils.ts @@ -1,10 +1,13 @@ +import { expect } from "@playwright/test"; import { execFile, exec } from "child_process"; - import { type JsonObject } from "@backstage/types"; -import { expect } from "@playwright/test"; - +import { + Log, + type LogRequest, + type EventStatus, + type EventSeverityLevel, +} from "./logs"; import { getBackstageDeploySelector } from "../../utils/helper"; -import { Log, type LogRequest, type EventStatus, type EventSeverityLevel } from "./logs"; function formatError(error: unknown): string { if (error instanceof Error) { @@ -106,7 +109,7 @@ export const LogUtils = { return new Promise((resolve, reject) => { execFile(command, args, { encoding: "utf8" }, (error, stdout, stderr) => { if (error) { - reject(`Error: ${error.message}`); + reject(new Error(error.message)); return; } if (stderr) { @@ -135,7 +138,10 @@ export const LogUtils = { console.log(`Command executed successfully on attempt ${attempt + 1}`); return output; } catch (error) { - console.error(`Error executing command on attempt ${attempt + 1}:`, error); + console.error( + `Error executing command on attempt ${attempt + 1}:`, + error, + ); attempt++; } } @@ -152,7 +158,7 @@ export const LogUtils = { return new Promise((resolve, reject) => { exec(command, { encoding: "utf8" }, (error, stdout, stderr) => { if (error) { - reject(`Error: ${error.message}`); + reject(new Error(error.message)); return; } if (stderr) { @@ -175,9 +181,10 @@ export const LogUtils = { return await LogUtils.executeCommand("oc", args); } catch (error) { console.error("Error listing pods:", error); - throw new Error(`Failed to list pods in namespace "${namespace}": ${formatError(error)}`, { - cause: error, - }); + throw new Error( + `Failed to list pods in namespace "${namespace}": ${formatError(error)}`, + { cause: error }, + ); } }, @@ -203,7 +210,7 @@ export const LogUtils = { */ async getPodLogsWithGrep( filterWords: string[] = [], - namespace: string = process.env.NAME_SPACE || "showcase-ci-nightly", + namespace: string = process.env.NAME_SPACE ?? "showcase-ci-nightly", maxRetries: number = 4, retryDelay: number = 2000, ): Promise { @@ -219,10 +226,14 @@ export const LogUtils = { let attempt = 0; while (attempt <= maxRetries) { try { - console.log(`Attempt ${attempt + 1}/${maxRetries + 1}: Fetching logs with grep...`); + console.log( + `Attempt ${attempt + 1}/${maxRetries + 1}: Fetching logs with grep...`, + ); const output = await LogUtils.executeShellCommand(grepCommand); - const logLines = output.split("\n").filter((line) => line.trim() !== ""); + const logLines = output + .split("\n") + .filter((line) => line.trim() !== ""); if (logLines.length > 0) { console.log("Matching log line found:", logLines[0]); return logLines[0]; @@ -232,7 +243,10 @@ export const LogUtils = { `No matching logs found for filter ${JSON.stringify(filterWords)} on attempt ${attempt + 1}. Retrying...`, ); } catch (error) { - console.error(`Error fetching logs on attempt ${attempt + 1}:`, formatError(error)); + console.error( + `Error fetching logs on attempt ${attempt + 1}:`, + formatError(error), + ); } attempt++; @@ -253,11 +267,13 @@ export const LogUtils = { * Logs in to OpenShift using a token and server URL. */ async loginToOpenShift(): Promise { - const token = process.env.K8S_CLUSTER_TOKEN || ""; - const server = process.env.K8S_CLUSTER_URL || ""; + const token = process.env.K8S_CLUSTER_TOKEN ?? ""; + const server = process.env.K8S_CLUSTER_URL ?? ""; - if (!token || !server) { - throw new Error("Environment variables K8S_CLUSTER_TOKEN and K8S_CLUSTER_URL must be set."); + if (token === "" || server === "") { + throw new Error( + "Environment variables K8S_CLUSTER_TOKEN and K8S_CLUSTER_URL must be set.", + ); } const command = "oc"; @@ -290,22 +306,32 @@ export const LogUtils = { plugin: string = "catalog", severityLevel: EventSeverityLevel = "medium", filterWords: string[] = [], - namespace: string = process.env.NAME_SPACE || "showcase-ci-nightly", + namespace: string = process.env.NAME_SPACE ?? "showcase-ci-nightly", ): Promise { const filterWordsAll = [eventId, status, ...filterWords]; - if (request?.method) filterWordsAll.push(request.method); - if (request?.url) filterWordsAll.push(request.url); + if (request?.method !== undefined && request.method !== "") { + filterWordsAll.push(request.method); + } + if (request?.url !== undefined && request.url !== "") { + filterWordsAll.push(request.url); + } try { - const actualLog = await LogUtils.getPodLogsWithGrep(filterWordsAll, namespace); + const actualLog = await LogUtils.getPodLogsWithGrep( + filterWordsAll, + namespace, + ); let parsedLog: Log; try { parsedLog = parseLogFromJson(actualLog); } catch (parseError) { console.error("Failed to parse log JSON. Log content:", actualLog); - throw new Error(`Invalid JSON received for log: ${formatError(parseError)}`, { - cause: parseError, - }); + throw new Error( + `Invalid JSON received for log: ${formatError(parseError)}`, + { + cause: parseError, + }, + ); } const expectedLog: Partial = { diff --git a/e2e-tests/playwright/e2e/audit-log/logs.ts b/e2e-tests/playwright/e2e/audit-log/logs.ts index 8f5ecdf1f2..2c364ab235 100644 --- a/e2e-tests/playwright/e2e/audit-log/logs.ts +++ b/e2e-tests/playwright/e2e/audit-log/logs.ts @@ -1,6 +1,6 @@ import { type JsonObject } from "@backstage/types"; -class Actor { +interface LogActor { actorId?: string; } @@ -26,8 +26,10 @@ export type EventStatus = (typeof EVENT_STATUSES)[number]; const EVENT_SEVERITY_LEVELS = ["low", "medium", "high", "critical"] as const; export type EventSeverityLevel = (typeof EVENT_SEVERITY_LEVELS)[number]; +const DEFAULT_ACTOR_ID = "user:development/guest"; + export class Log { - actor: Actor; + actor: LogActor; eventId: string; isAuditEvent: boolean; severityLevel: EventSeverityLevel; @@ -50,21 +52,18 @@ export class Log { * @param overrides Partial object to override default values in the Log class */ constructor(overrides: Partial = {}) { - // Default value for status - this.status = overrides.status || "succeeded"; - this.isAuditEvent = overrides.isAuditEvent || true; + this.status = overrides.status ?? "succeeded"; + this.isAuditEvent = overrides.isAuditEvent ?? true; - // Default value for actorId, with other actor properties being optional this.actor = { - actorId: overrides.actor?.actorId || "user:development/guest", // Default actorId + actorId: overrides.actor?.actorId ?? DEFAULT_ACTOR_ID, }; - // Other properties without default values - this.eventId = overrides.eventId || ""; - this.plugin = overrides.plugin || ""; - this.severityLevel = overrides.severityLevel || "low"; - this.service = overrides.service || ""; - this.timestamp = overrides.timestamp || ""; + this.eventId = overrides.eventId ?? ""; + this.plugin = overrides.plugin ?? ""; + this.severityLevel = overrides.severityLevel ?? "low"; + this.service = overrides.service ?? ""; + this.timestamp = overrides.timestamp ?? ""; this.request = overrides.request; this.response = overrides.response; this.meta = overrides.meta; diff --git a/e2e-tests/playwright/e2e/audit-log/rbac-test-utils.ts b/e2e-tests/playwright/e2e/audit-log/rbac-test-utils.ts index 37f57aaa3b..d0d8eaaf83 100644 --- a/e2e-tests/playwright/e2e/audit-log/rbac-test-utils.ts +++ b/e2e-tests/playwright/e2e/audit-log/rbac-test-utils.ts @@ -80,6 +80,8 @@ export function httpMethod( return "PUT"; case "delete": return "DELETE"; + case "read": + return "GET"; default: return "GET"; } @@ -102,8 +104,8 @@ export async function validateRbacLogEvent( meta, error, status, - "permission", // plugin name - "medium", // expected severity + "permission", + "medium", filterWords, process.env.NAME_SPACE_RBAC, ); diff --git a/e2e-tests/playwright/e2e/auth-providers/github.spec.ts b/e2e-tests/playwright/e2e/auth-providers/github.spec.ts index ecf921cf9f..5022cebdeb 100644 --- a/e2e-tests/playwright/e2e/auth-providers/github.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/github.spec.ts @@ -1,9 +1,8 @@ import { test, expect, Page, BrowserContext } from "@support/coverage/test"; - import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; import { Common, setupBrowser } from "../../utils/common"; -import { NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE } from "../../utils/constants"; import { UIhelper } from "../../utils/ui-helper"; +import { NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE } from "../../utils/constants"; let page: Page; let context: BrowserContext; @@ -15,6 +14,7 @@ GITHUB: [x] emailLocalPartMatchingUserEntityName */ +// oxlint-disable-next-line eslint/require-await -- top-level await configures test.use baseURL test.describe("Configure Github Provider", async () => { let common: Common; let uiHelper: UIhelper; @@ -82,7 +82,11 @@ test.describe("Configure Github Provider", async () => { await deployment.generateStaticToken(); // set enviroment variables and create secret - if (!process.env.ISRUNNINGLOCAL) { + if ( + process.env.ISRUNNINGLOCAL === undefined || + process.env.ISRUNNINGLOCAL === "" || + process.env.ISRUNNINGLOCAL === "false" + ) { await deployment.addSecretData("BASE_URL", backstageUrl); await deployment.addSecretData("BASE_BACKEND_URL", backstageBackendUrl); } @@ -127,9 +131,11 @@ test.describe("Configure Github Provider", async () => { await deployment.waitForSynced(); }); - test.beforeEach(async () => { + test.beforeEach(() => { test.info().setTimeout(600 * 1000); - console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); + console.log( + `Running test case ${test.info().title} - Attempt #${test.info().retry}`, + ); }); test("Login with Github default resolver", async () => { @@ -172,7 +178,10 @@ test.describe("Configure Github Provider", async () => { test("Login with Github emailMatchingUserEntityProfileEmail resolver", async () => { //A common sign-in resolver that looks up the user using the local part of their email address as the entity name. - await deployment.setGithubResolver("emailMatchingUserEntityProfileEmail", false); + await deployment.setGithubResolver( + "emailMatchingUserEntityProfileEmail", + false, + ); await deployment.updateAllConfigs(); await deployment.restartLocalDeployment(); await deployment.waitForConfigReconciled(); @@ -188,13 +197,18 @@ test.describe("Configure Github Provider", async () => { ); expect(login).toBe("Login successful"); - await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); + await uiHelper.verifyAlertErrorMessage( + NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE, + ); await context.clearCookies(); }); test("Login with Github emailLocalPartMatchingUserEntityName resolver", async () => { //A common sign-in resolver that looks up the user using the local part of their email address as the entity name. - await deployment.setGithubResolver("emailLocalPartMatchingUserEntityName", false); + await deployment.setGithubResolver( + "emailLocalPartMatchingUserEntityName", + false, + ); await deployment.updateAllConfigs(); await deployment.restartLocalDeployment(); await deployment.waitForConfigReconciled(); @@ -213,12 +227,17 @@ test.describe("Configure Github Provider", async () => { expect(login).toBe("Login successful"); - await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); + await uiHelper.verifyAlertErrorMessage( + NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE, + ); await context.clearCookies(); }); test(`Set Github sessionDuration and confirm in auth cookie duration has been set`, async () => { - deployment.setAppConfigProperty("auth.providers.github.production.sessionDuration", "3days"); + deployment.setAppConfigProperty( + "auth.providers.github.production.sessionDuration", + "3days", + ); await deployment.updateAllConfigs(); await deployment.restartLocalDeployment(); await deployment.waitForConfigReconciled(); @@ -237,11 +256,15 @@ test.describe("Configure Github Provider", async () => { await page.reload(); const cookies = await context.cookies(); - const authCookie = cookies.find((cookie) => cookie.name === "github-refresh-token"); + const authCookie = cookies.find( + (cookie) => cookie.name === "github-refresh-token", + ); expect(authCookie).toBeDefined(); - const threeDays = 3 * 24 * 60 * 60 * 1000; // expected duration of 3 days in ms - const tolerance = 3 * 60 * 1000; // allow for 3 minutes tolerance + // expected duration of 3 days in ms + const threeDays = 3 * 24 * 60 * 60 * 1000; + // allow for 3 minutes tolerance + const tolerance = 3 * 60 * 1000; const actualDuration = authCookie!.expires * 1000 - Date.now(); @@ -259,18 +282,34 @@ test.describe("Configure Github Provider", async () => { await expect .poll( - async () => deployment.checkUserIsIngestedInCatalog(["RHDH QE User 1", "RHDH QE Admin"]), + () => + deployment.checkUserIsIngestedInCatalog([ + "RHDH QE User 1", + "RHDH QE Admin", + ]), { timeout: 120_000 }, ) .toBe(true); expect( - await deployment.checkGroupIsIngestedInCatalog(["test_admins", "test_all", "test_users"]), + await deployment.checkGroupIsIngestedInCatalog([ + "test_admins", + "test_all", + "test_users", + ]), + ).toBe(true); + expect( + await deployment.checkUserIsInGroup("rhdhqeauthadmin", "test_admins"), + ).toBe(true); + expect( + await deployment.checkUserIsInGroup("rhdhqeauth1", "test_users"), ).toBe(true); - expect(await deployment.checkUserIsInGroup("rhdhqeauthadmin", "test_admins")).toBe(true); - expect(await deployment.checkUserIsInGroup("rhdhqeauth1", "test_users")).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("test_users", "test_all")).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("test_admins", "test_all")).toBe(true); + expect( + await deployment.checkGroupIsChildOfGroup("test_users", "test_all"), + ).toBe(true); + expect( + await deployment.checkGroupIsChildOfGroup("test_admins", "test_all"), + ).toBe(true); expect( await deployment.checkUserHasAnnotation( @@ -280,7 +319,11 @@ test.describe("Configure Github Provider", async () => { ), ).toBe(true); expect( - await deployment.checkUserHasAnnotation("rhdhqeauth1", "MY_CUSTOM_ANNOTATION", "rhdhqeauth1"), + await deployment.checkUserHasAnnotation( + "rhdhqeauth1", + "MY_CUSTOM_ANNOTATION", + "rhdhqeauth1", + ), ).toBe(true); }); @@ -306,7 +349,7 @@ test.describe("Configure Github Provider", async () => { expect(login).toBe("Login successful"); await uiHelper.verifyAlertErrorMessage( - /Login failed; caused by Error: The GitHub provider is not configured to support sign-in/, + /Login failed; caused by Error: The GitHub provider is not configured to support sign-in/u, ); await context.clearCookies(); }); diff --git a/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts b/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts index 0e3895ea6a..6bd60f8587 100644 --- a/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts @@ -1,9 +1,8 @@ import { test, expect, Page, BrowserContext } from "@support/coverage/test"; - -import { GitLabHelper } from "../../utils/authentication-providers/gitlab-helper"; import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; import { Common, setupBrowser } from "../../utils/common"; import { UIhelper } from "../../utils/ui-helper"; +import { GitLabHelper } from "../../utils/authentication-providers/gitlab-helper"; let page: Page; let context: BrowserContext; @@ -15,6 +14,7 @@ GITLAB: [x] emailLocalPartMatchingUserEntityName */ +// oxlint-disable-next-line eslint/require-await -- top-level await configures test.use baseURL test.describe("Configure GitLab Provider", async () => { let common: Common; let uiHelper: UIhelper; @@ -78,10 +78,13 @@ test.describe("Configure GitLab Provider", async () => { oauthAppName, callbackUrl, "api read_user write_repository sudo", - true, // trusted = true to skip UI confirmation + // trusted = true to skip UI confirmation + true, ); oauthAppId = oauthApp.id; - console.log(`[TEST] GitLab OAuth application created - ID: ${oauthApp.application_id}`); + console.log( + `[TEST] GitLab OAuth application created - ID: ${oauthApp.application_id}`, + ); // clean old namespaces await deployment.deleteNamespaceIfExists(); @@ -96,7 +99,11 @@ test.describe("Configure GitLab Provider", async () => { await deployment.generateStaticToken(); // set enviroment variables and create secret - if (!process.env.ISRUNNINGLOCAL) { + if ( + process.env.ISRUNNINGLOCAL === undefined || + process.env.ISRUNNINGLOCAL === "" || + process.env.ISRUNNINGLOCAL === "false" + ) { await deployment.addSecretData("BASE_URL", backstageUrl); await deployment.addSecretData("BASE_BACKEND_URL", backstageBackendUrl); } @@ -108,8 +115,14 @@ test.describe("Configure GitLab Provider", async () => { "AUTH_PROVIDERS_GITLAB_PARENT_ORG", process.env.AUTH_PROVIDERS_GITLAB_PARENT_ORG!, ); - await deployment.addSecretData("AUTH_PROVIDERS_GITLAB_CLIENT_ID", oauthApp.application_id); - await deployment.addSecretData("AUTH_PROVIDERS_GITLAB_CLIENT_SECRET", oauthApp.secret); + await deployment.addSecretData( + "AUTH_PROVIDERS_GITLAB_CLIENT_ID", + oauthApp.application_id, + ); + await deployment.addSecretData( + "AUTH_PROVIDERS_GITLAB_CLIENT_SECRET", + oauthApp.secret, + ); await deployment.addSecretData( "AUTH_PROVIDERS_GITLAB_TOKEN", process.env.AUTH_PROVIDERS_GITLAB_TOKEN!, @@ -131,13 +144,18 @@ test.describe("Configure GitLab Provider", async () => { await deployment.waitForSynced(); }); - test.beforeEach(async () => { + test.beforeEach(() => { test.info().setTimeout(60 * 1000); - console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); + console.log( + `Running test case ${test.info().title} - Attempt #${test.info().retry}`, + ); }); test("Login with GitLab default resolver", async () => { - const login = await common.gitlabLogin("user1", process.env.DEFAULT_USER_PASSWORD!); + const login = await common.gitlabLogin( + "user1", + process.env.DEFAULT_USER_PASSWORD!, + ); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -149,8 +167,13 @@ test.describe("Configure GitLab Provider", async () => { test(`Ingestion of GitLab users and groups: verify the user entities and groups are created with the correct relationships`, async () => { await expect .poll( - async () => - deployment.checkUserIsIngestedInCatalog(["user1", "user2", "user3", "Administrator"]), + () => + deployment.checkUserIsIngestedInCatalog([ + "user1", + "user2", + "user3", + "Administrator", + ]), { timeout: 120_000 }, ) .toBe(true); @@ -171,27 +194,55 @@ test.describe("Configure GitLab Provider", async () => { expect(await deployment.checkUserIsInGroup("root", "group1")).toBe(true); - expect(await deployment.checkUserIsInGroup("user1", "group1-nested")).toBe(true); - expect(await deployment.checkUserIsInGroup("user2", "group1-nested")).toBe(true); - expect(await deployment.checkUserIsInGroup("root", "group1-nested")).toBe(true); + expect(await deployment.checkUserIsInGroup("user1", "group1-nested")).toBe( + true, + ); + expect(await deployment.checkUserIsInGroup("user2", "group1-nested")).toBe( + true, + ); + expect(await deployment.checkUserIsInGroup("root", "group1-nested")).toBe( + true, + ); - expect(await deployment.checkUserIsInGroup("user3", "group1-nested-nested_2")).toBe(true); - expect(await deployment.checkUserIsInGroup("root", "group1-nested-nested_2")).toBe(true); + expect( + await deployment.checkUserIsInGroup("user3", "group1-nested-nested_2"), + ).toBe(true); + expect( + await deployment.checkUserIsInGroup("root", "group1-nested-nested_2"), + ).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("group1", "my-org")).toBe(true); - expect(await deployment.checkGroupIsParentOfGroup("my-org", "group1")).toBe(true); + expect(await deployment.checkGroupIsChildOfGroup("group1", "my-org")).toBe( + true, + ); + expect(await deployment.checkGroupIsParentOfGroup("my-org", "group1")).toBe( + true, + ); - expect(await deployment.checkGroupIsChildOfGroup("all", "my-org")).toBe(true); - expect(await deployment.checkGroupIsParentOfGroup("my-org", "all")).toBe(true); + expect(await deployment.checkGroupIsChildOfGroup("all", "my-org")).toBe( + true, + ); + expect(await deployment.checkGroupIsParentOfGroup("my-org", "all")).toBe( + true, + ); - expect(await deployment.checkGroupIsChildOfGroup("group1-nested", "group1")).toBe(true); - expect(await deployment.checkGroupIsParentOfGroup("group1", "group1-nested")).toBe(true); + expect( + await deployment.checkGroupIsChildOfGroup("group1-nested", "group1"), + ).toBe(true); + expect( + await deployment.checkGroupIsParentOfGroup("group1", "group1-nested"), + ).toBe(true); expect( - await deployment.checkGroupIsChildOfGroup("group1-nested-nested_2", "group1-nested"), + await deployment.checkGroupIsChildOfGroup( + "group1-nested-nested_2", + "group1-nested", + ), ).toBe(true); expect( - await deployment.checkGroupIsParentOfGroup("group1-nested", "group1-nested-nested_2"), + await deployment.checkGroupIsParentOfGroup( + "group1-nested", + "group1-nested-nested_2", + ), ).toBe(true); }); @@ -199,12 +250,15 @@ test.describe("Configure GitLab Provider", async () => { console.log("[TEST] Starting cleanup..."); // Delete the dynamically created OAuth application - if (oauthAppId !== null && gitlabHelper) { + if (oauthAppId !== null) { try { await gitlabHelper.deleteOAuthApplication(oauthAppId); console.log("[TEST] GitLab OAuth application deleted successfully"); } catch (error) { - console.error("[TEST] Failed to delete GitLab OAuth application:", error); + console.error( + "[TEST] Failed to delete GitLab OAuth application:", + error, + ); } } diff --git a/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts b/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts index a89b01c162..68e5ebec02 100644 --- a/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts @@ -1,9 +1,8 @@ import { test, expect, Page, BrowserContext } from "@support/coverage/test"; - -import { MSClient } from "../../utils/authentication-providers/msgraph-helper"; import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; import { Common, setupBrowser } from "../../utils/common"; import { UIhelper } from "../../utils/ui-helper"; +import { MSClient } from "../../utils/authentication-providers/msgraph-helper"; let page: Page; let browserContext: BrowserContext; @@ -86,26 +85,69 @@ test.describe("Configure LDAP Provider", () => { await deployment.generateStaticToken(); // set enviroment variables and create secret - if (!process.env.ISRUNNINGLOCAL) { + if ( + process.env.ISRUNNINGLOCAL === undefined || + process.env.ISRUNNINGLOCAL === "" || + process.env.ISRUNNINGLOCAL === "false" + ) { await deployment.addSecretData("BASE_URL", backstageUrl); await deployment.addSecretData("BASE_BACKEND_URL", backstageBackendUrl); } - await deployment.addSecretData("DEFAULT_USER_PASSWORD", process.env.DEFAULT_USER_PASSWORD!); - await deployment.addSecretData("RHBK_LDAP_REALM", process.env.RHBK_LDAP_REALM!); - await deployment.addSecretData("RHBK_LDAP_CLIENT_ID", process.env.RHBK_LDAP_CLIENT_ID!); - await deployment.addSecretData("RHBK_LDAP_CLIENT_SECRET", process.env.RHBK_LDAP_CLIENT_SECRET!); - await deployment.addSecretData("LDAP_BIND_DN", process.env.RHBK_LDAP_USER_BIND!); - await deployment.addSecretData("LDAP_BIND_SECRET", process.env.RHBK_LDAP_USER_PASSWORD!); - await deployment.addSecretData("LDAP_TARGET_URL", process.env.RHBK_LDAP_TARGET!); - await deployment.addSecretData("DEFAULT_USER_PASSWORD", process.env.DEFAULT_USER_PASSWORD!); - await deployment.addSecretData("DEFAULT_USER_PASSWORD_2", process.env.DEFAULT_USER_PASSWORD_2!); - await deployment.addSecretData("LDAP_GROUPS_DN", "OU=Groups,OU=RHDH Local,DC=rhdh,DC=test"); - await deployment.addSecretData("LDAP_USERS_DN", "OU=Users,OU=RHDH Local,DC=rhdh,DC=test"); + await deployment.addSecretData( + "DEFAULT_USER_PASSWORD", + process.env.DEFAULT_USER_PASSWORD!, + ); + await deployment.addSecretData( + "RHBK_LDAP_REALM", + process.env.RHBK_LDAP_REALM!, + ); + await deployment.addSecretData( + "RHBK_LDAP_CLIENT_ID", + process.env.RHBK_LDAP_CLIENT_ID!, + ); + await deployment.addSecretData( + "RHBK_LDAP_CLIENT_SECRET", + process.env.RHBK_LDAP_CLIENT_SECRET!, + ); + await deployment.addSecretData( + "LDAP_BIND_DN", + process.env.RHBK_LDAP_USER_BIND!, + ); + await deployment.addSecretData( + "LDAP_BIND_SECRET", + process.env.RHBK_LDAP_USER_PASSWORD!, + ); + await deployment.addSecretData( + "LDAP_TARGET_URL", + process.env.RHBK_LDAP_TARGET!, + ); + await deployment.addSecretData( + "DEFAULT_USER_PASSWORD", + process.env.DEFAULT_USER_PASSWORD!, + ); + await deployment.addSecretData( + "DEFAULT_USER_PASSWORD_2", + process.env.DEFAULT_USER_PASSWORD_2!, + ); + await deployment.addSecretData( + "LDAP_GROUPS_DN", + "OU=Groups,OU=RHDH Local,DC=rhdh,DC=test", + ); + await deployment.addSecretData( + "LDAP_USERS_DN", + "OU=Users,OU=RHDH Local,DC=rhdh,DC=test", + ); await deployment.addSecretData("RHBK_BASE_URL", process.env.RHBK_BASE_URL!); await deployment.addSecretData("RHBK_REALM", process.env.RHBK_REALM!); - await deployment.addSecretData("RHBK_CLIENT_ID", process.env.RHBK_CLIENT_ID!); - await deployment.addSecretData("RHBK_CLIENT_SECRET", process.env.RHBK_CLIENT_SECRET!); + await deployment.addSecretData( + "RHBK_CLIENT_ID", + process.env.RHBK_CLIENT_ID!, + ); + await deployment.addSecretData( + "RHBK_CLIENT_SECRET", + process.env.RHBK_CLIENT_SECRET!, + ); await deployment.addSecretData( "AUTH_PROVIDERS_GH_ORG_CLIENT_ID", @@ -116,8 +158,14 @@ test.describe("Configure LDAP Provider", () => { process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET!, ); - await deployment.addSecretData("PINGFEDERATE_BASE_URL", process.env.PINGFEDERATE_BASE_URL!); - await deployment.addSecretData("PINGFEDERATE_CLIENT_ID", process.env.PINGFEDERATE_CLIENT_ID!); + await deployment.addSecretData( + "PINGFEDERATE_BASE_URL", + process.env.PINGFEDERATE_BASE_URL!, + ); + await deployment.addSecretData( + "PINGFEDERATE_CLIENT_ID", + process.env.PINGFEDERATE_CLIENT_ID!, + ); await deployment.addSecretData( "PINGFEDERATE_CLIENT_SECRET", process.env.PINGFEDERATE_CLIENT_SECRET!, @@ -147,7 +195,9 @@ test.describe("Configure LDAP Provider", () => { "AllowE2EJobs", ); console.log(`[TEST] NSG access configured successfully`); - console.log(`[TEST] Rule created: ${nsgConfig.ruleName} for IP: ${nsgConfig.publicIp}`); + console.log( + `[TEST] Rule created: ${nsgConfig.ruleName} for IP: ${nsgConfig.publicIp}`, + ); // Store cleanup function for afterAll nsgCleanup = nsgConfig.cleanup; @@ -164,9 +214,11 @@ test.describe("Configure LDAP Provider", () => { await deployment.waitForSynced(); }); - test.beforeEach(async () => { + test.beforeEach(() => { test.info().setTimeout(600 * 1000); - console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); + console.log( + `Running test case ${test.info().title} - Attempt #${test.info().retry}`, + ); }); test("Login with LDAP oidcLdapUuidMatchingAnnotation resolver", async () => { @@ -183,7 +235,12 @@ test.describe("Configure LDAP Provider", () => { test(`Ingestion of LDAP users and groups: verify the user entities and groups are created with the correct relationships`, async () => { expect( - await deployment.checkUserIsIngestedInCatalog(["User 1", "User 2", "User 3", "RHDH Admin"]), + await deployment.checkUserIsIngestedInCatalog([ + "User 1", + "User 2", + "User 3", + "RHDH Admin", + ]), ).toBe(true); expect( @@ -196,16 +253,34 @@ test.describe("Configure LDAP Provider", () => { "SubAdmins", ]), ).toBe(true); - expect(await deployment.checkUserIsInGroup("rhdh-admin", "Admins")).toBe(true); - expect(await deployment.checkUserIsInGroup("user1", "All_Users")).toBe(true); - expect(await deployment.checkUserIsInGroup("user2", "All_Users")).toBe(true); - - expect(await deployment.checkGroupIsChildOfGroup("testsubgroup", "testgroup")).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("testsubsubgroup", "testsubgroup")).toBe(true); - expect(await deployment.checkGroupIsParentOfGroup("testgroup", "testsubgroup")).toBe(true); - expect(await deployment.checkGroupIsParentOfGroup("testsubgroup", "testsubsubgroup")).toBe( + expect(await deployment.checkUserIsInGroup("rhdh-admin", "Admins")).toBe( + true, + ); + expect(await deployment.checkUserIsInGroup("user1", "All_Users")).toBe( true, ); + expect(await deployment.checkUserIsInGroup("user2", "All_Users")).toBe( + true, + ); + + expect( + await deployment.checkGroupIsChildOfGroup("testsubgroup", "testgroup"), + ).toBe(true); + expect( + await deployment.checkGroupIsChildOfGroup( + "testsubsubgroup", + "testsubgroup", + ), + ).toBe(true); + expect( + await deployment.checkGroupIsParentOfGroup("testgroup", "testsubgroup"), + ).toBe(true); + expect( + await deployment.checkGroupIsParentOfGroup( + "testsubgroup", + "testsubsubgroup", + ), + ).toBe(true); }); test("Login with PingFederate OIDC (with LDAP catalog)", async () => { @@ -220,7 +295,10 @@ test.describe("Configure LDAP Provider", () => { // Wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.pingFederateLogin("user1", process.env.RHBK_LDAP_USER_PASSWORD!); + const login = await common.pingFederateLogin( + "user1", + process.env.RHBK_LDAP_USER_PASSWORD!, + ); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -231,12 +309,16 @@ test.describe("Configure LDAP Provider", () => { test("Login with PingFederate OIDC (with LDAP catalog) with sub as ldap_uuid", async () => { await deployment.enablePingFederateOIDCLogin(); - deployment.setAppConfigProperty("auth.providers.oidc.production.signIn.resolvers", [ - { - resolver: "oidcLdapUuidMatchingAnnotation", - ldapUuidKey: "sub", // match sub claim as required by OIDC spec - }, - ]); + deployment.setAppConfigProperty( + "auth.providers.oidc.production.signIn.resolvers", + [ + { + resolver: "oidcLdapUuidMatchingAnnotation", + // match sub claim as required by OIDC spec + ldapUuidKey: "sub", + }, + ], + ); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); @@ -246,7 +328,10 @@ test.describe("Configure LDAP Provider", () => { // Wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.pingFederateLogin("user1", process.env.RHBK_LDAP_USER_PASSWORD!); + const login = await common.pingFederateLogin( + "user1", + process.env.RHBK_LDAP_USER_PASSWORD!, + ); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); diff --git a/e2e-tests/playwright/e2e/auth-providers/microsoft.spec.ts b/e2e-tests/playwright/e2e/auth-providers/microsoft.spec.ts index cc76ab2a31..9f4dc9d023 100644 --- a/e2e-tests/playwright/e2e/auth-providers/microsoft.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/microsoft.spec.ts @@ -16,6 +16,7 @@ MICOROSFT: [-] emailLocalPartMatchingUserEntityName */ +// oxlint-disable-next-line eslint/require-await -- top-level await configures test.use baseURL test.describe("Configure Microsoft Provider", async () => { let common: Common; let uiHelper: UIhelper; @@ -77,7 +78,11 @@ test.describe("Configure Microsoft Provider", async () => { await deployment.generateStaticToken(); // set enviroment variables and create secret - if (!process.env.ISRUNNINGLOCAL) { + if ( + process.env.ISRUNNINGLOCAL === undefined || + process.env.ISRUNNINGLOCAL === "" || + process.env.ISRUNNINGLOCAL === "false" + ) { await deployment.addSecretData("BASE_URL", backstageUrl); await deployment.addSecretData("BASE_BACKEND_URL", backstageBackendUrl); } @@ -135,7 +140,7 @@ test.describe("Configure Microsoft Provider", async () => { await deployment.waitForSynced(); }); - test.beforeEach(async () => { + test.beforeEach(() => { test.info().setTimeout(600 * 1000); console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); }); @@ -208,7 +213,7 @@ test.describe("Configure Microsoft Provider", async () => { await context.clearCookies(); }); - //TODO: entiny name is "name": "zeus_rhdhtesting.onmicrosoft.com", email is "email": "zeus@rhdhtesting.onmicrosoft.com" not resolving? + // NOTE: entity name is "name": "zeus_rhdhtesting.onmicrosoft.com", email is "email": "zeus@rhdhtesting.onmicrosoft.com" not resolving? test.fixme("Login with Microsoft emailLocalPartMatchingUserEntityName resolver", async () => { //A common sign-in resolver that looks up the user using the local part of their email address as the entity name. await deployment.setMicrosoftResolver("emailLocalPartMatchingUserEntityName", false); @@ -262,8 +267,10 @@ test.describe("Configure Microsoft Provider", async () => { const authCookie = cookies.find((cookie) => cookie.name === "microsoft-refresh-token"); expect(authCookie).toBeDefined(); - const threeDays = 3 * 24 * 60 * 60 * 1000; // expected duration of 3 days in ms - const tolerance = 3 * 60 * 1000; // allow for 3 minutes tolerance + // expected duration of 3 days in ms + const threeDays = 3 * 24 * 60 * 60 * 1000; + // allow for 3 minutes tolerance + const tolerance = 3 * 60 * 1000; const actualDuration = authCookie!.expires * 1000 - Date.now(); @@ -280,7 +287,7 @@ test.describe("Configure Microsoft Provider", async () => { await expect .poll( - async () => + () => deployment.checkUserIsIngestedInCatalog([ "TEST Admin", "TEST Atena", diff --git a/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts b/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts index 5da18a9947..63d6eaa68a 100644 --- a/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts @@ -1,10 +1,9 @@ import { test, expect, Page, BrowserContext } from "@support/coverage/test"; - -import { KeycloakHelper } from "../../utils/authentication-providers/keycloak-helper"; import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; import { Common, setupBrowser } from "../../utils/common"; -import { NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE } from "../../utils/constants"; import { UIhelper } from "../../utils/ui-helper"; +import { KeycloakHelper } from "../../utils/authentication-providers/keycloak-helper"; +import { NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE } from "../../utils/constants"; let page: Page; let context: BrowserContext; @@ -19,6 +18,7 @@ OIDC: [-] oidcSubClaimMatchingPingIdentityUserId -> Ping Identity not supported */ +// oxlint-disable-next-line eslint/require-await -- top-level await configures test.use baseURL test.describe("Configure OIDC provider (using RHBK)", async () => { let common: Common; let uiHelper: UIhelper; @@ -91,16 +91,32 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { await deployment.generateStaticToken(); // set enviroment variables and create secret - if (!process.env.ISRUNNINGLOCAL) { + if ( + process.env.ISRUNNINGLOCAL === undefined || + process.env.ISRUNNINGLOCAL === "" || + process.env.ISRUNNINGLOCAL === "false" + ) { await deployment.addSecretData("BASE_URL", backstageUrl); await deployment.addSecretData("BASE_BACKEND_URL", backstageBackendUrl); } - await deployment.addSecretData("DEFAULT_USER_PASSWORD", process.env.DEFAULT_USER_PASSWORD!); - await deployment.addSecretData("DEFAULT_USER_PASSWORD_2", process.env.DEFAULT_USER_PASSWORD_2!); + await deployment.addSecretData( + "DEFAULT_USER_PASSWORD", + process.env.DEFAULT_USER_PASSWORD!, + ); + await deployment.addSecretData( + "DEFAULT_USER_PASSWORD_2", + process.env.DEFAULT_USER_PASSWORD_2!, + ); await deployment.addSecretData("RHBK_BASE_URL", process.env.RHBK_BASE_URL!); await deployment.addSecretData("RHBK_REALM", process.env.RHBK_REALM!); - await deployment.addSecretData("RHBK_CLIENT_ID", process.env.RHBK_CLIENT_ID!); - await deployment.addSecretData("RHBK_CLIENT_SECRET", process.env.RHBK_CLIENT_SECRET!); + await deployment.addSecretData( + "RHBK_CLIENT_ID", + process.env.RHBK_CLIENT_ID!, + ); + await deployment.addSecretData( + "RHBK_CLIENT_SECRET", + process.env.RHBK_CLIENT_SECRET!, + ); await deployment.addSecretData( "AUTH_PROVIDERS_GH_ORG_CLIENT_ID", @@ -128,13 +144,18 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { await deployment.waitForSynced(); }); - test.beforeEach(async () => { + test.beforeEach(() => { test.info().setTimeout(600 * 1000); - console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); + console.log( + `Running test case ${test.info().title} - Attempt #${test.info().retry}`, + ); }); test("Login with OIDC default resolver", async () => { - const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); + const login = await common.keycloakLogin( + "zeus", + process.env.DEFAULT_USER_PASSWORD!, + ); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -152,7 +173,10 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { test("Login with OIDC oidcSubClaimMatchingKeycloakUserId resolver", async () => { await deployment.enableOIDCLoginWithIngestion(); - await deployment.setOIDCResolver("oidcSubClaimMatchingKeycloakUserId", false); + await deployment.setOIDCResolver( + "oidcSubClaimMatchingKeycloakUserId", + false, + ); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); @@ -161,7 +185,10 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { // wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); + const login = await common.keycloakLogin( + "zeus", + process.env.DEFAULT_USER_PASSWORD!, + ); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -170,7 +197,10 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }); test("Login with OIDC emailMatchingUserEntityProfileEmail resolver", async () => { - await deployment.setOIDCResolver("emailMatchingUserEntityProfileEmail", false); + await deployment.setOIDCResolver( + "emailMatchingUserEntityProfileEmail", + false, + ); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); @@ -179,7 +209,10 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { // wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); + const login = await common.keycloakLogin( + "zeus", + process.env.DEFAULT_USER_PASSWORD!, + ); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -188,7 +221,10 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }); test("Login with OIDC emailLocalPartMatchingUserEntityName resolver", async () => { - await deployment.setOIDCResolver("emailLocalPartMatchingUserEntityName", false); + await deployment.setOIDCResolver( + "emailLocalPartMatchingUserEntityName", + false, + ); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); @@ -197,23 +233,34 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { // wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); + const login = await common.keycloakLogin( + "zeus", + process.env.DEFAULT_USER_PASSWORD!, + ); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); await uiHelper.verifyHeading("Zeus Giove"); await common.signOut(); - const login2 = await common.keycloakLogin("atena", process.env.DEFAULT_USER_PASSWORD!); + const login2 = await common.keycloakLogin( + "atena", + process.env.DEFAULT_USER_PASSWORD!, + ); expect(login2).toBe("Login successful"); - await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); + await uiHelper.verifyAlertErrorMessage( + NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE, + ); await keycloakHelper.initialize(); await keycloakHelper.clearUserSessions("atena"); }); test("Login with OIDC emailLocalPartMatchingUserEntityName with dangerouslyAllowSignInWithoutUserInCatalog resolver", async () => { - await deployment.setOIDCResolver("emailLocalPartMatchingUserEntityName", true); + await deployment.setOIDCResolver( + "emailLocalPartMatchingUserEntityName", + true, + ); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); @@ -222,14 +269,20 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { // wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); + const login = await common.keycloakLogin( + "zeus", + process.env.DEFAULT_USER_PASSWORD!, + ); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); await uiHelper.verifyHeading("Zeus Giove"); await common.signOut(); - const login2 = await common.keycloakLogin("atena", process.env.DEFAULT_USER_PASSWORD!); + const login2 = await common.keycloakLogin( + "atena", + process.env.DEFAULT_USER_PASSWORD!, + ); expect(login2).toBe("Login successful"); await uiHelper.goToSettingsPage(); await uiHelper.verifyHeading("Atena Minerva"); @@ -237,7 +290,10 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }); test("Login with OIDC preferredUsernameMatchingUserEntityName resolver", async () => { - await deployment.setOIDCResolver("preferredUsernameMatchingUserEntityName", false); + await deployment.setOIDCResolver( + "preferredUsernameMatchingUserEntityName", + false, + ); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); @@ -246,7 +302,10 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { // wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.keycloakLogin("atena", process.env.DEFAULT_USER_PASSWORD!); + const login = await common.keycloakLogin( + "atena", + process.env.DEFAULT_USER_PASSWORD!, + ); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -255,7 +314,10 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }); test(`Set sessionDuration and confirm in auth cookie duration has been set`, async () => { - deployment.setAppConfigProperty("auth.providers.oidc.production.sessionDuration", "3days"); + deployment.setAppConfigProperty( + "auth.providers.oidc.production.sessionDuration", + "3days", + ); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); @@ -264,17 +326,24 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { // wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); + const login = await common.keycloakLogin( + "zeus", + process.env.DEFAULT_USER_PASSWORD!, + ); expect(login).toBe("Login successful"); await page.reload(); const cookies = await context.cookies(); - const authCookie = cookies.find((cookie) => cookie.name === "oidc-refresh-token"); + const authCookie = cookies.find( + (cookie) => cookie.name === "oidc-refresh-token", + ); expect(authCookie).toBeDefined(); - const threeDays = 3 * 24 * 60 * 60 * 1000; // expected duration of 3 days in ms - const tolerance = 3 * 60 * 1000; // allow for 3 minutes tolerance + // expected duration of 3 days in ms + const threeDays = 3 * 24 * 60 * 60 * 1000; + // allow for 3 minutes tolerance + const tolerance = 3 * 60 * 1000; const actualDuration = authCookie!.expires * 1000 - Date.now(); @@ -296,25 +365,41 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { "Zeus Giove", ]), ).toBe(true); - expect(await deployment.checkGroupIsIngestedInCatalog(["admins", "goddesses", "gods"])).toBe( - true, - ); + expect( + await deployment.checkGroupIsIngestedInCatalog([ + "admins", + "goddesses", + "gods", + ]), + ).toBe(true); expect(await deployment.checkUserIsInGroup("admin", "admins")).toBe(true); expect(await deployment.checkUserIsInGroup("zeus", "admins")).toBe(true); - expect(await deployment.checkUserIsInGroup("atena", "goddesses")).toBe(true); + expect(await deployment.checkUserIsInGroup("atena", "goddesses")).toBe( + true, + ); expect(await deployment.checkUserIsInGroup("tyke", "goddesses")).toBe(true); expect(await deployment.checkUserIsInGroup("elio", "gods")).toBe(true); expect(await deployment.checkUserIsInGroup("zeus", "gods")).toBe(true); expect(await deployment.checkGroupIsChildOfGroup("gods", "all")).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("goddesses", "all")).toBe(true); - expect(await deployment.checkGroupIsParentOfGroup("all", "gods")).toBe(true); - expect(await deployment.checkGroupIsParentOfGroup("all", "goddesses")).toBe(true); + expect(await deployment.checkGroupIsChildOfGroup("goddesses", "all")).toBe( + true, + ); + expect(await deployment.checkGroupIsParentOfGroup("all", "gods")).toBe( + true, + ); + expect(await deployment.checkGroupIsParentOfGroup("all", "goddesses")).toBe( + true, + ); }); test(`Ingestion of users and groups with invalid characters: check sanitize[User/Group]NameTransformer`, async () => { - expect(await deployment.checkUserIsIngestedInCatalog(["Invalid Username"])).toBe(true); - expect(await deployment.checkGroupIsIngestedInCatalog(["invalid@groupname"])).toBe(true); + expect( + await deployment.checkUserIsIngestedInCatalog(["Invalid Username"]), + ).toBe(true); + expect( + await deployment.checkGroupIsIngestedInCatalog(["invalid@groupname"]), + ).toBe(true); }); test("Ensure Guest login is disabled when setting environment to production", async () => { @@ -328,7 +413,10 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }); test("Login with OIDC as primary sign in provider and GitHub auth as secondary", async () => { - const oidcLogin = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); + const oidcLogin = await common.keycloakLogin( + "zeus", + process.env.DEFAULT_USER_PASSWORD!, + ); expect(oidcLogin).toBe("Login successful"); @@ -342,7 +430,8 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { production: { clientId: "${AUTH_PROVIDERS_GH_ORG_CLIENT_ID}", clientSecret: "${AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET}", - callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/github/handler/frame", + callbackUrl: + "${BASE_URL:-http://localhost:7007}/api/auth/github/handler/frame", }, }); @@ -378,11 +467,12 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { test(`Enable autologout and user is logged out after inactivity`, async () => { deployment.setAppConfigProperty("auth.autologout.enabled", "true"); + // minimum allowed value is 0.5 minutes + deployment.setAppConfigProperty("auth.autologout.idleTimeoutMinutes", 0.5); deployment.setAppConfigProperty( - "auth.autologout.idleTimeoutMinutes", - 0.5, // minimum allowed value is 0.5 minutes + "auth.autologout.promptBeforeIdleSeconds", + 5, ); - deployment.setAppConfigProperty("auth.autologout.promptBeforeIdleSeconds", 5); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); @@ -391,10 +481,17 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { // wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); + const login = await common.keycloakLogin( + "zeus", + process.env.DEFAULT_USER_PASSWORD!, + ); expect(login).toBe("Login successful"); - await uiHelper.verifyTextVisible("Logging out due to inactivity", false, 60000); + await uiHelper.verifyTextVisible( + "Logging out due to inactivity", + false, + 60000, + ); await expect(page.getByText("Logging out due to inactivity")).toBeHidden({ timeout: 30000, }); @@ -402,17 +499,20 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { await page.reload(); const cookies = await context.cookies(); - const authCookie = cookies.find((cookie) => cookie.name === "oidc-refresh-token"); + const authCookie = cookies.find( + (cookie) => cookie.name === "oidc-refresh-token", + ); expect(authCookie).toBeUndefined(); }); test(`Enable autologout and user stays logged in after clicking "Don't log me out"`, async () => { deployment.setAppConfigProperty("auth.autologout.enabled", "true"); + // minimum allowed value is 0.5 minutes + deployment.setAppConfigProperty("auth.autologout.idleTimeoutMinutes", 0.5); deployment.setAppConfigProperty( - "auth.autologout.idleTimeoutMinutes", - 0.5, // minimum allowed value is 0.5 minutes + "auth.autologout.promptBeforeIdleSeconds", + 5, ); - deployment.setAppConfigProperty("auth.autologout.promptBeforeIdleSeconds", 5); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); @@ -421,7 +521,10 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { // wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); + const login = await common.keycloakLogin( + "zeus", + process.env.DEFAULT_USER_PASSWORD!, + ); expect(login).toBe("Login successful"); await uiHelper.clickButtonByText("Don't log me out", { timeout: 60000 }); diff --git a/e2e-tests/playwright/e2e/catalog-timestamp.spec.ts b/e2e-tests/playwright/e2e/catalog-timestamp.spec.ts index 377ea4bb81..78501274a1 100644 --- a/e2e-tests/playwright/e2e/catalog-timestamp.spec.ts +++ b/e2e-tests/playwright/e2e/catalog-timestamp.spec.ts @@ -57,7 +57,7 @@ test.describe("Test timestamp column on Catalog", () => { await uiHelper.verifyText("timestamp-test-created"); await uiHelper.verifyColumnHeading(["Created At"], true); await uiHelper.verifyRowInTableByUniqueText("timestamp-test-created", [ - /^\d{1,2}\/\d{1,2}\/\d{1,4}, \d:\d{1,2}:\d{1,2} (AM|PM)$/g, + /^\d{1,2}\/\d{1,2}\/\d{1,4}, \d:\d{1,2}:\d{1,2} (AM|PM)$/u, ]); }); @@ -76,7 +76,7 @@ test.describe("Test timestamp column on Catalog", () => { .getByRole("row") .filter({ has: page.getByRole("cell") }) .first(); - const createdAtCell = firstRow.getByRole("cell").nth(7); // 0-indexed, 8th column = index 7 + const createdAtCell = firstRow.getByRole("cell").nth(7); const column = page.getByRole("columnheader", { name: "Created At", diff --git a/e2e-tests/playwright/e2e/configuration-test/config-map.spec.ts b/e2e-tests/playwright/e2e/configuration-test/config-map.spec.ts index 88ddae9638..8d66c6c496 100644 --- a/e2e-tests/playwright/e2e/configuration-test/config-map.spec.ts +++ b/e2e-tests/playwright/e2e/configuration-test/config-map.spec.ts @@ -5,7 +5,7 @@ import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; import { UIhelper } from "../../utils/ui-helper"; test.describe("Change app-config at e2e test runtime", () => { - test.beforeAll(async () => { + test.beforeAll(() => { test.info().annotations.push( { type: "component", @@ -13,18 +13,17 @@ test.describe("Change app-config at e2e test runtime", () => { }, { type: "namespace", - description: process.env.NAME_SPACE_RUNTIME || "showcase-runtime", + description: process.env.NAME_SPACE_RUNTIME ?? "showcase-runtime", }, ); }); test("Verify title change after ConfigMap modification", async ({ page }) => { - test.setTimeout(300000); // Increasing to 5 minutes + test.setTimeout(300000); - // Start with a common name, but let KubeClient find the actual ConfigMap const configMapName = "app-config-rhdh"; - const namespace = process.env.NAME_SPACE_RUNTIME || "showcase-runtime"; + const namespace = process.env.NAME_SPACE_RUNTIME ?? "showcase-runtime"; const deploymentName = getRhdhDeploymentName(); const kubeUtils = new KubeClient(); @@ -53,6 +52,6 @@ test.describe("Change app-config at e2e test runtime", () => { }); function generateDynamicTitle() { - const timestamp = new Date().toISOString().replaceAll(/[-:.]/g, ""); + const timestamp = new Date().toISOString().replaceAll(/[-:.]/gu, ""); return `New Title - ${timestamp}`; } diff --git a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts index 45d627125b..70328ea23d 100644 --- a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts +++ b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@support/coverage/test"; - +import { UIhelper } from "../../utils/ui-helper"; import { Common } from "../../utils/common"; import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; import { @@ -8,7 +8,6 @@ import { configurePostgresCredentials, clearDatabase, } from "../../utils/postgres-config"; -import { UIhelper } from "../../utils/ui-helper"; interface AzureDbConfig { name: string; @@ -44,8 +43,10 @@ test.describe("Verify TLS configuration with Azure Database for PostgreSQL healt ); // Validate certificates are available - const azureCerts = readCertificateFile(process.env.AZURE_DB_CERTIFICATES_PATH); - if (!azureCerts) { + const azureCerts = readCertificateFile( + process.env.AZURE_DB_CERTIFICATES_PATH, + ); + if (azureCerts === undefined || azureCerts === null || azureCerts === "") { throw new Error( "AZURE_DB_CERTIFICATES_PATH environment variable must be set and point to a valid certificate file", ); @@ -53,13 +54,17 @@ test.describe("Verify TLS configuration with Azure Database for PostgreSQL healt // Validate required environment variables if (!azureUser || !azurePassword) { - throw new Error("AZURE_DB_USER and AZURE_DB_PASSWORD environment variables must be set"); + throw new Error( + "AZURE_DB_USER and AZURE_DB_PASSWORD environment variables must be set", + ); } const kubeClient = new KubeClient(); // Create/update the postgres-crt secret with Azure certificates - console.log("Configuring Azure Database for PostgreSQL TLS certificates..."); + console.log( + "Configuring Azure Database for PostgreSQL TLS certificates...", + ); await configurePostgresCertificate(kubeClient, namespace, azureCerts); }); @@ -87,8 +92,9 @@ test.describe("Verify TLS configuration with Azure Database for PostgreSQL healt user: azureUser, password: azurePassword, }); - const restarted = await kubeClient.restartDeployment(deploymentName, namespace); - expect(restarted).toBeDefined(); + await expect( + kubeClient.restartDeployment(deploymentName, namespace), + ).resolves.toBeUndefined(); }); test("Verify successful DB connection", async ({ page }) => { diff --git a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-crunchy.spec.ts b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-crunchy.spec.ts index 0e0f9d9ad5..e73a052b0b 100644 --- a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-crunchy.spec.ts +++ b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-crunchy.spec.ts @@ -4,7 +4,7 @@ import { Common } from "../../utils/common"; import { UIhelper } from "../../utils/ui-helper"; test.describe("Verify TLS configuration with external Crunchy Postgres DB", () => { - test.beforeAll(async () => { + test.beforeAll(() => { test.info().annotations.push( { type: "component", @@ -12,7 +12,7 @@ test.describe("Verify TLS configuration with external Crunchy Postgres DB", () = }, { type: "namespace", - description: process.env.NAME_SPACE_RBAC || "showcase-rbac", + description: process.env.NAME_SPACE_RBAC ?? "showcase-rbac", }, ); }); diff --git a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts index 986e3e38d8..32d13de0ee 100644 --- a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts +++ b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@support/coverage/test"; - +import { UIhelper } from "../../utils/ui-helper"; import { Common } from "../../utils/common"; import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; import { @@ -8,7 +8,6 @@ import { configurePostgresCredentials, clearDatabase, } from "../../utils/postgres-config"; -import { UIhelper } from "../../utils/ui-helper"; interface RdsConfig { name: string; @@ -45,7 +44,7 @@ test.describe("Verify TLS configuration with RDS PostgreSQL health check", () => // Validate certificates are available const rdsCerts = readCertificateFile(process.env.RDS_DB_CERTIFICATES_PATH); - if (!rdsCerts) { + if (rdsCerts === undefined || rdsCerts === null || rdsCerts === "") { throw new Error( "RDS_DB_CERTIFICATES_PATH environment variable must be set and point to a valid certificate file", ); @@ -53,7 +52,9 @@ test.describe("Verify TLS configuration with RDS PostgreSQL health check", () => // Validate required environment variables if (!rdsUser || !rdsPassword) { - throw new Error("RDS_USER and RDS_PASSWORD environment variables must be set"); + throw new Error( + "RDS_USER and RDS_PASSWORD environment variables must be set", + ); } const kubeClient = new KubeClient(); @@ -87,8 +88,9 @@ test.describe("Verify TLS configuration with RDS PostgreSQL health check", () => user: rdsUser, password: rdsPassword, }); - const restarted = await kubeClient.restartDeployment(deploymentName, namespace); - expect(restarted).toBeDefined(); + await expect( + kubeClient.restartDeployment(deploymentName, namespace), + ).resolves.toBeUndefined(); }); test("Verify successful DB connection", async ({ page }) => { diff --git a/e2e-tests/playwright/e2e/github-happy-path.spec.ts b/e2e-tests/playwright/e2e/github-happy-path.spec.ts index 24ec297d94..015fd84d70 100644 --- a/e2e-tests/playwright/e2e/github-happy-path.spec.ts +++ b/e2e-tests/playwright/e2e/github-happy-path.spec.ts @@ -47,7 +47,7 @@ async function getShowcasePullRequests( let page: Page; let browserContext: BrowserContext; -// TODO: https://issues.redhat.com/browse/RHDHBUGS-2099 +// Blocked by https://issues.redhat.com/browse/RHDHBUGS-2099 test.describe.fixme("GitHub Happy path", () => { let common: Common; let uiHelper: UIhelper; @@ -133,8 +133,9 @@ test.describe.fixme("GitHub Happy path", () => { const expectedPath = "/catalog/default/component/red-hat-developer-hub"; // Wait for the expected path in the URL + // Wait until the DOM is loaded await page.waitForURL(`**${expectedPath}`, { - waitUntil: "domcontentloaded", // Wait until the DOM is loaded + waitUntil: "domcontentloaded", timeout: 20000, }); // Optionally, verify that the current URL contains the expected path @@ -185,7 +186,8 @@ test.describe.fixme("GitHub Happy path", () => { await backstageShowcase.verifyPRRows(allPRs, 5, 10); // const lastPagePRs = Math.floor((allPRs.length - 1) / 5) * 5; - const lastPagePRs = 996; // redhat-developer/rhdh have more than 1000 PRs open/closed and by default the latest 1000 PR results are displayed. + // redhat-developer/rhdh have more than 1000 PRs open/closed and by default the latest 1000 PR results are displayed. + const lastPagePRs = 996; console.log("Clicking on Last Page button"); await backstageShowcase.clickLastPage(); @@ -208,7 +210,7 @@ test.describe.fixme("GitHub Happy path", () => { await backstageShowcase.verifyPRRowsPerPage(20, allPRs); }); - // TODO: https://issues.redhat.com/browse/RHDHBUGS-2099 + // Blocked by https://issues.redhat.com/browse/RHDHBUGS-2099 test.fixme("Click on the Dependencies tab and verify that all the relations have been listed and displayed", async () => { await uiHelper.clickTab("Dependencies"); for (const resource of RESOURCES) { @@ -218,7 +220,7 @@ test.describe.fixme("GitHub Happy path", () => { } }); - // TODO: https://issues.redhat.com/browse/RHDHBUGS-2099 + // Blocked by https://issues.redhat.com/browse/RHDHBUGS-2099 test.fixme("Sign out and verify that you return back to the Sign in page", async () => { await uiHelper.goToSettingsPage(); await common.signOut(); diff --git a/e2e-tests/playwright/e2e/guest-signin-happy-path.spec.ts b/e2e-tests/playwright/e2e/guest-signin-happy-path.spec.ts index 1f98a48585..2e0a6366ff 100644 --- a/e2e-tests/playwright/e2e/guest-signin-happy-path.spec.ts +++ b/e2e-tests/playwright/e2e/guest-signin-happy-path.spec.ts @@ -9,7 +9,7 @@ const t = getTranslations(); const lang = getCurrentLanguage(); test.describe("Guest Signing Happy path", () => { - test.beforeAll(async () => { + test.beforeAll(() => { test.info().annotations.push({ type: "component", description: "authentication", diff --git a/e2e-tests/playwright/e2e/home-page-customization.spec.ts b/e2e-tests/playwright/e2e/home-page-customization.spec.ts index 69e0c6c858..36a13f5454 100644 --- a/e2e-tests/playwright/e2e/home-page-customization.spec.ts +++ b/e2e-tests/playwright/e2e/home-page-customization.spec.ts @@ -10,7 +10,7 @@ test.describe("Home page customization", () => { let uiHelper: UIhelper; let homePage: HomePage; - test.beforeAll(async () => { + test.beforeAll(() => { test.info().annotations.push({ type: "component", description: "core", diff --git a/e2e-tests/playwright/e2e/instance-health-check.spec.ts b/e2e-tests/playwright/e2e/instance-health-check.spec.ts index 92caf35122..c59e129198 100644 --- a/e2e-tests/playwright/e2e/instance-health-check.spec.ts +++ b/e2e-tests/playwright/e2e/instance-health-check.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "@support/coverage/test"; test.describe("Application health check", () => { - test.beforeAll(async () => { + test.beforeAll(() => { test.info().annotations.push({ type: "component", description: "core", diff --git a/e2e-tests/playwright/e2e/learning-path-page.spec.ts b/e2e-tests/playwright/e2e/learning-path-page.spec.ts index acb265a497..e280e7fd7c 100644 --- a/e2e-tests/playwright/e2e/learning-path-page.spec.ts +++ b/e2e-tests/playwright/e2e/learning-path-page.spec.ts @@ -5,7 +5,7 @@ import { Common } from "../utils/common"; import { UIhelper } from "../utils/ui-helper"; test.describe("Learning Paths", { tag: "@layer3-equivalent" }, () => { - test.beforeAll(async () => { + test.beforeAll(() => { test.info().annotations.push({ type: "component", description: "core", diff --git a/e2e-tests/playwright/e2e/localization/locale.ts b/e2e-tests/playwright/e2e/localization/locale.ts index f4217b6aaf..d765749d29 100644 --- a/e2e-tests/playwright/e2e/localization/locale.ts +++ b/e2e-tests/playwright/e2e/localization/locale.ts @@ -81,7 +81,7 @@ function createMergedTranslations() { const merged: Record>> = {}; for (const namespace of allNamespaces) { - const enKeys = (en as TranslationFile)[namespace]?.en || {}; + const enKeys = (en as TranslationFile)[namespace]?.en ?? {}; const namespaceTranslations: Record> = { en: enKeys, }; @@ -105,7 +105,7 @@ function createMergedTranslations() { const translations = createMergedTranslations(); export function getCurrentLanguage(): Locale { - const lang = process.env.LOCALE || "en"; + const lang = process.env.LOCALE ?? "en"; return isLocale(lang) ? lang : "en"; } diff --git a/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-db.ts b/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-db.ts index 13c9c8f084..bd4e8bb501 100644 --- a/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-db.ts +++ b/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-db.ts @@ -16,11 +16,11 @@ export interface SchemaModeEnv { } function quoteIdent(name: string): string { - return '"' + name.replaceAll(/"/g, '""') + '"'; + return '"' + name.replaceAll('"', '""') + '"'; } function escapePasswordLiteral(value: string): string { - return value.replaceAll(/'/g, "''"); + return value.replaceAll("'", "''"); } export function normalizeDbHost(host: string): string { @@ -29,7 +29,9 @@ export function normalizeDbHost(host: string): string { let portForwardRestarter: (() => Promise) | null = null; -export function setPortForwardRestarter(fn: (() => Promise) | null): void { +export function setPortForwardRestarter( + fn: (() => Promise) | null, +): void { portForwardRestarter = fn; } @@ -69,7 +71,9 @@ async function connectWithRetry(config: ClientConfig): Promise { ); } } else { - console.warn(`Connection attempt ${attempt}/${maxRetries} failed, retrying...`); + console.warn( + `Connection attempt ${attempt}/${maxRetries} failed, retrying...`, + ); } const delay = Math.min(2000 * attempt, 10000); @@ -82,8 +86,11 @@ async function connectWithRetry(config: ClientConfig): Promise { } } - const errorMsg = lastError instanceof Error ? lastError.message : String(lastError); - throw new Error(`Failed to connect after ${maxRetries} attempts: ${errorMsg}`); + const errorMsg = + lastError instanceof Error ? lastError.message : String(lastError); + throw new Error( + `Failed to connect after ${maxRetries} attempts: ${errorMsg}`, + ); } const defaultConnectionOptions: Partial = { @@ -92,7 +99,7 @@ const defaultConnectionOptions: Partial = { keepAliveInitialDelayMillis: 10000, }; -export async function connectWithSslFallback(config: ClientConfig): Promise { +export function connectWithSslFallback(config: ClientConfig): Promise { return connectWithRetry({ ...defaultConnectionOptions, ...config }); } @@ -101,24 +108,30 @@ export function getSchemaModeEnv(): SchemaModeEnv { const dbAdminPassword = process.env.SCHEMA_MODE_DB_ADMIN_PASSWORD; const dbPassword = process.env.SCHEMA_MODE_DB_PASSWORD; - expect(dbHost, "SCHEMA_MODE_DB_HOST must be set for schema-mode tests").toBeTruthy(); + expect( + dbHost, + "SCHEMA_MODE_DB_HOST must be set for schema-mode tests", + ).toBeTruthy(); expect( dbAdminPassword, "SCHEMA_MODE_DB_ADMIN_PASSWORD must be set for schema-mode tests", ).toBeTruthy(); - expect(dbPassword, "SCHEMA_MODE_DB_PASSWORD must be set for schema-mode tests").toBeTruthy(); + expect( + dbPassword, + "SCHEMA_MODE_DB_PASSWORD must be set for schema-mode tests", + ).toBeTruthy(); return { dbHost: dbHost!, - dbAdminUser: process.env.SCHEMA_MODE_DB_ADMIN_USER || "postgres", + dbAdminUser: process.env.SCHEMA_MODE_DB_ADMIN_USER ?? "postgres", dbAdminPassword: dbAdminPassword!, - dbName: process.env.SCHEMA_MODE_DB_NAME || "postgres", - dbUser: process.env.SCHEMA_MODE_DB_USER || "backstage_schema_user", + dbName: process.env.SCHEMA_MODE_DB_NAME ?? "postgres", + dbUser: process.env.SCHEMA_MODE_DB_USER ?? "backstage_schema_user", dbPassword: dbPassword!, }; } -export async function connectAdminClient( +export function connectAdminClient( config: Pick, ): Promise { return connectWithSslFallback({ @@ -131,7 +144,9 @@ export async function connectAdminClient( }); } -export async function cleanupOldPluginDatabases(adminClient: Client): Promise { +export async function cleanupOldPluginDatabases( + adminClient: Client, +): Promise { const oldDbsResult = await adminClient.query<{ datname: string }>(` SELECT datname FROM pg_database WHERE datistemplate = false @@ -143,7 +158,9 @@ export async function cleanupOldPluginDatabases(adminClient: Client): Promise { - const { dbHost, dbAdminUser, dbAdminPassword, dbName, dbUser, dbPassword } = config; + const { dbHost, dbAdminUser, dbAdminPassword, dbName, dbUser, dbPassword } = + config; - if (dbName !== "postgres") { - await adminClient.query(`CREATE DATABASE ${quoteIdent(dbName)}`).catch(() => {}); - console.log(`✓ Created/verified test database: ${dbName}`); - } else { + if (dbName === "postgres") { console.log(`✓ Using default postgres database`); + } else { + await adminClient + .query(`CREATE DATABASE ${quoteIdent(dbName)}`) + .catch(() => {}); + console.log(`✓ Created/verified test database: ${dbName}`); } await adminClient @@ -228,9 +250,15 @@ export async function setupSchemaModeDatabase( }); try { - await dbClient.query(`GRANT CREATE ON DATABASE ${quoteIdent(dbName)} TO ${quoteIdent(dbUser)}`); - await dbClient.query(`GRANT USAGE ON SCHEMA public TO ${quoteIdent(dbUser)}`); - await dbClient.query(`GRANT CREATE ON SCHEMA public TO ${quoteIdent(dbUser)}`); + await dbClient.query( + `GRANT CREATE ON DATABASE ${quoteIdent(dbName)} TO ${quoteIdent(dbUser)}`, + ); + await dbClient.query( + `GRANT USAGE ON SCHEMA public TO ${quoteIdent(dbUser)}`, + ); + await dbClient.query( + `GRANT CREATE ON SCHEMA public TO ${quoteIdent(dbUser)}`, + ); await dbClient.query( `GRANT ALL PRIVILEGES ON DATABASE ${quoteIdent(dbName)} TO ${quoteIdent(dbUser)}`, ); diff --git a/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-setup.ts b/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-setup.ts index 2e258b3fc4..b9a6ad6326 100644 --- a/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-setup.ts +++ b/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-setup.ts @@ -4,7 +4,6 @@ */ import * as yaml from "js-yaml"; - import { KubeClient } from "../../utils/kube-client"; import { getSchemaModeEnv, @@ -40,7 +39,11 @@ export class SchemaModeTestSetup { private env: ReturnType; private kubeClient: KubeClient; - constructor(namespace: string, releaseName: string, installMethod: "helm" | "operator") { + constructor( + namespace: string, + releaseName: string, + installMethod: "helm" | "operator", + ) { this.namespace = namespace; this.releaseName = releaseName; this.installMethod = installMethod; @@ -87,7 +90,11 @@ export class SchemaModeTestSetup { private resolveRhdhPostgresHost(): string { const pfNamespace = process.env.SCHEMA_MODE_PORT_FORWARD_NAMESPACE; - if (pfNamespace && pfNamespace !== this.namespace) { + if ( + pfNamespace !== undefined && + pfNamespace !== "" && + pfNamespace !== this.namespace + ) { return `postgress-external-db-primary.${pfNamespace}.svc.cluster.local`; } @@ -119,8 +126,12 @@ export class SchemaModeTestSetup { metadata: { name: secretName }, data: { password: Buffer.from(this.env.dbPassword).toString("base64"), - "postgres-password": Buffer.from(this.env.dbPassword).toString("base64"), - POSTGRES_PASSWORD: Buffer.from(this.env.dbPassword).toString("base64"), + "postgres-password": Buffer.from(this.env.dbPassword).toString( + "base64", + ), + POSTGRES_PASSWORD: Buffer.from(this.env.dbPassword).toString( + "base64", + ), POSTGRES_DB: Buffer.from(this.env.dbName).toString("base64"), POSTGRES_USER: Buffer.from(this.env.dbUser).toString("base64"), POSTGRES_HOST: Buffer.from(rhdhPostgresHost).toString("base64"), @@ -135,12 +146,12 @@ export class SchemaModeTestSetup { // For operator: env vars are injected via extraEnvs.secrets in the Backstage CR, // so we only update the secret (step 1). Patching the Deployment directly // conflicts with operator reconciliation. - if (this.installMethod !== "operator") { - await this.ensureDeploymentEnvVars(deploymentName, secretName); - } else { + if (this.installMethod === "operator") { console.log( "Skipping Deployment env var patching (operator injects env vars from secret via extraEnvs.secrets)", ); + } else { + await this.ensureDeploymentEnvVars(deploymentName, secretName); } // 3. Update app-config ConfigMap for schema mode @@ -158,8 +169,13 @@ export class SchemaModeTestSetup { break; } catch (restartError) { if (attempt === maxRestartAttempts) throw restartError; - const msg = restartError instanceof Error ? restartError.message : String(restartError); - console.warn(`Restart attempt ${attempt} failed (${msg}), retrying in 30s...`); + const msg = + restartError instanceof Error + ? restartError.message + : String(restartError); + console.warn( + `Restart attempt ${attempt} failed (${msg}), retrying in 30s...`, + ); await new Promise((resolve) => { setTimeout(() => { resolve(); @@ -169,79 +185,95 @@ export class SchemaModeTestSetup { } } - private async ensureDeploymentEnvVars(deploymentName: string, secretName: string): Promise { + private async ensureDeploymentEnvVars( + deploymentName: string, + secretName: string, + ): Promise { const deployment = await this.kubeClient.appsApi.readNamespacedDeployment( deploymentName, this.namespace, ); - const containers = deployment.body.spec?.template?.spec?.containers || []; - const backstageIdx = containers.findIndex((c) => c.name === "backstage-backend"); - const backstageContainer = containers[backstageIdx]; + const containers = deployment.body.spec?.template?.spec?.containers ?? []; + const backstageContainer = containers.find( + (c) => c.name === "backstage-backend", + ); + const backstageIdx = containers.findIndex( + (c) => c.name === "backstage-backend", + ); - if (!backstageContainer) { + if (backstageContainer === undefined) { console.warn("backstage-backend container not found in deployment"); - return; - } - - const existingEnv = backstageContainer.env || []; - const requiredVars = [ - "POSTGRES_HOST", - "POSTGRES_PORT", - "POSTGRES_DB", - "POSTGRES_USER", - "POSTGRES_PASSWORD", - ]; - const missingVars = requiredVars.filter((v) => !existingEnv.some((e) => e.name === v)); - - if (missingVars.length === 0) { - console.log("POSTGRES_* env vars already present in deployment"); - return; - } + } else { + const existingEnv = backstageContainer.env ?? []; + const requiredVars = [ + "POSTGRES_HOST", + "POSTGRES_PORT", + "POSTGRES_DB", + "POSTGRES_USER", + "POSTGRES_PASSWORD", + ]; + const missingVars = requiredVars.filter( + (v) => !existingEnv.some((e) => e.name === v), + ); - console.log(`Adding env vars to deployment: ${missingVars.join(", ")}`); - const patch: { op: string; path: string; value?: unknown }[] = []; + if (missingVars.length === 0) { + console.log("POSTGRES_* env vars already present in deployment"); + return; + } - if (!backstageContainer.env || backstageContainer.env.length === 0) { - patch.push({ - op: "add", - path: `/spec/template/spec/containers/${backstageIdx}/env`, - value: [], - }); - } + console.log(`Adding env vars to deployment: ${missingVars.join(", ")}`); + const patch: { op: string; path: string; value?: unknown }[] = []; + + if ( + backstageContainer.env === undefined || + backstageContainer.env.length === 0 + ) { + patch.push({ + op: "add", + path: `/spec/template/spec/containers/${backstageIdx}/env`, + value: [], + }); + } - for (const varName of missingVars) { - patch.push({ - op: "add", - path: `/spec/template/spec/containers/${backstageIdx}/env/-`, - value: { - name: varName, - valueFrom: { - secretKeyRef: { name: secretName, key: varName }, + for (const varName of missingVars) { + patch.push({ + op: "add", + path: `/spec/template/spec/containers/${backstageIdx}/env/-`, + value: { + name: varName, + valueFrom: { + secretKeyRef: { name: secretName, key: varName }, + }, }, - }, - }); - } + }); + } - await this.kubeClient.appsApi.patchNamespacedDeployment( - deploymentName, - this.namespace, - patch, - undefined, - undefined, - undefined, - undefined, - undefined, - { headers: { "Content-Type": "application/json-patch+json" } }, - ); - console.log("Added env vars to deployment"); + await this.kubeClient.appsApi.patchNamespacedDeployment( + deploymentName, + this.namespace, + patch, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { "Content-Type": "application/json-patch+json" } }, + ); + console.log("Added env vars to deployment"); + } } private async updateAppConfigForSchemaMode(): Promise { - const configMapName = await this.kubeClient.findAppConfigMap(this.namespace); + const configMapName = await this.kubeClient.findAppConfigMap( + this.namespace, + ); let configMapResponse; try { - configMapResponse = await this.kubeClient.getConfigMap(configMapName, this.namespace); + configMapResponse = await this.kubeClient.getConfigMap( + configMapName, + this.namespace, + ); } catch { throw new Error( `ConfigMap '${configMapName}' not found in namespace '${this.namespace}'. ` + @@ -250,14 +282,22 @@ export class SchemaModeTestSetup { } const configMap = configMapResponse.body; - const configKey = Object.keys(configMap.data || {}).find((key) => key.includes("app-config")); + const configKey = Object.keys(configMap.data ?? {}).find((key) => + key.includes("app-config"), + ); - if (!configKey || !configMap.data) { - throw new Error(`Could not find app-config key in ConfigMap ${configMapName}`); + if ( + configKey === undefined || + configKey === "" || + configMap.data === undefined + ) { + throw new Error( + `Could not find app-config key in ConfigMap ${configMapName}`, + ); } const appConfig = parseAppConfigYaml(yaml.load(configMap.data[configKey])); - if (!appConfig.backend) appConfig.backend = {}; + appConfig.backend ??= {}; const currentDbConfig = appConfig.backend.database; const isAlreadyConfigured = @@ -300,20 +340,25 @@ export class SchemaModeTestSetup { const routeNames = this.installMethod === "operator" ? [`backstage-${this.releaseName}`, `${this.releaseName}-developer-hub`] - : [`${this.releaseName}-developer-hub`, `backstage-${this.releaseName}`]; + : [ + `${this.releaseName}-developer-hub`, + `backstage-${this.releaseName}`, + ]; for (const routeName of routeNames) { try { - const route = (await this.kubeClient.customObjectsApi.getNamespacedCustomObject( - "route.openshift.io", - "v1", - this.namespace, - "routes", - routeName, - )) as { body?: { spec?: { host?: string } } }; - - if (route?.body?.spec?.host) { - const url = `https://${route.body.spec.host}`; + const route = + (await this.kubeClient.customObjectsApi.getNamespacedCustomObject( + "route.openshift.io", + "v1", + this.namespace, + "routes", + routeName, + )) as { body?: { spec?: { host?: string } } }; + + const routeHost = route.body?.spec?.host; + if (routeHost !== undefined && routeHost !== "") { + const url = `https://${routeHost}`; console.log(`Found RHDH URL: ${url}`); return url; } @@ -347,7 +392,9 @@ export class SchemaModeTestSetup { const hasCreateDb = result.rows[0].rolcreatedb; if (!hasCreateDb) { - console.log(`Database user "${this.env.dbUser}" has restricted permissions (NOCREATEDB)`); + console.log( + `Database user "${this.env.dbUser}" has restricted permissions (NOCREATEDB)`, + ); return true; } console.warn(`Database user "${this.env.dbUser}" has CREATEDB privilege`); diff --git a/e2e-tests/playwright/e2e/plugin-division-mode-schema/verify-schema-mode.spec.ts b/e2e-tests/playwright/e2e/plugin-division-mode-schema/verify-schema-mode.spec.ts index 16cfca3b25..d98214e266 100644 --- a/e2e-tests/playwright/e2e/plugin-division-mode-schema/verify-schema-mode.spec.ts +++ b/e2e-tests/playwright/e2e/plugin-division-mode-schema/verify-schema-mode.spec.ts @@ -7,10 +7,8 @@ * Tests are opt-in - they skip when SCHEMA_MODE_* environment variables are not set. */ -import { ChildProcessWithoutNullStreams, spawn } from "child_process"; - import { test, expect } from "@support/coverage/test"; - +import { ChildProcessWithoutNullStreams, spawn } from "child_process"; import { Common } from "../../utils/common"; import { KubeClient } from "../../utils/kube-client"; import { setPortForwardRestarter } from "./schema-mode-db"; @@ -25,7 +23,13 @@ function startPortForward( pfResource: string, ): Promise { return new Promise((resolve, reject) => { - const proc = spawn("oc", ["port-forward", "-n", pfNamespace, pfResource, "5432:5432"]); + const proc = spawn("oc", [ + "port-forward", + "-n", + pfNamespace, + pfResource, + "5432:5432", + ]); const timeout = setTimeout(() => { proc.kill("SIGTERM"); @@ -54,7 +58,9 @@ function startPortForward( }); } -function killPortForward(proc: ChildProcessWithoutNullStreams | undefined): Promise { +function killPortForward( + proc: ChildProcessWithoutNullStreams | undefined, +): Promise { if (!proc || proc.exitCode !== null) return Promise.resolve(); return new Promise((resolve) => { @@ -77,9 +83,10 @@ function killPortForward(proc: ChildProcessWithoutNullStreams | undefined): Prom } test.describe("Verify pluginDivisionMode: schema", () => { - const namespace = process.env.NAME_SPACE_RUNTIME || "showcase-runtime"; - const releaseName = process.env.RELEASE_NAME || "developer-hub"; - const installMethod = process.env.INSTALL_METHOD === "operator" ? "operator" : "helm"; + const namespace = process.env.NAME_SPACE_RUNTIME ?? "showcase-runtime"; + const releaseName = process.env.RELEASE_NAME ?? "developer-hub"; + const installMethod = + process.env.INSTALL_METHOD === "operator" ? "operator" : "helm"; let portForwardProcess: ChildProcessWithoutNullStreams | undefined; let testSetup: SchemaModeTestSetup; @@ -87,14 +94,24 @@ test.describe("Verify pluginDivisionMode: schema", () => { test.beforeAll(async ({}, testInfo) => { test.setTimeout(900000); + const pfNamespace = process.env.SCHEMA_MODE_PORT_FORWARD_NAMESPACE; + const pfResource = process.env.SCHEMA_MODE_PORT_FORWARD_RESOURCE; + const dbHost = process.env.SCHEMA_MODE_DB_HOST; + const adminPassword = process.env.SCHEMA_MODE_DB_ADMIN_PASSWORD; + const dbPassword = process.env.SCHEMA_MODE_DB_PASSWORD; + const hasPortForwardMeta = - !!process.env.SCHEMA_MODE_PORT_FORWARD_NAMESPACE && - !!process.env.SCHEMA_MODE_PORT_FORWARD_RESOURCE; - const hasDirectHost = !!process.env.SCHEMA_MODE_DB_HOST; + pfNamespace !== undefined && + pfNamespace !== "" && + pfResource !== undefined && + pfResource !== ""; + const hasDirectHost = dbHost !== undefined && dbHost !== ""; if ( - !process.env.SCHEMA_MODE_DB_ADMIN_PASSWORD || - !process.env.SCHEMA_MODE_DB_PASSWORD || + adminPassword === undefined || + adminPassword === "" || + dbPassword === undefined || + dbPassword === "" || (!hasPortForwardMeta && !hasDirectHost) ) { testInfo.skip( @@ -110,10 +127,9 @@ test.describe("Verify pluginDivisionMode: schema", () => { ); if (hasPortForwardMeta) { - const pfNamespace = process.env.SCHEMA_MODE_PORT_FORWARD_NAMESPACE!; - const pfResource = process.env.SCHEMA_MODE_PORT_FORWARD_RESOURCE!; - - console.log(`Starting port-forward: ${pfResource} in ${pfNamespace} -> localhost:5432`); + console.log( + `Starting port-forward: ${pfResource} in ${pfNamespace} -> localhost:5432`, + ); portForwardProcess = await startPortForward(pfNamespace, pfResource); console.log("Port-forward established"); @@ -144,11 +160,14 @@ test.describe("Verify pluginDivisionMode: schema", () => { }); test("Verify database user has restricted permissions", async () => { - const hasRestrictedPerms = await testSetup.verifyRestrictedDatabasePermissions(); + const hasRestrictedPerms = + await testSetup.verifyRestrictedDatabasePermissions(); expect(hasRestrictedPerms).toBe(true); }); - test("Verify RHDH is accessible with schema mode", async ({ page }, testInfo) => { + test("Verify RHDH is accessible with schema mode", async ({ + page, + }, testInfo) => { const kubeClient = new KubeClient(); const deploymentName = testSetup.getDeploymentName(); @@ -160,7 +179,10 @@ test.describe("Verify pluginDivisionMode: schema", () => { const readyReplicas = deployment.body.status?.readyReplicas ?? 0; if (readyReplicas < 1) { - testInfo.skip(true, "Deployment is not ready (cluster capacity or PVC issue)"); + testInfo.skip( + true, + "Deployment is not ready (cluster capacity or PVC issue)", + ); return; } } catch (error) { @@ -172,6 +194,8 @@ test.describe("Verify pluginDivisionMode: schema", () => { await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); - console.log("RHDH is accessible - plugins successfully created schemas in schema mode"); + console.log( + "RHDH is accessible - plugins successfully created schemas in schema mode", + ); }); }); diff --git a/e2e-tests/playwright/e2e/plugins/application-listener.spec.ts b/e2e-tests/playwright/e2e/plugins/application-listener.spec.ts index 1e3e32dc7d..74621a494e 100644 --- a/e2e-tests/playwright/e2e/plugins/application-listener.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/application-listener.spec.ts @@ -4,7 +4,7 @@ import { Common } from "../../utils/common"; import { UIhelper } from "../../utils/ui-helper"; test.describe("Test ApplicationListener", () => { - test.beforeAll(async () => { + test.beforeAll(() => { test.info().annotations.push({ type: "component", description: "plugins", diff --git a/e2e-tests/playwright/e2e/plugins/application-provider.spec.ts b/e2e-tests/playwright/e2e/plugins/application-provider.spec.ts index bde2b089c6..9a347c01fc 100644 --- a/e2e-tests/playwright/e2e/plugins/application-provider.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/application-provider.spec.ts @@ -4,7 +4,7 @@ import { Common } from "../../utils/common"; import { UIhelper } from "../../utils/ui-helper"; test.describe("Test ApplicationProvider", () => { - test.beforeAll(async () => { + test.beforeAll(() => { test.info().annotations.push({ type: "component", description: "plugins", diff --git a/e2e-tests/playwright/e2e/plugins/http-request.spec.ts b/e2e-tests/playwright/e2e/plugins/http-request.spec.ts index ad8cd61927..99049ffe00 100644 --- a/e2e-tests/playwright/e2e/plugins/http-request.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/http-request.spec.ts @@ -17,7 +17,7 @@ test.describe("Testing scaffolder-backend-module-http-request to invoke an exter let catalogImport: CatalogImport; const template = "https://github.com/janus-qe/software-template/blob/main/test-http-request.yaml"; - test.beforeAll(async () => { + test.beforeAll(() => { test.info().annotations.push({ type: "component", description: "plugins", diff --git a/e2e-tests/playwright/e2e/plugins/licensed-users-info-backend/licensed-users-info.spec.ts b/e2e-tests/playwright/e2e/plugins/licensed-users-info-backend/licensed-users-info.spec.ts index b98502a2dc..6503645cdb 100644 --- a/e2e-tests/playwright/e2e/plugins/licensed-users-info-backend/licensed-users-info.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/licensed-users-info-backend/licensed-users-info.spec.ts @@ -43,13 +43,13 @@ function isLicensedUser(value: unknown): value is LicensedUser { } function isLicensedUserArray(value: unknown): value is LicensedUser[] { - return Array.isArray(value) && value.every(isLicensedUser); + return Array.isArray(value) && value.every((item) => isLicensedUser(item)); } test.describe("Test licensed users info backend plugin", () => { let common: Common; - test.beforeAll(async () => { + test.beforeAll(() => { test.info().annotations.push({ type: "component", description: "plugins", diff --git a/e2e-tests/playwright/e2e/plugins/scaffolder-backend-module-annotator/annotator.spec.ts b/e2e-tests/playwright/e2e/plugins/scaffolder-backend-module-annotator/annotator.spec.ts index 5f077adf4e..0164896ebd 100644 --- a/e2e-tests/playwright/e2e/plugins/scaffolder-backend-module-annotator/annotator.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/scaffolder-backend-module-annotator/annotator.spec.ts @@ -1,11 +1,10 @@ import { Page, test, expect } from "@support/coverage/test"; - +import { UIhelper } from "../../../utils/ui-helper"; +import { Common, setupBrowser, teardownBrowser } from "../../../utils/common"; import { CatalogImport } from "../../../support/pages/catalog-import"; -import { runAccessibilityTests } from "../../../utils/accessibility"; -import { GITHUB_API_ENDPOINTS } from "../../../utils/api-endpoints"; import { APIHelper } from "../../../utils/api-helper"; -import { Common, setupBrowser, teardownBrowser } from "../../../utils/common"; -import { UIhelper } from "../../../utils/ui-helper"; +import { GITHUB_API_ENDPOINTS } from "../../../utils/api-endpoints"; +import { runAccessibilityTests } from "../../../utils/accessibility"; let page: Page; @@ -29,7 +28,10 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { label: "some-label", annotation: "some-annotation", repo: `test-annotator-${Date.now()}`, - repoOwner: Buffer.from(process.env.GITHUB_ORG || "amFudXMtcWU=", "base64").toString("utf8"), // Default repoOwner janus-qe + repoOwner: Buffer.from( + process.env.GITHUB_ORG ?? "amFudXMtcWU=", + "base64", + ).toString("utf8"), }; test.beforeAll(async ({ browser }, testInfo) => { @@ -70,10 +72,16 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.clickButton("Choose"); await uiHelper.fillTextInputByLabel("Name", reactAppDetails.componentName); - await uiHelper.fillTextInputByLabel("Description", reactAppDetails.description); + await uiHelper.fillTextInputByLabel( + "Description", + reactAppDetails.description, + ); await uiHelper.fillTextInputByLabel("Owner", reactAppDetails.owner); await uiHelper.fillTextInputByLabel("Label", reactAppDetails.label); - await uiHelper.fillTextInputByLabel("Annotation", reactAppDetails.annotation); + await uiHelper.fillTextInputByLabel( + "Annotation", + reactAppDetails.annotation, + ); await uiHelper.clickButton("Next"); await uiHelper.fillTextInputByLabel("Owner", reactAppDetails.repoOwner); @@ -81,17 +89,29 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.pressTab(); await uiHelper.clickButton("Review"); - await uiHelper.verifyRowInTableByUniqueText("Owner", [`group:${reactAppDetails.owner}`]); - await uiHelper.verifyRowInTableByUniqueText("Name", [reactAppDetails.componentName]); - await uiHelper.verifyRowInTableByUniqueText("Description", [reactAppDetails.description]); - await uiHelper.verifyRowInTableByUniqueText("Label", [reactAppDetails.label]); - await uiHelper.verifyRowInTableByUniqueText("Annotation", [reactAppDetails.annotation]); + await uiHelper.verifyRowInTableByUniqueText("Owner", [ + `group:${reactAppDetails.owner}`, + ]); + await uiHelper.verifyRowInTableByUniqueText("Name", [ + reactAppDetails.componentName, + ]); + await uiHelper.verifyRowInTableByUniqueText("Description", [ + reactAppDetails.description, + ]); + await uiHelper.verifyRowInTableByUniqueText("Label", [ + reactAppDetails.label, + ]); + await uiHelper.verifyRowInTableByUniqueText("Annotation", [ + reactAppDetails.annotation, + ]); await uiHelper.verifyRowInTableByUniqueText("Repository Location", [ `github.com?owner=${reactAppDetails.repoOwner}&repo=${reactAppDetails.repo}`, ]); await uiHelper.clickButton("Create"); - await expect(page.getByRole("link", { name: "Open in catalog" })).toBeVisible({ + await expect( + page.getByRole("link", { name: "Open in catalog" }), + ).toBeVisible({ timeout: 30_000, }); await uiHelper.clickLink("Open in catalog"); @@ -101,7 +121,9 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.openCatalogSidebar("Component"); await uiHelper.searchInputPlaceholder(reactAppDetails.componentName); - await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, ["website"]); + await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, [ + "website", + ]); await uiHelper.clickLink(reactAppDetails.componentName); await catalogImport.inspectEntityAndVerifyYaml( @@ -113,7 +135,9 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.openCatalogSidebar("Component"); await uiHelper.searchInputPlaceholder(reactAppDetails.componentName); - await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, ["website"]); + await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, [ + "website", + ]); await uiHelper.clickLink(reactAppDetails.componentName); await catalogImport.inspectEntityAndVerifyYaml( @@ -125,10 +149,14 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.openCatalogSidebar("Component"); await uiHelper.searchInputPlaceholder(reactAppDetails.componentName); - await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, ["website"]); + await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, [ + "website", + ]); await uiHelper.clickLink(reactAppDetails.componentName); - await catalogImport.inspectEntityAndVerifyYaml(`backstage.io/template-version: 0.0.1`); + await catalogImport.inspectEntityAndVerifyYaml( + `backstage.io/template-version: 0.0.1`, + ); }); test("Verify template version annotation is present on the template", async () => { @@ -136,16 +164,23 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.selectMuiBox("Kind", "Template"); await uiHelper.searchInputPlaceholder("Create React App Template\n"); - await uiHelper.verifyRowInTableByUniqueText("Create React App Template", ["website"]); + await uiHelper.verifyRowInTableByUniqueText("Create React App Template", [ + "website", + ]); await uiHelper.clickLink("Create React App Template"); - await catalogImport.inspectEntityAndVerifyYaml(`backstage.io/template-version: 0.0.1`); + await catalogImport.inspectEntityAndVerifyYaml( + `backstage.io/template-version: 0.0.1`, + ); }); test.afterAll(async ({}, testInfo) => { await APIHelper.githubRequest( "DELETE", - GITHUB_API_ENDPOINTS.deleteRepo(reactAppDetails.repoOwner, reactAppDetails.repo), + GITHUB_API_ENDPOINTS.deleteRepo( + reactAppDetails.repoOwner, + reactAppDetails.repo, + ), ); await teardownBrowser(page, testInfo); }); diff --git a/e2e-tests/playwright/e2e/plugins/scaffolder-relation-processor/scaffolder-relation-processor.spec.ts b/e2e-tests/playwright/e2e/plugins/scaffolder-relation-processor/scaffolder-relation-processor.spec.ts index aee70396ea..c5f08711e5 100644 --- a/e2e-tests/playwright/e2e/plugins/scaffolder-relation-processor/scaffolder-relation-processor.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/scaffolder-relation-processor/scaffolder-relation-processor.spec.ts @@ -1,10 +1,9 @@ import { expect, Page, test } from "@support/coverage/test"; - +import { UIhelper } from "../../../utils/ui-helper"; +import { Common, setupBrowser, teardownBrowser } from "../../../utils/common"; import { CatalogImport } from "../../../support/pages/catalog-import"; -import { GITHUB_API_ENDPOINTS } from "../../../utils/api-endpoints"; import { APIHelper } from "../../../utils/api-helper"; -import { Common, setupBrowser, teardownBrowser } from "../../../utils/common"; -import { UIhelper } from "../../../utils/ui-helper"; +import { GITHUB_API_ENDPOINTS } from "../../../utils/api-endpoints"; let page: Page; @@ -29,7 +28,10 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { label: "test-label", annotation: "test-annotation", repo: `test-relation-${Date.now()}`, - repoOwner: Buffer.from(process.env.GITHUB_ORG || "amFudXMtcWU=", "base64").toString("utf8"), // Default repoOwner janus-qe + repoOwner: Buffer.from( + process.env.GITHUB_ORG ?? "amFudXMtcWU=", + "base64", + ).toString("utf8"), }; test.beforeAll(async ({ browser }, testInfo) => { @@ -70,10 +72,16 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { await uiHelper.clickButton("Choose"); await uiHelper.fillTextInputByLabel("Name", reactAppDetails.componentName); - await uiHelper.fillTextInputByLabel("Description", reactAppDetails.description); + await uiHelper.fillTextInputByLabel( + "Description", + reactAppDetails.description, + ); await uiHelper.fillTextInputByLabel("Owner", reactAppDetails.owner); await uiHelper.fillTextInputByLabel("Label", reactAppDetails.label); - await uiHelper.fillTextInputByLabel("Annotation", reactAppDetails.annotation); + await uiHelper.fillTextInputByLabel( + "Annotation", + reactAppDetails.annotation, + ); await uiHelper.clickButton("Next"); await uiHelper.fillTextInputByLabel("Owner", reactAppDetails.repoOwner); @@ -83,7 +91,9 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { await uiHelper.clickButton("Create"); // Wait for the scaffolder task to complete and the link to appear - await expect(page.getByRole("link", { name: "Open in catalog" })).toBeVisible({ + await expect( + page.getByRole("link", { name: "Open in catalog" }), + ).toBeVisible({ timeout: 60000, }); await uiHelper.clickLink("Open in catalog"); @@ -117,9 +127,15 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { const labelSelector = 'g[data-testid="label"]'; const nodeSelector = 'g[data-testid="node"]'; - await uiHelper.verifyTextInSelector(labelSelector, "scaffolderOf / scaffoldedFrom"); + await uiHelper.verifyTextInSelector( + labelSelector, + "scaffolderOf / scaffoldedFrom", + ); - await uiHelper.verifyPartialTextInSelector(nodeSelector, reactAppDetails.componentPartialName); + await uiHelper.verifyPartialTextInSelector( + nodeSelector, + reactAppDetails.componentPartialName, + ); }); test("Verify scaffolderOf relation on the template", async () => { @@ -127,7 +143,9 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { await uiHelper.selectMuiBox("Kind", "Template"); await uiHelper.searchInputPlaceholder("Create React App Template\n"); - await uiHelper.verifyRowInTableByUniqueText("Create React App Template", ["website"]); + await uiHelper.verifyRowInTableByUniqueText("Create React App Template", [ + "website", + ]); await uiHelper.clickLink("Create React App Template"); // Verify the scaffolderOf relation in the YAML view @@ -143,7 +161,10 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { test.afterAll(async ({}, testInfo) => { await APIHelper.githubRequest( "DELETE", - GITHUB_API_ENDPOINTS.deleteRepo(reactAppDetails.repoOwner, reactAppDetails.repo), + GITHUB_API_ENDPOINTS.deleteRepo( + reactAppDetails.repoOwner, + reactAppDetails.repo, + ), ); await teardownBrowser(page, testInfo); }); diff --git a/e2e-tests/playwright/e2e/plugins/user-settings-info-card.spec.ts b/e2e-tests/playwright/e2e/plugins/user-settings-info-card.spec.ts index 66d485e8a6..b9143be3f0 100644 --- a/e2e-tests/playwright/e2e/plugins/user-settings-info-card.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/user-settings-info-card.spec.ts @@ -1,42 +1,49 @@ import { test, expect } from "@support/coverage/test"; - import { Common } from "../../utils/common"; import { UIhelper } from "../../utils/ui-helper"; -test.describe("Test user settings info card", { tag: "@layer3-equivalent" }, () => { - test.beforeAll(async () => { - test.info().annotations.push({ - type: "component", - description: "plugins", +test.describe( + "Test user settings info card", + { tag: "@layer3-equivalent" }, + () => { + test.beforeAll(() => { + test.info().annotations.push({ + type: "component", + description: "plugins", + }); }); - }); - let uiHelper: UIhelper; + let uiHelper: UIhelper; - test.beforeEach(async ({ page }) => { - const common = new Common(page); - await common.loginAsGuest(); + test.beforeEach(async ({ page }) => { + const common = new Common(page); + await common.loginAsGuest(); - uiHelper = new UIhelper(page); - }); + uiHelper = new UIhelper(page); + }); - test("Check if customized build info is rendered", async ({ page }) => { - await uiHelper.openSidebar("Home"); - await page.getByText("Guest").click(); - await page.getByRole("menuitem", { name: "Settings" }).click(); + test("Check if customized build info is rendered", async ({ page }) => { + await uiHelper.openSidebar("Home"); + await page.getByText("Guest").click(); + await page.getByRole("menuitem", { name: "Settings" }).click(); - // Verify card header is visible - await expect(page.getByText("RHDH Build info")).toBeVisible(); + // Verify card header is visible + await expect(page.getByText("RHDH Build info")).toBeVisible(); - // Verify initial card content using text content - await expect(page.getByText("TechDocs builder: local")).toBeVisible(); - await expect(page.getByText("Authentication provider: Github")).toBeVisible(); + // Verify initial card content using text content + await expect(page.getByText("TechDocs builder: local")).toBeVisible(); + await expect( + page.getByText("Authentication provider: Github"), + ).toBeVisible(); - await page.getByTitle("Show more").click(); + await page.getByTitle("Show more").click(); - // Verify expanded card content shows RBAC status - await expect(page.getByText("TechDocs builder: local")).toBeVisible(); - await expect(page.getByText("Authentication provider: Github")).toBeVisible(); - await expect(page.getByText("RBAC: disabled")).toBeVisible(); - }); -}); + // Verify expanded card content shows RBAC status + await expect(page.getByText("TechDocs builder: local")).toBeVisible(); + await expect( + page.getByText("Authentication provider: Github"), + ).toBeVisible(); + await expect(page.getByText("RBAC: disabled")).toBeVisible(); + }); + }, +); diff --git a/e2e-tests/playwright/e2e/settings.spec.ts b/e2e-tests/playwright/e2e/settings.spec.ts index 773f3af28e..69f798e711 100644 --- a/e2e-tests/playwright/e2e/settings.spec.ts +++ b/e2e-tests/playwright/e2e/settings.spec.ts @@ -31,12 +31,12 @@ test.describe(`Settings page`, { tag: "@layer3-equivalent" }, () => { `); await expect(page.getByTestId("select")).toContainText( - /English|Deutsch|Español|Français|Italiano|日本語/, + /English|Deutsch|Español|Français|Italiano|日本語/u, ); await page .getByTestId("select") .getByRole("button", { - name: /English|Deutsch|Español|Français|Italiano|日本語/, + name: /English|Deutsch|Español|Français|Italiano|日本語/u, }) .click(); await expect(page.getByRole("listbox")).toMatchAriaSnapshot(` diff --git a/e2e-tests/playwright/e2e/smoke-test.spec.ts b/e2e-tests/playwright/e2e/smoke-test.spec.ts index dc8cf915d1..87e2583cdf 100644 --- a/e2e-tests/playwright/e2e/smoke-test.spec.ts +++ b/e2e-tests/playwright/e2e/smoke-test.spec.ts @@ -7,7 +7,7 @@ test.describe("Smoke test", { tag: "@smoke" }, () => { let uiHelper: UIhelper; let common: Common; - test.beforeAll(async () => { + test.beforeAll(() => { test.info().annotations.push({ type: "component", description: "core", diff --git a/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts b/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts index 6acb2f6214..b350ce2f9d 100644 --- a/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts +++ b/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts @@ -11,7 +11,7 @@ function streamDataToString(data: Buffer | string): string { } test.describe("Verify Redis Cache DB", () => { - test.beforeAll(async () => { + test.beforeAll(() => { test.info().annotations.push({ type: "component", description: "core", @@ -84,7 +84,7 @@ test.describe("Verify Redis Cache DB", () => { expect(keys).toContainEqual(expect.stringContaining("techdocs")); const key = keys[0]; console.log(`Verifying key format: ${key}`); - expect(key).toMatch(/(?:techdocs):(?:[A-Za-z0-9+/]+={0,2})$/gm); + expect(key).toMatch(/(?:techdocs):(?:[A-Za-z0-9+/]+={0,2})$/gmu); }).toPass({ intervals: [3_000], timeout: 60_000, diff --git a/e2e-tests/playwright/support/api/github-structures.ts b/e2e-tests/playwright/support/api/github-structures.ts index f138e800df..64b8dddf94 100644 --- a/e2e-tests/playwright/support/api/github-structures.ts +++ b/e2e-tests/playwright/support/api/github-structures.ts @@ -2,13 +2,19 @@ export class GetOrganizationResponse { reposUrl: string; constructor(response: unknown) { - if (typeof response !== "object" || response === null || !("repos_url" in response)) { + if ( + typeof response !== "object" || + response === null || + !("repos_url" in response) + ) { throw new Error("Invalid GitHub organization response"); } const reposUrl = (response as { repos_url: unknown }).repos_url; if (typeof reposUrl !== "string") { - throw new Error("Invalid GitHub organization response: missing repos_url"); + throw new TypeError( + "Invalid GitHub organization response: missing repos_url", + ); } this.reposUrl = reposUrl; diff --git a/e2e-tests/playwright/support/api/github.ts b/e2e-tests/playwright/support/api/github.ts index 8b1b8b6d8d..fcf3440d8a 100644 --- a/e2e-tests/playwright/support/api/github.ts +++ b/e2e-tests/playwright/support/api/github.ts @@ -1,21 +1,27 @@ -import { GITHUB_API_ENDPOINTS } from "../../utils/api-endpoints"; -import { APIHelper } from "../../utils/api-helper"; import { JANUS_ORG } from "../../utils/constants"; +import { APIHelper } from "../../utils/api-helper"; +import { GITHUB_API_ENDPOINTS } from "../../utils/api-endpoints"; // https://docs.github.com/en/rest?apiVersion=2022-11-28 export default class GithubApi { - public async getReposFromOrg(org = JANUS_ORG) { - return APIHelper.getGithubPaginatedRequest(GITHUB_API_ENDPOINTS.orgRepos(org)); + public getReposFromOrg(org = JANUS_ORG) { + return APIHelper.getGithubPaginatedRequest( + GITHUB_API_ENDPOINTS.orgRepos(org), + ); } - public async fileExistsInRepo(owner: string, repo: string, file: string): Promise { + public async fileExistsInRepo( + owner: string, + repo: string, + file: string, + ): Promise { const resp = await APIHelper.githubRequest( "GET", `${GITHUB_API_ENDPOINTS.contents(owner, repo)}/${file}`, ); const status = resp.status(); if (status === 403) { - throw Error("You don't have permissions to see this path"); + throw new Error("You don't have permissions to see this path"); } return [200, 302, 304].includes(status); } diff --git a/e2e-tests/playwright/support/api/rbac-api.ts b/e2e-tests/playwright/support/api/rbac-api.ts index 48b92f3f72..33dc8bc9d2 100644 --- a/e2e-tests/playwright/support/api/rbac-api.ts +++ b/e2e-tests/playwright/support/api/rbac-api.ts @@ -1,5 +1,9 @@ -import { APIRequestContext, APIResponse, Page, request } from "@playwright/test"; - +import { + APIRequestContext, + APIResponse, + Page, + request, +} from "@playwright/test"; import playwrightConfig from "../../../playwright.config"; import { Policy, Role } from "./rbac-api-structures"; import { RhdhAuthApiHack } from "./rhdh-auth-api-hack"; @@ -11,11 +15,11 @@ export default class RhdhRbacApi { Authorization: string; }; private myContext!: APIRequestContext; - private readonly roleRegex = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/; + private readonly roleRegex = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/u; private constructor(private readonly token: string) { const baseURL = playwrightConfig.use?.baseURL; - if (!baseURL) { + if (baseURL === undefined || baseURL === "") { throw new Error("playwright.config use.baseURL is not defined"); } this.apiUrl = baseURL + "/api/permission/"; @@ -34,17 +38,16 @@ export default class RhdhRbacApi { return instance; } - //Roles: - - public async getRoles(): Promise { + public getRoles(): Promise { return this.myContext.get("roles"); } - public async getRole(role: string): Promise { + public getRole(role: string): Promise { return this.myContext.get(`roles/role/${role}`); } - public async updateRole( - role: string /* shall be like: default/admin */, + + public updateRole( + role: string, oldRole: Role, newRole: Role, ): Promise { @@ -53,36 +56,35 @@ export default class RhdhRbacApi { data: { oldRole, newRole }, }); } - public async createRoles(role: Role): Promise { + + public createRoles(role: Role): Promise { return this.myContext.post("roles", { data: role }); } - public async deleteRole(role: string): Promise { + public deleteRole(role: string): Promise { return this.myContext.delete(`roles/role/${role}`); } - //Policies: - - public async getPolicies(): Promise { + public getPolicies(): Promise { return this.myContext.get("policies"); } - public async getPoliciesByRole(policy: string): Promise { + public getPoliciesByRole(policy: string): Promise { return this.myContext.get(`policies/role/${policy}`); } - public async getPoliciesByQuery( + public getPoliciesByQuery( params: string | { [key: string]: string | number | boolean }, ): Promise { return this.myContext.get("policies", { params }); } - public async createPolicies(policy: Policy[]): Promise { + public createPolicies(policy: Policy[]): Promise { return this.myContext.post("policies", { data: policy }); } - public async updatePolicy( - role: string /* shall be like: default/admin */, + public updatePolicy( + role: string, oldPolicy: Policy[], newPolicy: Policy[], ): Promise { @@ -91,30 +93,29 @@ export default class RhdhRbacApi { data: { oldPolicy, newPolicy }, }); } - public async deletePolicy(policy: string, policies: Policy[]) { + + public deletePolicy(policy: string, policies: Policy[]) { this.checkRoleFormat(policy); return this.myContext.delete(`policies/role/${policy}`, { data: policies, }); } - // Conditions - - public async getConditions(): Promise { + public getConditions(): Promise { return this.myContext.get("roles/conditions"); } - public async getConditionByQuery( + public getConditionByQuery( params: string | { [key: string]: string | number | boolean }, ): Promise { return this.myContext.get("roles/conditions", { params }); } - public async getConditionById(id: number): Promise { + public getConditionById(id: number): Promise { return this.myContext.get(`roles/conditions/${id}`); } - public async deleteConditionById(id: number): Promise { + public deleteConditionById(id: number): Promise { return this.myContext.delete(`roles/conditions/${id}`); } @@ -123,8 +124,11 @@ export default class RhdhRbacApi { } private checkRoleFormat(role: string) { - if (!this.roleRegex.test(role)) - throw Error("roles passed to the Rbac api must have format like: default/admin"); + if (!this.roleRegex.test(role)) { + throw new Error( + "roles passed to the Rbac api must have format like: default/admin", + ); + } } public static async buildRbacApi(page: Page): Promise { diff --git a/e2e-tests/playwright/support/api/rhdh-auth-hack.ts b/e2e-tests/playwright/support/api/rhdh-auth-hack.ts index e4c4f9c9f4..add1b0c896 100644 --- a/e2e-tests/playwright/support/api/rhdh-auth-hack.ts +++ b/e2e-tests/playwright/support/api/rhdh-auth-hack.ts @@ -5,22 +5,20 @@ import { UIhelper } from "../../utils/ui-helper"; //https://redhatquickcourses.github.io/devhub-admin/devhub-admin/1/chapter2/rbac.html#_lab_rbac_rest_api export class RhdhAuthUiHack { - private static instance: RhdhAuthUiHack; + private static instance: RhdhAuthUiHack | undefined; private token?: string; private constructor() {} public static getInstance(): RhdhAuthUiHack { - if (!RhdhAuthUiHack.instance) { - RhdhAuthUiHack.instance = new RhdhAuthUiHack(); - } + RhdhAuthUiHack.instance ??= new RhdhAuthUiHack(); return RhdhAuthUiHack.instance; } async getApiToken(page: Page): Promise { - if (!this.token) { + if (this.token === undefined) { const apiToken = await this.fetchApiTokenFromPage(page); - if (!apiToken) { + if (apiToken === null || apiToken === "") { throw new Error("Failed to obtain API token from page request"); } this.token = apiToken; @@ -31,7 +29,7 @@ export class RhdhAuthUiHack { private async fetchApiTokenFromPage(page: Page): Promise { const uiHelper = new UIhelper(page); const baseURL = playwrightConfig.use?.baseURL; - if (!baseURL) { + if (baseURL === undefined || baseURL === "") { throw new Error("playwright.config use.baseURL is not defined"); } diff --git a/e2e-tests/playwright/support/page-objects/catalog/catalog-users-obj.ts b/e2e-tests/playwright/support/page-objects/catalog/catalog-users-obj.ts index 8b56623e29..e820f4bcfd 100644 --- a/e2e-tests/playwright/support/page-objects/catalog/catalog-users-obj.ts +++ b/e2e-tests/playwright/support/page-objects/catalog/catalog-users-obj.ts @@ -1,40 +1,43 @@ -/* oxlint-disable typescript/no-extraneous-class -- grouped static page-object helpers */ import { Page, Locator } from "@playwright/test"; -export class CatalogUsersPO { - static BASE_URL = "/catalog?filters%5Bkind%5D=user&filters%5Buser"; +export const CatalogUsersPO = { + BASE_URL: "/catalog?filters%5Bkind%5D=user&filters%5Buser", - static getListOfUsers(page: Page): Locator { + getListOfUsers(page: Page): Locator { // Get all user links in the table's body // Using rowgroup to target tbody, then getting links within cells // These links point to /catalog/{namespace}/user/{username} - return page - .getByRole("table") - .first() // Scope to the first table (users table), not pagination table - .getByRole("rowgroup") - .nth(1) // Second rowgroup (data rows), 0-indexed: 0=header, 1=data - .getByRole("cell") - .getByRole("link"); - } + return ( + page + .getByRole("table") + .first() + // Scope to the first table (users table), not pagination table + .getByRole("rowgroup") + // Second rowgroup (data rows), 0-indexed: 0=header, 1=data + .nth(1) + .getByRole("cell") + .getByRole("link") + ); + }, - static getEmailLink(page: Page): Locator { - return page.getByRole("link", { name: /@/ }); - } + getEmailLink(page: Page): Locator { + return page.getByRole("link", { name: /@/u }); + }, - static async visitUserPage(page: Page, username: string) { + async visitUserPage(page: Page, username: string) { // Click on user link in the table by name await page .getByRole("table") - .getByRole("link", { name: new RegExp(username, "i") }) + .getByRole("link", { name: new RegExp(username, "iu") }) .first() .click(); - } + }, - static getGroupLink(page: Page, groupName: string): Locator { - return page.getByRole("link", { name: new RegExp(groupName, "i") }); - } + getGroupLink(page: Page, groupName: string): Locator { + return page.getByRole("link", { name: new RegExp(groupName, "iu") }); + }, - static async visitBaseURL(page: Page) { - await page.goto(this.BASE_URL); - } -} + async visitBaseURL(page: Page) { + await page.goto(CatalogUsersPO.BASE_URL); + }, +}; diff --git a/e2e-tests/playwright/support/page-objects/page-obj.ts b/e2e-tests/playwright/support/page-objects/page-obj.ts index 9a409e271c..48f51e6aef 100644 --- a/e2e-tests/playwright/support/page-objects/page-obj.ts +++ b/e2e-tests/playwright/support/page-objects/page-obj.ts @@ -1,8 +1,10 @@ /* oxlint-disable playwright/no-raw-locators -- Legacy CSS selector constants; prefer SemanticSelectors get*() methods */ import { Page, Locator } from "@playwright/test"; - -import { getTranslations, getCurrentLanguage } from "../../e2e/localization/locale"; import { SemanticSelectors } from "../selectors/semantic-selectors"; +import { + getTranslations, + getCurrentLanguage, +} from "../../e2e/localization/locale"; const t = getTranslations(); const lang = getCurrentLanguage(); @@ -99,7 +101,7 @@ export const KUBERNETES_COMPONENTS = { * @example KUBERNETES_COMPONENTS.getClusterAccordion(page, 'production').click() */ getClusterAccordion: (page: Page, clusterName?: string | RegExp): Locator => { - if (clusterName) { + if (clusterName !== undefined) { return page .getByRole("button", { name: clusterName, expanded: false }) .or(page.getByRole("button", { name: clusterName, expanded: true })); @@ -122,7 +124,8 @@ export const KUBERNETES_COMPONENTS = { * Get pod logs label/button * @example KUBERNETES_COMPONENTS.getPodLogsButton(page).click() */ - getPodLogsButton: (page: Page): Locator => page.locator('label[aria-label="get logs"]'), + getPodLogsButton: (page: Page): Locator => + page.locator('label[aria-label="get logs"]'), /** * Get error/notification snackbar @@ -130,7 +133,9 @@ export const KUBERNETES_COMPONENTS = { * @example await expect(KUBERNETES_COMPONENTS.getNotification(page)).toContainText('Error') */ getNotification: (page: Page, message?: string | RegExp): Locator => - message ? SemanticSelectors.alert(page, message) : SemanticSelectors.alert(page), + message === undefined + ? SemanticSelectors.alert(page) + : SemanticSelectors.alert(page, message), }; /** @@ -154,7 +159,8 @@ export const BACKSTAGE_SHOWCASE_COMPONENTS = { * ✅ Already semantic, but wrapped for consistency * @example BACKSTAGE_SHOWCASE_COMPONENTS.getNextPageButton(page).click() */ - getNextPageButton: (page: Page): Locator => page.getByRole("button", { name: "Next Page" }), + getNextPageButton: (page: Page): Locator => + page.getByRole("button", { name: "Next Page" }), /** * Get previous page button @@ -167,20 +173,23 @@ export const BACKSTAGE_SHOWCASE_COMPONENTS = { * Get last page button * @example BACKSTAGE_SHOWCASE_COMPONENTS.getLastPageButton(page).click() */ - getLastPageButton: (page: Page): Locator => page.getByRole("button", { name: "Last Page" }), + getLastPageButton: (page: Page): Locator => + page.getByRole("button", { name: "Last Page" }), /** * Get first page button * @example BACKSTAGE_SHOWCASE_COMPONENTS.getFirstPageButton(page).click() */ - getFirstPageButton: (page: Page): Locator => page.getByRole("button", { name: "First Page" }), + getFirstPageButton: (page: Page): Locator => + page.getByRole("button", { name: "First Page" }), /** * Get table rows * ✅ Preferred over tableRows * @example const rows = BACKSTAGE_SHOWCASE_COMPONENTS.getTableRows(page) */ - getTableRows: (page: Page): Locator => SemanticSelectors.table(page).locator("tbody tr"), + getTableRows: (page: Page): Locator => + SemanticSelectors.table(page).locator("tbody tr"), /** * Get specific table row by content @@ -203,7 +212,8 @@ export const SETTINGS_PAGE_COMPONENTS = { * Get user settings menu button * @example SETTINGS_PAGE_COMPONENTS.getUserSettingsMenu(page).click() */ - getUserSettingsMenu: (page: Page): Locator => page.getByTestId("user-settings-menu"), + getUserSettingsMenu: (page: Page): Locator => + page.getByTestId("user-settings-menu"), /** * Get sign out menu item @@ -225,7 +235,8 @@ export const ROLES_PAGE_COMPONENTS = { * Get edit role button * @example ROLES_PAGE_COMPONENTS.getEditRoleButton(page, 'admin').click() */ - getEditRoleButton: (page: Page, name: string): Locator => page.getByTestId(`edit-role-${name}`), + getEditRoleButton: (page: Page, name: string): Locator => + page.getByTestId(`edit-role-${name}`), /** * Get delete role button @@ -247,7 +258,8 @@ export const DELETE_ROLE_COMPONENTS = { * Get role name confirmation input * @example DELETE_ROLE_COMPONENTS.getRoleNameInput(page).fill('role-name') */ - getRoleNameInput: (page: Page): Locator => page.locator('input[name="delete-role"]'), + getRoleNameInput: (page: Page): Locator => + page.locator('input[name="delete-role"]'), }; /** @@ -262,11 +274,13 @@ export const ROLE_OVERVIEW_COMPONENTS_TEST_ID = { * Get update policies button * @example ROLE_OVERVIEW_COMPONENTS_TEST_ID.getUpdatePoliciesButton(page).click() */ - getUpdatePoliciesButton: (page: Page): Locator => page.getByTestId("update-policies"), + getUpdatePoliciesButton: (page: Page): Locator => + page.getByTestId("update-policies"), /** * Get update members button * @example ROLE_OVERVIEW_COMPONENTS_TEST_ID.getUpdateMembersButton(page).click() */ - getUpdateMembersButton: (page: Page): Locator => page.getByTestId("update-members"), + getUpdateMembersButton: (page: Page): Locator => + page.getByTestId("update-members"), }; diff --git a/e2e-tests/playwright/support/page-objects/ui-locators.ts b/e2e-tests/playwright/support/page-objects/ui-locators.ts new file mode 100644 index 0000000000..4a5ab83f7f --- /dev/null +++ b/e2e-tests/playwright/support/page-objects/ui-locators.ts @@ -0,0 +1,30 @@ +/* oxlint-disable playwright/no-raw-locators -- legacy card/table region selectors pending SemanticSelectors migration */ +import { Locator, Page } from "@playwright/test"; +import { SemanticSelectors } from "../selectors/semantic-selectors"; + +export function getCardByHeading( + page: Page, + heading: string | RegExp, +): Locator { + return page + .locator('[role="region"], article, section') + .filter({ + has: page.getByRole("heading", { name: heading }), + }) + .first(); +} + +export function getCardByText(page: Page, text: string | RegExp): Locator { + return page + .locator('[role="region"], article, section') + .filter({ + hasText: text, + }) + .first(); +} + +export const getTableCell = (page: Page, text?: string | RegExp): Locator => + SemanticSelectors.tableCell(page, text); + +export const getTableRow = (page: Page, text?: string | RegExp): Locator => + SemanticSelectors.tableRow(page, text); diff --git a/e2e-tests/playwright/support/pages/backstage-showcase.ts b/e2e-tests/playwright/support/pages/backstage-showcase.ts new file mode 100644 index 0000000000..c4357eb2f8 --- /dev/null +++ b/e2e-tests/playwright/support/pages/backstage-showcase.ts @@ -0,0 +1,78 @@ +import { Page, expect } from "@playwright/test"; +import { UIhelper } from "../../utils/ui-helper"; +import { APIHelper } from "../../utils/api-helper"; +import { BACKSTAGE_SHOWCASE_COMPONENTS } from "../page-objects/page-obj"; + +export class BackstageShowcase { + private readonly page: Page; + private uiHelper: UIhelper; + + constructor(page: Page) { + this.page = page; + this.uiHelper = new UIhelper(page); + } + + static getShowcasePRs(state: "open" | "closed" | "all", paginated = false) { + return APIHelper.getGitHubPRs("redhat-developer", "rhdh", state, paginated); + } + + async clickNextPage() { + await BACKSTAGE_SHOWCASE_COMPONENTS.getNextPageButton(this.page).click(); + } + + async clickPreviousPage() { + await BACKSTAGE_SHOWCASE_COMPONENTS.getPreviousPageButton( + this.page, + ).click(); + } + + async clickLastPage() { + await BACKSTAGE_SHOWCASE_COMPONENTS.getLastPageButton(this.page).click(); + } + + async verifyPRRowsPerPage( + rows: number, + allPRs: { title: string; number: string }[], + ) { + await this.selectRowsPerPage(rows); + await this.uiHelper.verifyText(allPRs[rows - 1].title, false); + await this.uiHelper.verifyLink(allPRs[rows].number, { + exact: false, + notVisible: true, + }); + + const tableRows = BACKSTAGE_SHOWCASE_COMPONENTS.getTableRows(this.page); + await expect(tableRows).toHaveCount(rows); + } + + async selectRowsPerPage(rows: number) { + await this.page.getByRole("combobox").click(); + await this.page.getByRole("option", { name: String(rows) }).click(); + } + + async verifyPRStatisticsRendered() { + const regex = /Average Size Of PR\d+ lines/u; + await this.uiHelper.verifyText(regex); + } + + async verifyAboutCardIsDisplayed() { + const url = + "https://github.com/redhat-developer/rhdh/tree/main/catalog-entities/components/"; + const isLinkVisible = await this.page + .locator(`a[href="${url}"]`) + .isVisible(); + if (!isLinkVisible) { + throw new Error("About card is not displayed"); + } + } + + async verifyPRRows( + allPRs: { title: string }[], + startRow: number, + lastRow: number, + ) { + for (let i = startRow; i < lastRow; i++) { + await this.uiHelper.verifyRowsInTable([allPRs[i].title], false); + } + } +} diff --git a/e2e-tests/playwright/support/pages/catalog-import.ts b/e2e-tests/playwright/support/pages/catalog-import.ts index 2ab25067f7..e03742a346 100644 --- a/e2e-tests/playwright/support/pages/catalog-import.ts +++ b/e2e-tests/playwright/support/pages/catalog-import.ts @@ -1,9 +1,10 @@ import { Page, expect } from "@playwright/test"; - -import { getTranslations, getCurrentLanguage } from "../../e2e/localization/locale"; -import { APIHelper } from "../../utils/api-helper"; import { UIhelper } from "../../utils/ui-helper"; -import { BACKSTAGE_SHOWCASE_COMPONENTS, CATALOG_IMPORT_COMPONENTS } from "../page-objects/page-obj"; +import { CATALOG_IMPORT_COMPONENTS } from "../page-objects/page-obj"; +import { + getTranslations, + getCurrentLanguage, +} from "../../e2e/localization/locale"; const t = getTranslations(); const lang = getCurrentLanguage(); @@ -40,8 +41,10 @@ export class CatalogImport { * * @returns boolean indicating if the component is already registered */ - async isComponentAlreadyRegistered(): Promise { - return this.uiHelper.isBtnVisible(t["catalog-import"][lang]["stepReviewLocation.refresh"]); + isComponentAlreadyRegistered(): Promise { + return this.uiHelper.isBtnVisible( + t["catalog-import"][lang]["stepReviewLocation.refresh"], + ); } /** @@ -51,21 +54,31 @@ export class CatalogImport { * @param url - The component URL to register * @param clickViewComponent - Whether to click "View Component" after import */ - async registerExistingComponent(url: string, clickViewComponent: boolean = true) { + async registerExistingComponent( + url: string, + clickViewComponent: boolean = true, + ) { await this.analyzeAndWait(url); - const isComponentAlreadyRegistered = await this.isComponentAlreadyRegistered(); + const isComponentAlreadyRegistered = + await this.isComponentAlreadyRegistered(); if (isComponentAlreadyRegistered) { - await this.uiHelper.clickButton(t["catalog-import"][lang]["stepReviewLocation.refresh"]); + await this.uiHelper.clickButton( + t["catalog-import"][lang]["stepReviewLocation.refresh"], + ); expect( await this.uiHelper.isBtnVisible( t["catalog-import"][lang]["stepFinishImportLocation.backButtonText"], ), ).toBeTruthy(); } else { - await this.uiHelper.clickButton(t["catalog-import"][lang]["stepReviewLocation.import"]); + await this.uiHelper.clickButton( + t["catalog-import"][lang]["stepReviewLocation.import"], + ); if (clickViewComponent) { await this.uiHelper.clickButton( - t["catalog-import"][lang]["stepFinishImportLocation.locations.viewButtonText"], + t["catalog-import"][lang][ + "stepFinishImportLocation.locations.viewButtonText" + ], ); } } @@ -74,7 +87,9 @@ export class CatalogImport { async analyzeComponent(url: string) { await this.page.fill(CATALOG_IMPORT_COMPONENTS.componentURL, url); - await this.uiHelper.clickButton(t["catalog-import"][lang]["stepInitAnalyzeUrl.nextButtonText"]); + await this.uiHelper.clickButton( + t["catalog-import"][lang]["stepInitAnalyzeUrl.nextButtonText"], + ); } async inspectEntityAndVerifyYaml(text: string) { @@ -86,64 +101,4 @@ export class CatalogImport { } } -export class BackstageShowcase { - private readonly page: Page; - private uiHelper: UIhelper; - - constructor(page: Page) { - this.page = page; - this.uiHelper = new UIhelper(page); - } - - static async getShowcasePRs(state: "open" | "closed" | "all", paginated = false) { - return APIHelper.getGitHubPRs("redhat-developer", "rhdh", state, paginated); - } - - async clickNextPage() { - await this.page.click(BACKSTAGE_SHOWCASE_COMPONENTS.tableNextPage); - } - - async clickPreviousPage() { - await this.page.click(BACKSTAGE_SHOWCASE_COMPONENTS.tablePreviousPage); - } - - async clickLastPage() { - await this.page.click(BACKSTAGE_SHOWCASE_COMPONENTS.tableLastPage); - } - - async verifyPRRowsPerPage(rows: number, allPRs: { title: string; number: string }[]) { - await this.selectRowsPerPage(rows); - await this.uiHelper.verifyText(allPRs[rows - 1].title, false); - await this.uiHelper.verifyLink(allPRs[rows].number, { - exact: false, - notVisible: true, - }); - - const tableRows = this.page.locator(BACKSTAGE_SHOWCASE_COMPONENTS.tableRows); - await expect(tableRows).toHaveCount(rows); - } - - async selectRowsPerPage(rows: number) { - await this.page.click(BACKSTAGE_SHOWCASE_COMPONENTS.tablePageSelectBox); - await this.page.click(`ul[role="listbox"] li[data-value="${rows}"]`); - } - - async verifyPRStatisticsRendered() { - const regex = /Average Size Of PR\d+ lines/; - await this.uiHelper.verifyText(regex); - } - - async verifyAboutCardIsDisplayed() { - const url = "https://github.com/redhat-developer/rhdh/tree/main/catalog-entities/components/"; - const isLinkVisible = await this.page.locator(`a[href="${url}"]`).isVisible(); - if (!isLinkVisible) { - throw new Error("About card is not displayed"); - } - } - - async verifyPRRows(allPRs: { title: string }[], startRow: number, lastRow: number) { - for (let i = startRow; i < lastRow; i++) { - await this.uiHelper.verifyRowsInTable([allPRs[i].title], false); - } - } -} +export { BackstageShowcase } from "./backstage-showcase"; diff --git a/e2e-tests/playwright/support/pages/catalog.ts b/e2e-tests/playwright/support/pages/catalog.ts index 910d93b8bd..a8a0a5a381 100644 --- a/e2e-tests/playwright/support/pages/catalog.ts +++ b/e2e-tests/playwright/support/pages/catalog.ts @@ -33,13 +33,13 @@ export class Catalog { await this.searchField.clear(); const baseURL = playwrightConfig.use?.baseURL ?? ""; const searchResponse = this.page.waitForResponse( - new RegExp(`${baseURL}/api/catalog/entities/by-query/*`), + new RegExp(`${baseURL}/api/catalog/entities/by-query/*`, "u"), ); await this.searchField.fill(s); await searchResponse; } - async tableRow(content: string) { + tableRow(content: string) { return this.page.locator(`tr >> a >> text="${content}"`); } } diff --git a/e2e-tests/playwright/support/pages/home-page.ts b/e2e-tests/playwright/support/pages/home-page.ts index 51ca08b8d6..21339320e7 100644 --- a/e2e-tests/playwright/support/pages/home-page.ts +++ b/e2e-tests/playwright/support/pages/home-page.ts @@ -1,8 +1,10 @@ -import { Page, expect } from "@playwright/test"; - -import { UIhelper } from "../../utils/ui-helper"; /* oxlint-disable playwright/no-raw-locators -- MUI home page layout selectors */ -import { HOME_PAGE_COMPONENTS, SEARCH_OBJECTS_COMPONENTS } from "../page-objects/page-obj"; +import { + HOME_PAGE_COMPONENTS, + SEARCH_OBJECTS_COMPONENTS, +} from "../page-objects/page-obj"; +import { UIhelper } from "../../utils/ui-helper"; +import { Page, expect } from "@playwright/test"; export class HomePage { private page: Page; @@ -13,25 +15,29 @@ export class HomePage { this.uiHelper = new UIhelper(page); } async verifyQuickSearchBar(text: string) { - const searchBar = this.page.locator(SEARCH_OBJECTS_COMPONENTS.ariaLabelSearch); + const searchBar = SEARCH_OBJECTS_COMPONENTS.getSearchInput(this.page); await searchBar.waitFor(); await searchBar.fill(""); - await searchBar.type(text + "\n"); // '\n' simulates pressing the Enter key + await searchBar.pressSequentially(`${text}\n`); await this.uiHelper.verifyLink(text); } - async verifyQuickAccess(section: string, items: string | string[], expand = false) { - await this.page.waitForSelector(HOME_PAGE_COMPONENTS.MuiAccordion, { - state: "visible", - }); - - const sectionLocator = this.page - .locator(HOME_PAGE_COMPONENTS.MuiAccordion) - .filter({ hasText: section }); + async verifyQuickAccess( + section: string, + items: string | string[], + expand = false, + ) { + const sectionLocator = HOME_PAGE_COMPONENTS.getAccordion( + this.page, + section, + ); + await expect(sectionLocator).toBeVisible(); if (expand) { await sectionLocator.click(); - await expect(sectionLocator.locator('[class*="MuiAccordionDetails-root"]')).toBeVisible(); + await expect( + sectionLocator.locator('[class*="MuiAccordionDetails-root"]'), + ).toBeVisible(); } for (const item of Array.isArray(items) ? items : [items]) { @@ -44,13 +50,8 @@ export class HomePage { } async verifyVisitedCardContent(section: string) { - await this.page.waitForSelector(HOME_PAGE_COMPONENTS.MuiCard, { - state: "visible", - }); - - const sectionLocator = this.page - .locator(HOME_PAGE_COMPONENTS.MuiCard) - .filter({ hasText: section }); + const sectionLocator = HOME_PAGE_COMPONENTS.getCard(this.page, section); + await expect(sectionLocator).toBeVisible(); const itemLocator = sectionLocator.locator(`li[class*="MuiListItem-root"]`); expect(await itemLocator.count()).toBeGreaterThanOrEqual(0); diff --git a/e2e-tests/playwright/support/pages/rbac.ts b/e2e-tests/playwright/support/pages/rbac.ts index 5f5a7cdf50..75ebada39b 100644 --- a/e2e-tests/playwright/support/pages/rbac.ts +++ b/e2e-tests/playwright/support/pages/rbac.ts @@ -1,5 +1,4 @@ import { APIResponse, Page, expect } from "@playwright/test"; - import { UIhelper } from "../../utils/ui-helper"; import { Policy, Role } from "../api/rbac-api-structures"; @@ -12,30 +11,34 @@ export class Roles { this.uiHelper = new UIhelper(page); } static getRolesListCellsIdentifier() { - const roleName = new RegExp(/^(role|user|group):[a-zA-Z]+\/[\w@*.~-]+$/); - const usersAndGroups = new RegExp( - /^(1\s(user|group)|[2-9]\s(users|groups))(, (1\s(user|group)|[2-9]\s(users|groups)))?$/, - ); - const permissionPolicies = /\d/; + const roleName = /^(role|user|group):[a-zA-Z]+\/[\w@*.~-]+$/u; + const usersAndGroups = + /^(1\s(user|group)|[2-9]\s(users|groups))(, (1\s(user|group)|[2-9]\s(users|groups)))?$/u; + const permissionPolicies = /\d/u; return [roleName, usersAndGroups, permissionPolicies]; } static getUsersAndGroupsListCellsIdentifier() { - const name = new RegExp(/^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/); - const type = new RegExp(/^(User|Group)$/); - const members = /^(-|\d+)$/; + const name = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/u; + const type = /^(User|Group)$/u; + const members = /^(-|\d+)$/u; return [name, type, members]; } static getPermissionPoliciesListCellsIdentifier() { - const policies = /^(?:(Read|Create|Update|Delete)(?:, (?:Read|Create|Update|Delete))*|Use)$/; + const policies = + /^(?:(Read|Create|Update|Delete)(?:, (?:Read|Create|Update|Delete))*|Use)$/u; return [policies]; } //Depending on the version of the Backstage, it can be 'Permission Policies' or 'Accessible Plugins' // Accepts either term static getRolesListColumnsText() { - return [/^Name$/, /^Users and groups$/, /Permission Policies|Accessible plugins/]; + return [ + /^Name$/u, + /^Users and groups$/u, + /Permission Policies|Accessible plugins/u, + ]; } static getUsersAndGroupsListColumnsText() { @@ -47,12 +50,16 @@ export class Roles { } } -export async function removeMetadataFromResponse(response: APIResponse): Promise { +export async function removeMetadataFromResponse( + response: APIResponse, +): Promise { try { const responseJson: unknown = await response.json(); if (!Array.isArray(responseJson)) { - console.warn(`Expected an array but received: ${JSON.stringify(responseJson)}`); + console.warn( + `Expected an array but received: ${JSON.stringify(responseJson)}`, + ); return []; } @@ -70,7 +77,10 @@ export async function removeMetadataFromResponse(response: APIResponse): Promise } } -export async function checkRbacResponse(response: APIResponse, expected: Role[] | Policy[]) { +export async function checkRbacResponse( + response: APIResponse, + expected: Role[] | Policy[], +) { const cleanResponse = await removeMetadataFromResponse(response); expect(cleanResponse).toEqual(expected); } diff --git a/e2e-tests/playwright/support/selectors/semantic-selectors-accessibility.ts b/e2e-tests/playwright/support/selectors/semantic-selectors-accessibility.ts new file mode 100644 index 0000000000..788247a0b2 --- /dev/null +++ b/e2e-tests/playwright/support/selectors/semantic-selectors-accessibility.ts @@ -0,0 +1,58 @@ +import { Page, Locator } from "@playwright/test"; + +export const semanticSelectorsAccessibility = { + button(page: Page, name: string | RegExp): Locator { + return page.getByRole("button", { name }); + }, + + link(page: Page, name: string | RegExp): Locator { + return page.getByRole("link", { name }); + }, + + table(page: Page): Locator { + return page.getByRole("table"); + }, + + tableCell(page: Page, text?: string | RegExp): Locator { + if (text === undefined) { + return page.getByRole("cell"); + } + return page.getByRole("cell", { name: text }); + }, + + tableHeader(page: Page, name: string | RegExp): Locator { + return page.getByRole("columnheader", { name }); + }, + + tableRow(page: Page, text?: string | RegExp): Locator { + const rows = page.getByRole("row"); + if (text === undefined) { + return rows; + } + return rows.filter({ hasText: text }); + }, + + heading( + page: Page, + name: string | RegExp, + level?: 1 | 2 | 3 | 4 | 5 | 6, + ): Locator { + return page.getByRole("heading", { name, level }); + }, + + inputByLabel(page: Page, label: string | RegExp): Locator { + return page.getByLabel(label); + }, + + inputByPlaceholder(page: Page, placeholder: string | RegExp): Locator { + return page.getByPlaceholder(placeholder); + }, + + checkbox(page: Page, label: string | RegExp): Locator { + return page.getByRole("checkbox", { name: label }); + }, + + radio(page: Page, label: string | RegExp): Locator { + return page.getByRole("radio", { name: label }); + }, +}; diff --git a/e2e-tests/playwright/support/selectors/semantic-selectors-structure.ts b/e2e-tests/playwright/support/selectors/semantic-selectors-structure.ts new file mode 100644 index 0000000000..db7411cdb8 --- /dev/null +++ b/e2e-tests/playwright/support/selectors/semantic-selectors-structure.ts @@ -0,0 +1,90 @@ +import { Locator, Page } from "@playwright/test"; + +export const semanticSelectorsStructure = { + dialog(page: Page, name?: string | RegExp): Locator { + return name === undefined + ? page.getByRole("dialog") + : page.getByRole("dialog", { name }); + }, + + navigation(page: Page, name?: string | RegExp): Locator { + return name === undefined + ? page.getByRole("navigation") + : page.getByRole("navigation", { name }); + }, + + banner(page: Page): Locator { + return page.getByRole("banner"); + }, + + main(page: Page): Locator { + return page.getByRole("main"); + }, + + tab(page: Page, name: string | RegExp): Locator { + return page.getByRole("tab", { name }); + }, + + menuItem(page: Page, name: string | RegExp): Locator { + return page.getByRole("menuitem", { name }); + }, + + list(page: Page, name?: string | RegExp): Locator { + return name === undefined + ? page.getByRole("list") + : page.getByRole("list", { name }); + }, + + listItem(page: Page, text?: string | RegExp): Locator { + const items = page.getByRole("listitem"); + return text === undefined ? items : items.filter({ hasText: text }); + }, + + article(page: Page): Locator { + return page.getByRole("article"); + }, + + region(page: Page, name?: string | RegExp): Locator { + return name === undefined + ? page.getByRole("region") + : page.getByRole("region", { name }); + }, + + alert(page: Page, name?: string | RegExp): Locator { + return name === undefined + ? page.getByRole("alert") + : page.getByRole("alert", { name }); + }, + + testId(page: Page, testId: string): Locator { + return page.getByTestId(testId); + }, + + image(page: Page, altText: string | RegExp): Locator { + return page.getByAltText(altText); + }, + + title(page: Page, title: string | RegExp): Locator { + return page.getByTitle(title); + }, + + scopedByRole( + container: Locator, + role: + | "button" + | "link" + | "heading" + | "textbox" + | "cell" + | "row" + | "columnheader" + | "tab" + | "menuitem" + | "listitem", + name?: string | RegExp, + ): Locator { + return name === undefined + ? container.getByRole(role) + : container.getByRole(role, { name }); + }, +}; diff --git a/e2e-tests/playwright/support/selectors/semantic-selectors-table-helpers.ts b/e2e-tests/playwright/support/selectors/semantic-selectors-table-helpers.ts new file mode 100644 index 0000000000..5415aa79fb --- /dev/null +++ b/e2e-tests/playwright/support/selectors/semantic-selectors-table-helpers.ts @@ -0,0 +1,23 @@ +import { Page, Locator } from "@playwright/test"; +import { semanticSelectorsAccessibility } from "./semantic-selectors-accessibility"; + +export function findTableCell( + page: Page, + rowText: string | RegExp, + cellIndex: number, +): Locator { + const row = semanticSelectorsAccessibility.tableRow(page, rowText); + return row.getByRole("cell").nth(cellIndex); +} + +export async function findTableCellByColumn( + page: Page, + rowText: string | RegExp, + columnName: string | RegExp, +): Promise { + const header = semanticSelectorsAccessibility.tableHeader(page, columnName); + const columnIndex = await header.evaluate( + (th: HTMLTableCellElement) => th.cellIndex, + ); + return findTableCell(page, rowText, columnIndex); +} diff --git a/e2e-tests/playwright/support/selectors/semantic-selectors.ts b/e2e-tests/playwright/support/selectors/semantic-selectors.ts index 113c6e6963..f8831d7dd8 100644 --- a/e2e-tests/playwright/support/selectors/semantic-selectors.ts +++ b/e2e-tests/playwright/support/selectors/semantic-selectors.ts @@ -1,10 +1,10 @@ -/* oxlint-disable typescript/no-extraneous-class -- grouped semantic locator helpers */ -import { Page, Locator } from "@playwright/test"; +import { semanticSelectorsAccessibility } from "./semantic-selectors-accessibility"; +import { semanticSelectorsStructure } from "./semantic-selectors-structure"; /** * Semantic Selectors - Playwright Best Practices * - * This class provides semantic locator methods following Playwright best practices. + * This object provides semantic locator methods following Playwright best practices. * Prefer these methods over CSS class selectors for more stable and maintainable tests. * * Priority Order: @@ -16,444 +16,13 @@ import { Page, Locator } from "@playwright/test"; * @see https://playwright.dev/docs/locators * @see .cursor/rules/playwright-locators.mdc */ -export class SemanticSelectors { - /** - * Get a button by its accessible name - * @param page - Playwright Page object - * @param name - Button text or accessible name (supports regex) - * @returns Locator for the button - * - * @example - * await SemanticSelectors.button(page, 'Submit').click(); - * await SemanticSelectors.button(page, /save/i).click(); - */ - static button(page: Page, name: string | RegExp): Locator { - return page.getByRole("button", { name }); - } - - /** - * Get a link by its accessible name - * @param page - Playwright Page object - * @param name - Link text or accessible name (supports regex) - * @returns Locator for the link - * - * @example - * await SemanticSelectors.link(page, 'View Details').click(); - */ - static link(page: Page, name: string | RegExp): Locator { - return page.getByRole("link", { name }); - } - - /** - * Get a table element - * @param page - Playwright Page object - * @returns Locator for the table - * - * @example - * const table = SemanticSelectors.table(page); - * const rows = table.getByRole('row'); - */ - static table(page: Page): Locator { - return page.getByRole("table"); - } - - /** - * Get a table cell by its accessible name or content - * @param page - Playwright Page object - * @param text - Cell text content (optional, supports regex) - * @returns Locator for the cell(s) - * - * @example - * await expect(SemanticSelectors.tableCell(page, 'Active')).toBeVisible(); - */ - static tableCell(page: Page, text?: string | RegExp): Locator { - return text ? page.getByRole("cell", { name: text }) : page.getByRole("cell"); - } - - /** - * Get a table column header - * @param page - Playwright Page object - * @param name - Column header text - * @returns Locator for the column header - * - * @example - * await SemanticSelectors.tableHeader(page, 'Created At').click(); - */ - static tableHeader(page: Page, name: string | RegExp): Locator { - return page.getByRole("columnheader", { name }); - } - - /** - * Get a table row by content - * @param page - Playwright Page object - * @param text - Row content to filter by (optional) - * @returns Locator for the row(s) - * - * @example - * const row = SemanticSelectors.tableRow(page, 'Guest User'); - * await row.getByRole('button', { name: 'Edit' }).click(); - */ - static tableRow(page: Page, text?: string | RegExp): Locator { - const rows = page.getByRole("row"); - return text ? rows.filter({ hasText: text }) : rows; - } - - /** - * Get a heading by level and text - * @param page - Playwright Page object - * @param name - Heading text (supports regex) - * @param level - Heading level (1-6, optional) - * @returns Locator for the heading - * - * @example - * await expect(SemanticSelectors.heading(page, 'RBAC', 1)).toBeVisible(); - * await expect(SemanticSelectors.heading(page, /settings/i)).toBeVisible(); - */ - static heading(page: Page, name: string | RegExp, level?: 1 | 2 | 3 | 4 | 5 | 6): Locator { - return page.getByRole("heading", { name, level }); - } - - /** - * Get a text input by label - * @param page - Playwright Page object - * @param label - Input label text - * @returns Locator for the input - * - * @example - * await SemanticSelectors.inputByLabel(page, 'Email address').fill('user@example.com'); - */ - static inputByLabel(page: Page, label: string | RegExp): Locator { - return page.getByLabel(label); - } - - /** - * Get an input by placeholder - * @param page - Playwright Page object - * @param placeholder - Placeholder text - * @returns Locator for the input - * - * @example - * await SemanticSelectors.inputByPlaceholder(page, 'Search...').fill('test'); - */ - static inputByPlaceholder(page: Page, placeholder: string | RegExp): Locator { - return page.getByPlaceholder(placeholder); - } - - /** - * Get a checkbox by label - * @param page - Playwright Page object - * @param label - Checkbox label text - * @returns Locator for the checkbox - * - * @example - * await SemanticSelectors.checkbox(page, 'Accept terms').check(); - */ - static checkbox(page: Page, label: string | RegExp): Locator { - return page.getByRole("checkbox", { name: label }); - } - - /** - * Get a radio button by label - * @param page - Playwright Page object - * @param label - Radio button label text - * @returns Locator for the radio button - * - * @example - * await SemanticSelectors.radio(page, 'Option A').check(); - */ - static radio(page: Page, label: string | RegExp): Locator { - return page.getByRole("radio", { name: label }); - } - - /** - * Get a dialog/modal - * @param page - Playwright Page object - * @param name - Dialog name/title (optional) - * @returns Locator for the dialog - * - * @example - * const dialog = SemanticSelectors.dialog(page, 'Confirm Delete'); - * await dialog.getByRole('button', { name: 'OK' }).click(); - */ - static dialog(page: Page, name?: string | RegExp): Locator { - return name ? page.getByRole("dialog", { name }) : page.getByRole("dialog"); - } - - /** - * Get a navigation element - * @param page - Playwright Page object - * @param name - Navigation name (optional) - * @returns Locator for the navigation - * - * @example - * const nav = SemanticSelectors.navigation(page); - * await nav.getByRole('link', { name: 'Home' }).click(); - */ - static navigation(page: Page, name?: string | RegExp): Locator { - return name ? page.getByRole("navigation", { name }) : page.getByRole("navigation"); - } - - /** - * Get a banner (header) element - * @param page - Playwright Page object - * @returns Locator for the banner - * - * @example - * const header = SemanticSelectors.banner(page); - * await expect(header).toContainText('Welcome'); - */ - static banner(page: Page): Locator { - return page.getByRole("banner"); - } - - /** - * Get a main content area - * @param page - Playwright Page object - * @returns Locator for the main content - * - * @example - * const main = SemanticSelectors.main(page); - * await expect(main.getByRole('heading')).toBeVisible(); - */ - static main(page: Page): Locator { - return page.getByRole("main"); - } - - /** - * Get a tab by name - * @param page - Playwright Page object - * @param name - Tab name/text - * @returns Locator for the tab - * - * @example - * await SemanticSelectors.tab(page, 'Settings').click(); - */ - static tab(page: Page, name: string | RegExp): Locator { - return page.getByRole("tab", { name }); - } - - /** - * Get a menu item - * @param page - Playwright Page object - * @param name - Menu item text - * @returns Locator for the menu item - * - * @example - * await SemanticSelectors.menuItem(page, 'Delete').click(); - */ - static menuItem(page: Page, name: string | RegExp): Locator { - return page.getByRole("menuitem", { name }); - } - - /** - * Get a list (ul/ol) element - * @param page - Playwright Page object - * @param name - List accessible name (optional) - * @returns Locator for the list - * - * @example - * const list = SemanticSelectors.list(page); - * const items = list.getByRole('listitem'); - */ - static list(page: Page, name?: string | RegExp): Locator { - return name ? page.getByRole("list", { name }) : page.getByRole("list"); - } - - /** - * Get a list item - * @param page - Playwright Page object - * @param text - List item content to filter by (optional) - * @returns Locator for the list item(s) - * - * @example - * await SemanticSelectors.listItem(page, 'Product 1').click(); - */ - static listItem(page: Page, text?: string | RegExp): Locator { - const items = page.getByRole("listitem"); - return text ? items.filter({ hasText: text }) : items; - } - - /** - * Get an article element - * @param page - Playwright Page object - * @returns Locator for the article - * - * @example - * const article = SemanticSelectors.article(page); - * await expect(article.getByRole('link')).toBeVisible(); - */ - static article(page: Page): Locator { - return page.getByRole("article"); - } - - /** - * Get a region (section with accessible name) - * @param page - Playwright Page object - * @param name - Region accessible name (optional) - * @returns Locator for the region - * - * @example - * const sidebar = SemanticSelectors.region(page, 'Sidebar'); - * await sidebar.getByRole('button', { name: 'Filter' }).click(); - */ - static region(page: Page, name?: string | RegExp): Locator { - return name ? page.getByRole("region", { name }) : page.getByRole("region"); - } - - /** - * Get an alert element - * @param page - Playwright Page object - * @param name - Alert text/name (optional) - * @returns Locator for the alert - * - * @example - * await expect(SemanticSelectors.alert(page, 'Error')).toBeVisible(); - */ - static alert(page: Page, name?: string | RegExp): Locator { - return name ? page.getByRole("alert", { name }) : page.getByRole("alert"); - } - - /** - * Get an element by test ID (fallback when semantic selectors not available) - * @param page - Playwright Page object - * @param testId - data-testid attribute value - * @returns Locator for the element - * - * @example - * await SemanticSelectors.testId(page, 'custom-component').click(); - */ - static testId(page: Page, testId: string): Locator { - return page.getByTestId(testId); - } - - /** - * Get an element by alt text (for images) - * @param page - Playwright Page object - * @param altText - Image alt text - * @returns Locator for the image - * - * @example - * await expect(SemanticSelectors.image(page, 'Logo')).toBeVisible(); - */ - static image(page: Page, altText: string | RegExp): Locator { - return page.getByAltText(altText); - } - - /** - * Get an element by title attribute - * @param page - Playwright Page object - * @param title - Title attribute value - * @returns Locator for the element - * - * @example - * await SemanticSelectors.title(page, 'Close').click(); - */ - static title(page: Page, title: string | RegExp): Locator { - return page.getByTitle(title); - } - - /** - * Scope a locator to a specific container using semantic selector - * @param container - Parent locator to scope within - * @param role - Role of the element to find - * @param name - Accessible name (optional) - * @returns Scoped locator - * - * @example - * const dialog = page.getByRole('dialog'); - * const button = SemanticSelectors.scopedByRole(dialog, 'button', 'OK'); - * await button.click(); - */ - static scopedByRole( - container: Locator, - role: - | "button" - | "link" - | "heading" - | "textbox" - | "cell" - | "row" - | "columnheader" - | "tab" - | "menuitem" - | "listitem", - name?: string | RegExp, - ): Locator { - return name ? container.getByRole(role, { name }) : container.getByRole(role); - } -} - -/** - * Helper to find table row by unique text and get specific cell - * @param page - Playwright Page object - * @param rowText - Unique text to identify the row - * @param cellIndex - Index of the cell to get (0-based) - * @returns Locator for the specific cell in the row - * - * @example - * const createdAtCell = findTableCell(page, 'timestamp-test', 7); - * await expect(createdAtCell).toHaveText(/\d{1,2}\/\d{1,2}\/\d{4}/); - */ -export function findTableCell(page: Page, rowText: string | RegExp, cellIndex: number): Locator { - const row = SemanticSelectors.tableRow(page, rowText); - return row.getByRole("cell").nth(cellIndex); -} - -/** - * Helper to find table cell by column header name - * @param page - Playwright Page object - * @param rowText - Unique text to identify the row - * @param columnName - Column header name - * @returns Locator for the cell - * - * @example - * const statusCell = await findTableCellByColumn(page, 'Guest User', 'Status'); - * await expect(statusCell).toHaveText('Active'); - */ -export async function findTableCellByColumn( - page: Page, - rowText: string | RegExp, - columnName: string | RegExp, -): Promise { - const header = SemanticSelectors.tableHeader(page, columnName); - const columnIndex = await header.evaluate((th: HTMLTableCellElement) => th.cellIndex); - return findTableCell(page, rowText, columnIndex); -} - -/** - * Wait strategies - Prefer these over waitForTimeout - * - * Note: For element visibility/hidden states, prefer using expect() assertions: - * - await expect(locator).toBeVisible() - Auto-waits for visibility - * - await expect(locator).toBeHidden() - Auto-waits for hidden state - * - * These methods are only for specialized waiting scenarios. - * - * ⚠️ networkidle removed: Not recommended by Playwright as it doesn't wait for - * requests triggered after load and can give false positives with polling. - * Use forAPIResponse() or expect() assertions instead. - */ -export class WaitStrategies { - /** - * Wait for DOM content to be loaded - */ - static async forDOMContentLoaded(page: Page): Promise { - await page.waitForLoadState("domcontentloaded"); - } - - /** - * Wait for specific API response - */ - static async forAPIResponse( - page: Page, - urlPattern: string | RegExp, - statusCode: number = 200, - ): Promise { - await page.waitForResponse((response) => { - const url = response.url(); - const matchesUrl = - typeof urlPattern === "string" ? url.includes(urlPattern) : urlPattern.test(url); - return matchesUrl && response.status() === statusCode; - }); - } -} +export const SemanticSelectors = { + ...semanticSelectorsAccessibility, + ...semanticSelectorsStructure, +}; + +export { + findTableCell, + findTableCellByColumn, +} from "./semantic-selectors-table-helpers"; +export { WaitStrategies } from "./wait-strategies"; diff --git a/e2e-tests/playwright/support/selectors/wait-strategies.ts b/e2e-tests/playwright/support/selectors/wait-strategies.ts new file mode 100644 index 0000000000..8ae8ac41e7 --- /dev/null +++ b/e2e-tests/playwright/support/selectors/wait-strategies.ts @@ -0,0 +1,22 @@ +import { Page } from "@playwright/test"; + +export const WaitStrategies = { + async forDOMContentLoaded(page: Page): Promise { + await page.waitForLoadState("domcontentloaded"); + }, + + async forAPIResponse( + page: Page, + urlPattern: string | RegExp, + statusCode: number = 200, + ): Promise { + await page.waitForResponse((response) => { + const url = response.url(); + const matchesUrl = + typeof urlPattern === "string" + ? url.includes(urlPattern) + : urlPattern.test(url); + return matchesUrl && response.status() === statusCode; + }); + }, +}; diff --git a/e2e-tests/playwright/utils/analytics/analytics.ts b/e2e-tests/playwright/utils/analytics/analytics.ts index 7ce11d4c7a..9a4a85a6c6 100644 --- a/e2e-tests/playwright/utils/analytics/analytics.ts +++ b/e2e-tests/playwright/utils/analytics/analytics.ts @@ -13,7 +13,7 @@ export class Analytics { expect(response.status()).toBe(200); const body: unknown = await response.json(); if (!Array.isArray(body)) { - throw new Error("Expected loaded plugins response to be an array"); + throw new TypeError("Expected loaded plugins response to be an array"); } plugins = body.filter( (item): item is { name: string } => diff --git a/e2e-tests/playwright/utils/api-helper-catalog.ts b/e2e-tests/playwright/utils/api-helper-catalog.ts new file mode 100644 index 0000000000..ddd9cd38dc --- /dev/null +++ b/e2e-tests/playwright/utils/api-helper-catalog.ts @@ -0,0 +1,98 @@ +import { request } from "@playwright/test"; +import { + type CatalogLocationEntry, + isCatalogLocationEntry, + isEntityMetadataResponse, + parseJsonResponse, +} from "./api-helper-guards"; + +export async function getEntityUidByName( + name: string, +): Promise { + const baseUrl = process.env.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: unknown = await parseJsonResponse(response); + if (!isEntityMetadataResponse(data)) { + return undefined; + } + return data.metadata?.uid; +} + +export async function deleteLocationByUid(uid: string): Promise { + const baseUrl = process.env.BASE_URL; + const url = `${baseUrl}/api/catalog/locations/${uid}`; + const context = await request.newContext(); + const response = await context.delete(url); + return response.status(); +} + +export async function getTemplateEntityUidByName( + name: string, + namespace: string = "default", +): Promise { + const baseUrl = process.env.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: unknown = await parseJsonResponse(response); + if (!isEntityMetadataResponse(data)) { + return undefined; + } + return data.metadata?.uid; + } + if (response.status() === 404) { + return undefined; + } + return undefined; +} + +export async function deleteEntityLocationById(id: string): Promise { + const baseUrl = process.env.BASE_URL; + const url = `${baseUrl}/api/catalog/locations/${id}`; + const context = await request.newContext(); + const response = await context.delete(url); + return response.status(); +} + +export async function registerLocation(target: string): Promise { + const baseUrl = process.env.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(); +} + +export async function getLocationIdByTarget( + target: string, +): Promise { + const baseUrl = process.env.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: unknown = await parseJsonResponse(response); + if (!Array.isArray(data)) { + return undefined; + } + const location = data.find( + (entry): entry is CatalogLocationEntry => + isCatalogLocationEntry(entry) && entry.data?.target === target, + ); + return location?.data?.id; +} diff --git a/e2e-tests/playwright/utils/api-helper-github.ts b/e2e-tests/playwright/utils/api-helper-github.ts new file mode 100644 index 0000000000..4006011bad --- /dev/null +++ b/e2e-tests/playwright/utils/api-helper-github.ts @@ -0,0 +1,189 @@ +import { request, type APIResponse, expect } from "@playwright/test"; +import { GITHUB_API_ENDPOINTS } from "./api-endpoints"; +import { + type GitHubPullRequestFile, + isGitHubPullRequestFile, + parseJsonResponse, + toUnknownArray, +} from "./api-helper-guards"; + +type FetchOptions = { + method: string; + headers: { + Accept: string; + Authorization: string; + "X-GitHub-Api-Version": string; + }; + data?: string | object; +}; + +const githubAPIVersion = "2022-11-28"; + +export async function githubRequest( + method: string, + url: string, + body?: string | object, +): Promise { + const context = await request.newContext(); + const options: FetchOptions = { + method: method, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${process.env.GH_RHDH_QE_USER_TOKEN}`, + "X-GitHub-Api-Version": githubAPIVersion, + }, + }; + + if (body !== undefined) { + options.data = body; + } + + const response = await context.fetch(url, options); + return response; +} + +async function getGithubPaginatedRequest( + url: string, + pageNo = 1, + response: unknown[] = [], +): Promise { + const fullUrl = `${url}&page=${pageNo}`; + const result = await githubRequest("GET", fullUrl); + const body: unknown = await result.json(); + const pageItems = toUnknownArray(body); + + if (pageItems.length === 0) { + return response; + } + + response = response.concat(pageItems); + return getGithubPaginatedRequest(url, pageNo + 1, response); +} + +export { getGithubPaginatedRequest }; + +export async function createGitHubRepo(owner: string, repoName: string) { + const response = await githubRequest( + "POST", + GITHUB_API_ENDPOINTS.createRepo(owner), + { + name: repoName, + private: false, + }, + ); + expect(response.status() === 201 || response.ok()).toBeTruthy(); +} + +export async function createFileInRepo( + owner: string, + repoName: string, + filePath: string, + content: string, + commitMessage: string, + branch = "main", +) { + const encodedContent = Buffer.from(content).toString("base64"); + const response = await githubRequest( + "PUT", + `${GITHUB_API_ENDPOINTS.contents(owner, repoName)}/${filePath}`, + { + message: commitMessage, + content: encodedContent, + branch: branch, + }, + ); + expect(response.status() === 201 || response.ok()).toBeTruthy(); +} + +export async function createGitHubRepoWithFile( + owner: string, + repoName: string, + filename: string, + fileContent: string, +) { + await createGitHubRepo(owner, repoName); + await createFileInRepo( + owner, + repoName, + filename, + fileContent, + `Add ${filename} file`, + ); +} + +export async function initCommit(owner: string, repo: string, branch = "main") { + const content = Buffer.from( + "This is the initial commit for the repository.", + ).toString("base64"); + const response = await 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(); +} + +export async function deleteGitHubRepo(owner: string, repoName: string) { + await githubRequest( + "DELETE", + GITHUB_API_ENDPOINTS.deleteRepo(owner, repoName), + ); +} + +export async function mergeGitHubPR( + owner: string, + repoName: string, + pullNumber: number, +) { + await githubRequest( + "PUT", + GITHUB_API_ENDPOINTS.mergePR(owner, repoName, pullNumber), + ); +} + +export async function getGitHubPRs( + owner: string, + repoName: string, + state: "open" | "closed" | "all", + paginated = false, +) { + const url = GITHUB_API_ENDPOINTS.pull(owner, repoName, state); + if (paginated) { + return getGithubPaginatedRequest(url); + } + const response = await githubRequest("GET", url); + return parseJsonResponse(response); +} + +export async function getfileContentFromPR( + owner: string, + repoName: string, + pr: number, + filename: string, +): Promise { + const response = await githubRequest( + "GET", + GITHUB_API_ENDPOINTS.pull_files(owner, repoName, pr), + ); + const files: unknown = await parseJsonResponse(response); + if (!Array.isArray(files)) { + throw new TypeError( + `Expected PR files array but got ${typeof files}: ${JSON.stringify(files)}`, + ); + } + const file = files.find( + (entry): entry is GitHubPullRequestFile => + isGitHubPullRequestFile(entry) && entry.filename === filename, + ); + if (file === undefined) { + throw new Error(`File ${filename} not found in PR ${pr}`); + } + const rawFileContent = await ( + await githubRequest("GET", file.raw_url) + ).text(); + return rawFileContent; +} diff --git a/e2e-tests/playwright/utils/api-helper-guards.ts b/e2e-tests/playwright/utils/api-helper-guards.ts new file mode 100644 index 0000000000..7663b29630 --- /dev/null +++ b/e2e-tests/playwright/utils/api-helper-guards.ts @@ -0,0 +1,96 @@ +import { type APIResponse } from "@playwright/test"; +import { type GroupEntity, type UserEntity } from "@backstage/catalog-model"; + +interface GitHubPullRequestFile { + filename: string; + raw_url: string; +} + +interface GuestTokenResponse { + backstageIdentity: { + token: string; + }; +} + +interface EntityMetadataResponse { + metadata?: { + uid?: string; + }; +} + +interface CatalogLocationEntry { + data?: { + target?: string; + id?: string; + }; +} + +export function isGitHubPullRequestFile( + value: unknown, +): value is GitHubPullRequestFile { + return ( + typeof value === "object" && + value !== null && + "filename" in value && + typeof value.filename === "string" && + "raw_url" in value && + typeof value.raw_url === "string" + ); +} + +export function isGuestTokenResponse( + value: unknown, +): value is GuestTokenResponse { + return ( + typeof value === "object" && + value !== null && + "backstageIdentity" in value && + typeof value.backstageIdentity === "object" && + value.backstageIdentity !== null && + "token" in value.backstageIdentity && + typeof value.backstageIdentity.token === "string" + ); +} + +export function isEntityMetadataResponse( + value: unknown, +): value is EntityMetadataResponse { + return typeof value === "object" && value !== null; +} + +export function isCatalogLocationEntry( + value: unknown, +): value is CatalogLocationEntry { + return typeof value === "object" && value !== null; +} + +export function isUserEntity(value: unknown): value is UserEntity { + return ( + isEntityMetadataResponse(value) && "kind" in value && value.kind === "User" + ); +} + +export function isGroupEntity(value: unknown): value is GroupEntity { + return ( + isEntityMetadataResponse(value) && "kind" in value && value.kind === "Group" + ); +} + +export function parseJsonResponse(response: APIResponse): Promise { + return response.json(); +} + +export function toUnknownArray(value: unknown): unknown[] { + if (!Array.isArray(value)) { + throw new TypeError( + `Expected array but got ${typeof value}: ${JSON.stringify(value)}`, + ); + } + const items: unknown[] = []; + for (const item of value) { + items.push(item); + } + return items; +} + +export type { GitHubPullRequestFile, CatalogLocationEntry }; diff --git a/e2e-tests/playwright/utils/api-helper.ts b/e2e-tests/playwright/utils/api-helper.ts index 393997a25d..57606c1d77 100644 --- a/e2e-tests/playwright/utils/api-helper.ts +++ b/e2e-tests/playwright/utils/api-helper.ts @@ -1,255 +1,36 @@ -import { GroupEntity, UserEntity } from "@backstage/catalog-model"; -import { request, APIResponse, expect } from "@playwright/test"; - -import { GITHUB_API_ENDPOINTS } from "./api-endpoints"; - -type FetchOptions = { - method: string; - headers: { - Accept: string; - Authorization: string; - "X-GitHub-Api-Version": string; - }; - data?: string | object; -}; - -interface GitHubPullRequestFile { - filename: string; - raw_url: string; -} - -interface GuestTokenResponse { - backstageIdentity: { - token: string; - }; -} - -interface EntityMetadataResponse { - metadata?: { - uid?: string; - }; -} - -interface CatalogLocationEntry { - data?: { - target?: string; - id?: string; - }; -} - -function isGitHubPullRequestFile(value: unknown): value is GitHubPullRequestFile { - return ( - typeof value === "object" && - value !== null && - "filename" in value && - typeof value.filename === "string" && - "raw_url" in value && - typeof value.raw_url === "string" - ); -} - -function isGuestTokenResponse(value: unknown): value is GuestTokenResponse { - return ( - typeof value === "object" && - value !== null && - "backstageIdentity" in value && - typeof value.backstageIdentity === "object" && - value.backstageIdentity !== null && - "token" in value.backstageIdentity && - typeof value.backstageIdentity.token === "string" - ); -} - -function isEntityMetadataResponse(value: unknown): value is EntityMetadataResponse { - return typeof value === "object" && value !== null; -} - -function isCatalogLocationEntry(value: unknown): value is CatalogLocationEntry { - return typeof value === "object" && value !== null; -} - -function isUserEntity(value: unknown): value is UserEntity { - return isEntityMetadataResponse(value) && "kind" in value && value.kind === "User"; -} - -function isGroupEntity(value: unknown): value is GroupEntity { - return isEntityMetadataResponse(value) && "kind" in value && value.kind === "Group"; -} - -async function parseJsonResponse(response: APIResponse): Promise { - return response.json(); -} - -function toUnknownArray(value: unknown): unknown[] { - if (!Array.isArray(value)) { - throw new TypeError(`Expected array but got ${typeof value}: ${JSON.stringify(value)}`); - } - const items: unknown[] = []; - for (const item of value) { - items.push(item); - } - return items; -} +import { request, type APIResponse, expect } from "@playwright/test"; +import { type GroupEntity, type UserEntity } from "@backstage/catalog-model"; +import { + isGuestTokenResponse, + isGroupEntity, + isUserEntity, + parseJsonResponse, +} from "./api-helper-guards"; +import * as catalogApi from "./api-helper-catalog"; +import * as githubApi from "./api-helper-github"; export class APIHelper { - private static githubAPIVersion = "2022-11-28"; private staticToken = ""; private baseUrl = ""; useStaticToken = false; - static async githubRequest( - method: string, - url: string, - body?: string | object, - ): Promise { - const context = await request.newContext(); - const options: FetchOptions = { - method: method, - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${process.env.GH_RHDH_QE_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: string, - pageNo = 1, - response: unknown[] = [], - ): Promise { - const fullUrl = `${url}&page=${pageNo}`; - const result = await this.githubRequest("GET", fullUrl); - const body: unknown = await result.json(); - const pageItems = toUnknownArray(body); - - if (pageItems.length === 0) { - return response; - } - - response = response.concat(pageItems); - return await this.getGithubPaginatedRequest(url, pageNo + 1, response); - } - - static async createGitHubRepo(owner: string, repoName: string) { - 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: string, - repoName: string, - filename: string, - fileContent: string, - ) { - // Create the repository - await APIHelper.createGitHubRepo(owner, repoName); - - // Add the specified file - await APIHelper.createFileInRepo( - owner, - repoName, - filename, - fileContent, - `Add ${filename} file`, - ); - } - - static async createFileInRepo( - owner: string, - repoName: string, - filePath: string, - content: string, - commitMessage: string, - 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: string, repo: string, 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: string, repoName: string) { - await APIHelper.githubRequest("DELETE", GITHUB_API_ENDPOINTS.deleteRepo(owner, repoName)); - } - - static async mergeGitHubPR(owner: string, repoName: string, pullNumber: number) { - await APIHelper.githubRequest("PUT", GITHUB_API_ENDPOINTS.mergePR(owner, repoName, pullNumber)); - } - - static async getGitHubPRs( - owner: string, - repoName: string, - state: "open" | "closed" | "all", - paginated = false, - ) { - const url = GITHUB_API_ENDPOINTS.pull(owner, repoName, state); - if (paginated) { - return APIHelper.getGithubPaginatedRequest(url); - } - const response = await APIHelper.githubRequest("GET", url); - return parseJsonResponse(response); - } - - static async getfileContentFromPR( - owner: string, - repoName: string, - pr: number, - filename: string, - ): Promise { - const response = await APIHelper.githubRequest( - "GET", - GITHUB_API_ENDPOINTS.pull_files(owner, repoName, pr), - ); - const files: unknown = await parseJsonResponse(response); - if (!Array.isArray(files)) { - throw new TypeError( - `Expected PR files array but got ${typeof files}: ${JSON.stringify(files)}`, - ); - } - const file = files.find( - (entry): entry is GitHubPullRequestFile => - isGitHubPullRequestFile(entry) && entry.filename === filename, - ); - if (!file) { - throw new Error(`File ${filename} not found in PR ${pr}`); - } - const rawFileContent = await (await APIHelper.githubRequest("GET", file.raw_url)).text(); - return rawFileContent; - } + static githubRequest = githubApi.githubRequest; + static createGitHubRepo = githubApi.createGitHubRepo; + static createGitHubRepoWithFile = githubApi.createGitHubRepoWithFile; + static createFileInRepo = githubApi.createFileInRepo; + static initCommit = githubApi.initCommit; + static deleteGitHubRepo = githubApi.deleteGitHubRepo; + static mergeGitHubPR = githubApi.mergeGitHubPR; + static getGitHubPRs = githubApi.getGitHubPRs; + static getfileContentFromPR = githubApi.getfileContentFromPR; + static getGithubPaginatedRequest = githubApi.getGithubPaginatedRequest; + + static getEntityUidByName = catalogApi.getEntityUidByName; + static deleteLocationByUid = catalogApi.deleteLocationByUid; + static getTemplateEntityUidByName = catalogApi.getTemplateEntityUidByName; + static deleteEntityLocationById = catalogApi.deleteEntityLocationById; + static registerLocation = catalogApi.registerLocation; + static getLocationIdByTarget = catalogApi.getLocationIdByTarget; async getGuestToken(): Promise { const context = await request.newContext(); @@ -270,12 +51,12 @@ export class APIHelper { return headers; } - async UseStaticToken(token: string) { + UseStaticToken(token: string): void { this.useStaticToken = true; this.staticToken = "Bearer " + token; } - async UseBaseUrl(url: string) { + UseBaseUrl(url: string): void { this.baseUrl = url; } @@ -301,7 +82,7 @@ export class APIHelper { }, }; - if (body) { + if (body !== undefined) { options.data = body; } @@ -309,38 +90,57 @@ export class APIHelper { return response; } + private getAuthToken(): string { + return this.useStaticToken ? this.staticToken : ""; + } + async getAllCatalogUsersFromAPI(): Promise { 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); + const response = await APIHelper.APIRequestWithStaticToken( + "GET", + url, + this.getAuthToken(), + ); return parseJsonResponse(response); } async getAllCatalogLocationsFromAPI(): Promise { 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); + const response = await APIHelper.APIRequestWithStaticToken( + "GET", + url, + this.getAuthToken(), + ); return parseJsonResponse(response); } async getAllCatalogGroupsFromAPI(): Promise { 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); + const response = await APIHelper.APIRequestWithStaticToken( + "GET", + url, + this.getAuthToken(), + ); return parseJsonResponse(response); } async getGroupEntityFromAPI(group: string): Promise { 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); + const response = await APIHelper.APIRequestWithStaticToken( + "GET", + url, + this.getAuthToken(), + ); return parseJsonResponse(response); } async getCatalogUserFromAPI(user: string): Promise { 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); + const response = await APIHelper.APIRequestWithStaticToken( + "GET", + url, + this.getAuthToken(), + ); const body: unknown = await parseJsonResponse(response); if (!isUserEntity(body)) { throw new TypeError(`Invalid catalog user response for ${user}`); @@ -350,19 +150,26 @@ export class APIHelper { async deleteUserEntityFromAPI(user: string): Promise { const r = await this.getCatalogUserFromAPI(user); - if (!r.metadata?.uid) { + const uid = r.metadata?.uid; + if (uid === undefined || uid === "") { return undefined; } - 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); + const url = `${this.baseUrl}/api/catalog/entities/by-uid/${uid}`; + const response = await APIHelper.APIRequestWithStaticToken( + "DELETE", + url, + this.getAuthToken(), + ); return response.statusText(); } async getCatalogGroupFromAPI(group: string): Promise { 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); + const response = await APIHelper.APIRequestWithStaticToken( + "GET", + url, + this.getAuthToken(), + ); const body: unknown = await parseJsonResponse(response); if (!isGroupEntity(body)) { throw new TypeError(`Invalid catalog group response for ${group}`); @@ -373,139 +180,27 @@ export class APIHelper { async deleteGroupEntityFromAPI(group: string): Promise { 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); + const response = await APIHelper.APIRequestWithStaticToken( + "DELETE", + url, + this.getAuthToken(), + ); return response.statusText(); } - async scheduleEntityRefreshFromAPI(entity: string, kind: string, token: string) { + async scheduleEntityRefreshFromAPI( + entity: string, + kind: string, + token: string, + ) { 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: string): Promise { - const baseUrl = process.env.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: unknown = await parseJsonResponse(response); - if (!isEntityMetadataResponse(data)) { - return undefined; - } - 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: string): Promise { - const baseUrl = process.env.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: string, - namespace: string = "default", - ): Promise { - const baseUrl = process.env.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: unknown = await parseJsonResponse(response); - if (!isEntityMetadataResponse(data)) { - return undefined; - } - 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: string): Promise { - const baseUrl = process.env.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: string): Promise { - const baseUrl = process.env.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: string): Promise { - const baseUrl = process.env.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: unknown = await parseJsonResponse(response); - if (!Array.isArray(data)) { - return undefined; - } - const location = data.find( - (entry): entry is CatalogLocationEntry => - isCatalogLocationEntry(entry) && entry.data?.target === target, + const responseRefresh = await APIHelper.APIRequestWithStaticToken( + "POST", + url, + token, + reqBody, ); - return location?.data?.id; + return responseRefresh.status(); } } diff --git a/e2e-tests/playwright/utils/authentication-providers/gitlab-helper.ts b/e2e-tests/playwright/utils/authentication-providers/gitlab-helper.ts index 33e377e784..1bf80ae72b 100644 --- a/e2e-tests/playwright/utils/authentication-providers/gitlab-helper.ts +++ b/e2e-tests/playwright/utils/authentication-providers/gitlab-helper.ts @@ -18,7 +18,9 @@ interface GitLabOAuthAppResponse { scopes?: string[]; } -function isGitLabOAuthAppResponse(value: unknown): value is GitLabOAuthAppResponse { +function isGitLabOAuthAppResponse( + value: unknown, +): value is GitLabOAuthAppResponse { return ( typeof value === "object" && value !== null && @@ -43,10 +45,64 @@ export class GitLabHelper { constructor(config: GitLabConfig) { this.config = config; // Ensure host doesn't have protocol prefix - const cleanHost = config.host.replace(/^https?:\/\//, ""); + const cleanHost = config.host.replace(/^https?:\/\//u, ""); this.apiBaseUrl = `https://${cleanHost}/api/v4`; } + private async postOAuthApplication( + name: string, + redirectUri: string, + scopes: string, + trusted: boolean, + ): Promise { + const response = await fetch(`${this.apiBaseUrl}/applications`, { + method: "POST", + headers: { + "PRIVATE-TOKEN": this.config.personalAccessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: name, + redirect_uri: redirectUri, + scopes: scopes, + trusted: trusted, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to create OAuth application: ${response.status} ${response.statusText} - ${errorText}`, + ); + } + + const app: unknown = await response.json(); + if (!isGitLabOAuthAppResponse(app)) { + console.error("[GITLAB] Unexpected API response structure:", app); + throw new Error( + "GitLab API response missing required fields (id, application_id, or secret)", + ); + } + return app; + } + + private mapOAuthAppResponse( + app: GitLabOAuthAppResponse, + name: string, + redirectUri: string, + scopes: string, + ): GitLabOAuthApp { + return { + id: app.id, + application_id: app.application_id, + application_name: app.application_name ?? app.name ?? name, + secret: app.secret, + callback_url: app.callback_url ?? app.redirect_uri ?? redirectUri, + scopes: + app.scopes ?? (typeof scopes === "string" ? scopes.split(" ") : []), + }; + } + /** * Creates an OAuth application in GitLab * @param name - Name of the application @@ -64,50 +120,21 @@ export class GitLabHelper { try { console.log(`[GITLAB] Creating OAuth application: ${name}`); console.log(`[GITLAB] Scopes: ${scopes}, Trusted: ${trusted}`); - const response = await fetch(`${this.apiBaseUrl}/applications`, { - method: "POST", - headers: { - "PRIVATE-TOKEN": this.config.personalAccessToken, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: name, - redirect_uri: redirectUri, - scopes: scopes, - trusted: trusted, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Failed to create OAuth application: ${response.status} ${response.statusText} - ${errorText}`, - ); - } - - const app: unknown = await response.json(); - - // Validate required fields - if (!isGitLabOAuthAppResponse(app)) { - console.error("[GITLAB] Unexpected API response structure:", app); - throw new Error( - "GitLab API response missing required fields (id, application_id, or secret)", - ); - } + const app = await this.postOAuthApplication( + name, + redirectUri, + scopes, + trusted, + ); - console.log(`[GITLAB] OAuth application created successfully with ID: ${app.id}`); + console.log( + `[GITLAB] OAuth application created successfully with ID: ${app.id}`, + ); console.log( `[GITLAB] Application ID: ${app.application_id}, Secret: ${app.secret ? "***" : "not provided"}`, ); - return { - id: app.id, - application_id: app.application_id, - application_name: app.application_name || app.name || name, - secret: app.secret, - callback_url: app.callback_url || app.redirect_uri || redirectUri, - scopes: app.scopes || (typeof scopes === "string" ? scopes.split(" ") : []), - }; + return this.mapOAuthAppResponse(app, name, redirectUri, scopes); } catch (error) { console.error("[GITLAB] Failed to create OAuth application:", error); throw error; @@ -121,12 +148,15 @@ export class GitLabHelper { async deleteOAuthApplication(applicationId: number): Promise { try { console.log(`[GITLAB] Deleting OAuth application: ${applicationId}`); - const response = await fetch(`${this.apiBaseUrl}/applications/${applicationId}`, { - method: "DELETE", - headers: { - "PRIVATE-TOKEN": this.config.personalAccessToken, + const response = await fetch( + `${this.apiBaseUrl}/applications/${applicationId}`, + { + method: "DELETE", + headers: { + "PRIVATE-TOKEN": this.config.personalAccessToken, + }, }, - }); + ); if (!response.ok) { // 404 is acceptable if the app was already deleted @@ -142,9 +172,14 @@ export class GitLabHelper { ); } - console.log(`[GITLAB] OAuth application ${applicationId} deleted successfully`); + console.log( + `[GITLAB] OAuth application ${applicationId} deleted successfully`, + ); } catch (error) { - console.error(`[GITLAB] Failed to delete OAuth application ${applicationId}:`, error); + console.error( + `[GITLAB] Failed to delete OAuth application ${applicationId}:`, + error, + ); throw error; } } @@ -174,7 +209,7 @@ export class GitLabHelper { if (!Array.isArray(apps)) { throw new TypeError("Expected array of OAuth applications"); } - const validatedApps = apps.filter(isGitLabOAuthAppResponse); + const validatedApps = apps.filter((app) => isGitLabOAuthAppResponse(app)); console.log(`[GITLAB] Found ${validatedApps.length} OAuth applications`); return validatedApps.map((app) => ({ id: app.id, @@ -182,7 +217,7 @@ export class GitLabHelper { application_name: app.application_name ?? app.name ?? "", secret: app.secret, callback_url: app.callback_url ?? app.redirect_uri ?? "", - scopes: app.scopes || [], + scopes: app.scopes ?? [], })); } catch (error) { console.error("[GITLAB] Failed to list OAuth applications:", error); diff --git a/e2e-tests/playwright/utils/authentication-providers/msgraph-helper-nsg.ts b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper-nsg.ts new file mode 100644 index 0000000000..3edc8bda54 --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper-nsg.ts @@ -0,0 +1,285 @@ +import { + NetworkManagementClient, + NetworkSecurityGroupsGetResponse, + SecurityRule, + SecurityRulesGetResponse, +} from "@azure/arm-network"; +import { getErrorMessage, hasStatusCode } from "../errors"; + +export async function getNetworkSecurityGroupRule( + armNetworkClient: NetworkManagementClient, + resourceGroupName: string, + nsgName: string, + ruleName: string, +): Promise { + try { + console.log( + `Getting network security group rule ${ruleName} from NSG ${nsgName} in resource group ${resourceGroupName}`, + ); + + const rule = await armNetworkClient.securityRules.get( + resourceGroupName, + nsgName, + ruleName, + ); + return rule ?? null; + } catch (e) { + if (hasStatusCode(e) && e.statusCode === 404) { + console.log( + `Network security group rule ${ruleName} not found in NSG ${nsgName}`, + ); + return null; + } + console.error("Failed to get network security group rule:", e); + throw e; + } +} + +export async function getNetworkSecurityGroup( + armNetworkClient: NetworkManagementClient, + resourceGroupName: string, + nsgName: string, +): Promise { + try { + console.log( + `Getting network security group ${nsgName} from resource group ${resourceGroupName}`, + ); + + const nsg = await armNetworkClient.networkSecurityGroups.get( + resourceGroupName, + nsgName, + ); + if (nsg === undefined) { + throw new Error( + `Network security group ${nsgName} not found in ${resourceGroupName}`, + ); + } + return nsg; + } catch (e) { + console.error("Failed to get network security group:", e); + throw e; + } +} + +async function findAvailablePriority( + armNetworkClient: NetworkManagementClient, + resourceGroupName: string, + nsgName: string, +): Promise { + const existingRules = armNetworkClient.securityRules.list( + resourceGroupName, + nsgName, + ); + const existingPriorities = new Set(); + + for await (const rule of existingRules) { + if (rule.priority !== undefined) { + existingPriorities.add(rule.priority); + } + } + + let availablePriority = 200; + while (existingPriorities.has(availablePriority)) { + availablePriority++; + } + return availablePriority; +} + +function buildTemporaryNsgRule( + templateRule: SecurityRulesGetResponse, + availablePriority: number, +): SecurityRule { + return { + protocol: templateRule.protocol, + sourcePortRange: templateRule.sourcePortRange, + destinationPortRange: templateRule.destinationPortRange, + sourceAddressPrefix: "*", + destinationAddressPrefix: templateRule.destinationAddressPrefix, + access: templateRule.access, + priority: availablePriority, + direction: templateRule.direction, + description: `Temporary E2E test rule allowing all IPs - Created at ${new Date().toISOString()}`, + }; +} + +function createNsgRuleCleanup( + armNetworkClient: NetworkManagementClient, + resourceGroupName: string, + nsgName: string, + ruleName: string, +): () => Promise { + return async (): Promise => { + try { + console.log(`[NSG] Starting cleanup for rule: ${ruleName}`); + console.log(`[NSG] Verifying rule exists before deletion...`); + + const existingRule = await getNetworkSecurityGroupRule( + armNetworkClient, + resourceGroupName, + nsgName, + ruleName, + ); + if (existingRule === null) { + console.log( + `[NSG] Rule ${ruleName} not found during cleanup - may have been already deleted`, + ); + return; + } + + console.log(`[NSG] Deleting rule: ${ruleName}`); + const deletePoller = await armNetworkClient.securityRules.beginDelete( + resourceGroupName, + nsgName, + ruleName, + ); + console.log(`[NSG] Waiting for rule deletion to complete...`); + await deletePoller.pollUntilDone(); + console.log(`[NSG] Rule deleted successfully: ${ruleName}`); + } catch (error) { + console.error(`[NSG] Failed to cleanup rule ${ruleName}:`, error); + console.error(`[NSG] Cleanup error details:`, { + message: getErrorMessage(error), + statusCode: hasStatusCode(error) ? error.statusCode : undefined, + }); + } + }; +} + +function generateRuleName(baseRuleName: string): string { + const timestamp = Date.now(); + const randomSuffix = Math.random().toString(36).slice(2, 8); + return `${baseRuleName}-${timestamp}-${randomSuffix}`; +} + +function logNsgFailure(error: unknown): void { + console.error(`[NSG] Failed to allow public IP in NSG:`, error); + console.error(`[NSG] Error details:`, { + message: getErrorMessage(error), + statusCode: hasStatusCode(error) ? error.statusCode : undefined, + }); +} + +async function resolveTemplateRule( + armNetworkClient: NetworkManagementClient, + resourceGroupName: string, + nsgName: string, + baseRuleName: string, +): Promise { + console.log( + `[NSG] Verifying NSG exists: ${nsgName} in resource group: ${resourceGroupName}`, + ); + const nsg = await getNetworkSecurityGroup( + armNetworkClient, + resourceGroupName, + nsgName, + ); + console.log(`[NSG] NSG verified: ${nsg.name} (ID: ${nsg.id})`); + + console.log(`[NSG] Getting existing rule as template: ${baseRuleName}`); + const templateRule = await getNetworkSecurityGroupRule( + armNetworkClient, + resourceGroupName, + nsgName, + baseRuleName, + ); + + if (templateRule === null) { + throw new Error( + `Template rule ${baseRuleName} not found in NSG ${nsgName}`, + ); + } + console.log( + `[NSG] Template rule found: ${templateRule.name} (Priority: ${templateRule.priority})`, + ); + return templateRule; +} + +async function createTemporaryNsgRule( + armNetworkClient: NetworkManagementClient, + resourceGroupName: string, + nsgName: string, + ruleName: string, + templateRule: SecurityRulesGetResponse, +): Promise { + const availablePriority = await findAvailablePriority( + armNetworkClient, + resourceGroupName, + nsgName, + ); + console.log( + `[NSG] Template rule priority: ${templateRule.priority}, Using available priority: ${availablePriority}`, + ); + + const newRule = buildTemporaryNsgRule(templateRule, availablePriority); + + console.log(`[NSG] Creating new rule: ${ruleName} with wildcard IP (*)`); + console.log( + `[NSG] Rule details: Priority=${newRule.priority}, Protocol=${newRule.protocol}, Access=${newRule.access}`, + ); + + const rulePoller = await armNetworkClient.securityRules.beginCreateOrUpdate( + resourceGroupName, + nsgName, + ruleName, + newRule, + ); + + console.log(`[NSG] Waiting for rule creation to complete...`); + const createdRule = await rulePoller.pollUntilDone(); + + console.log(`[NSG] Rule created successfully: ${ruleName}`); + console.log(`[NSG] Rule ID: ${createdRule.id}`); +} + +export async function allowPublicIpInNsg( + armNetworkClient: NetworkManagementClient, + getPublicIp: () => Promise, + resourceGroupName: string, + nsgName: string, + baseRuleName: string = "AllowE2EJobs", +): Promise<{ + publicIp: string; + ruleName: string; + resourceGroupName: string; + nsgName: string; + cleanup: () => Promise; +}> { + try { + console.log("[NSG] Getting current public IP address..."); + const publicIp = await getPublicIp(); + console.log(`[NSG] Public IP obtained: ${publicIp}`); + + const ruleName = generateRuleName(baseRuleName); + console.log(`[NSG] Generated unique rule name: ${ruleName}`); + + const templateRule = await resolveTemplateRule( + armNetworkClient, + resourceGroupName, + nsgName, + baseRuleName, + ); + await createTemporaryNsgRule( + armNetworkClient, + resourceGroupName, + nsgName, + ruleName, + templateRule, + ); + + return { + publicIp, + ruleName, + resourceGroupName, + nsgName, + cleanup: createNsgRuleCleanup( + armNetworkClient, + resourceGroupName, + nsgName, + ruleName, + ), + }; + } catch (error) { + logNsgFailure(error); + throw error; + } +} diff --git a/e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts index 6315d34379..4a2974f93d 100644 --- a/e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts +++ b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts @@ -1,17 +1,19 @@ // oxlint-disable-next-line import/no-unassigned-import -- fetch polyfill required by Graph SDK import "isomorphic-fetch"; -import { - NetworkManagementClient, - NetworkSecurityGroupsGetResponse, - SecurityRulesGetResponse, - type SecurityRule, -} from "@azure/arm-network"; +import { hasStatusCode } from "../errors"; import { ClientSecretCredential } from "@azure/identity"; import { Client, PageCollection } from "@microsoft/microsoft-graph-client"; import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials/index.js"; import { User, Group } from "@microsoft/microsoft-graph-types"; - -import { getErrorMessage, hasStatusCode } from "../errors"; +import { + NetworkManagementClient, + SecurityRulesGetResponse, +} from "@azure/arm-network"; +import { + allowPublicIpInNsg, + getNetworkSecurityGroup, + getNetworkSecurityGroupRule, +} from "./msgraph-helper-nsg"; interface AzureApplicationWeb { redirectUris?: string[]; @@ -25,13 +27,18 @@ interface IpifyResponse { ip: string; } -function isAzureApplicationResponse(value: unknown): value is AzureApplicationResponse { +function isAzureApplicationResponse( + value: unknown, +): value is AzureApplicationResponse { return typeof value === "object" && value !== null; } function isIpifyResponse(value: unknown): value is IpifyResponse { return ( - typeof value === "object" && value !== null && "ip" in value && typeof value.ip === "string" + typeof value === "object" && + value !== null && + "ip" in value && + typeof value.ip === "string" ); } @@ -44,7 +51,12 @@ export class MSClient { private readonly clientSecret: string; private readonly subscriptionId?: string; - constructor(clientId: string, clientSecret: string, tenantId: string, subscriptionId?: string) { + constructor( + clientId: string, + clientSecret: string, + tenantId: string, + subscriptionId?: string, + ) { if (!clientId || !tenantId || !clientSecret) { console.error("Missing required credentials"); throw new Error("Client ID, Tenant ID, and Client Secret are required"); @@ -57,18 +69,19 @@ export class MSClient { } private initializeGraphForAppOnlyAuth(): void { - if (!this.clientSecretCredential) { - this.clientSecretCredential = new ClientSecretCredential( - this.tenantId, - this.clientId, - this.clientSecret, + this.clientSecretCredential ??= new ClientSecretCredential( + this.tenantId, + this.clientId, + this.clientSecret, + ); + + if (this.appClient === undefined) { + const authProvider = new TokenCredentialAuthenticationProvider( + this.clientSecretCredential, + { + scopes: ["https://graph.microsoft.com/.default"], + }, ); - } - - if (!this.appClient) { - const authProvider = new TokenCredentialAuthenticationProvider(this.clientSecretCredential, { - scopes: ["https://graph.microsoft.com/.default"], - }); this.appClient = Client.initWithMiddleware({ authProvider: authProvider, @@ -77,26 +90,22 @@ export class MSClient { } private initializeArmNetworkClient(): void { - if (!this.subscriptionId) { + if (this.subscriptionId === undefined || this.subscriptionId === "") { throw new Error( "Subscription ID is required for ARM operations. Please provide it in the constructor.", ); } - if (!this.clientSecretCredential) { - this.clientSecretCredential = new ClientSecretCredential( - this.tenantId, - this.clientId, - this.clientSecret, - ); - } + this.clientSecretCredential ??= new ClientSecretCredential( + this.tenantId, + this.clientId, + this.clientSecret, + ); - if (!this.armNetworkClient) { - this.armNetworkClient = new NetworkManagementClient( - this.clientSecretCredential, - this.subscriptionId, - ); - } + this.armNetworkClient ??= new NetworkManagementClient( + this.clientSecretCredential, + this.subscriptionId, + ); } private ensureInitialized(): void { @@ -114,20 +123,26 @@ export class MSClient { } /** Graph SDK requests return untyped data; narrow at call sites. */ - private async graphGet(request: (client: Client) => Promise): Promise { + private async graphGet( + request: (client: Client) => Promise, + ): Promise { const result: unknown = await request(this.getAppClient()); // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Graph SDK has no typed responses return result as T; } /** Graph SDK mutations return untyped data; narrow at call sites. */ - private async graphMutate(request: (client: Client) => Promise): Promise { + private async graphMutate( + request: (client: Client) => Promise, + ): Promise { const result: unknown = await request(this.getAppClient()); // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Graph SDK has no typed responses return result as T; } - private async graphDelete(request: (client: Client) => Promise): Promise { + private async graphDelete( + request: (client: Client) => Promise, + ): Promise { await request(this.getAppClient()); } @@ -152,7 +167,10 @@ export class MSClient { async getGroupsAsync(): Promise { try { return await this.graphGet((client) => - client.api("/groups").select(["id", "displayName", "members", "owners"]).get(), + client + .api("/groups") + .select(["id", "displayName", "members", "owners"]) + .get(), ); } catch (e) { console.error("Failed to get groups:", e); @@ -163,7 +181,11 @@ export class MSClient { async getGroupByNameAsync(groupName: string): Promise { try { return await this.graphGet((client) => - client.api("/groups").filter(`displayName eq '${groupName}'`).top(1).get(), + client + .api("/groups") + .filter(`displayName eq '${groupName}'`) + .top(1) + .get(), ); } catch (e) { if (hasStatusCode(e) && e.statusCode === 404) { @@ -180,7 +202,14 @@ export class MSClient { return await this.graphGet((client) => client .api(`/groups/${groupId}/members`) - .select(["displayName", "id", "mail", "userPrincipalName", "surname", "firstname"]) + .select([ + "displayName", + "id", + "mail", + "userPrincipalName", + "surname", + "firstname", + ]) .get(), ); } catch (e) { @@ -192,7 +221,9 @@ export class MSClient { async createUserAsync(user: User): Promise { try { console.log(`Creating user ${user.userPrincipalName}`); - return await this.graphMutate((client) => client.api("/users").post(user)); + return await this.graphMutate((client) => + client.api("/users").post(user), + ); } catch (e) { console.error("Failed to create user:", e); throw e; @@ -202,7 +233,9 @@ export class MSClient { async createGroupAsync(group: Group): Promise { try { console.log(`Creating group ${group.displayName}`); - return await this.graphMutate((client) => client.api("/groups").post(group)); + return await this.graphMutate((client) => + client.api("/groups").post(group), + ); } catch (e) { console.error("Failed to create group:", e); throw e; @@ -214,7 +247,14 @@ export class MSClient { return await this.graphGet((client) => client .api("/users") - .select(["displayName", "id", "mail", "userPrincipalName", "surname", "firstname"]) + .select([ + "displayName", + "id", + "mail", + "userPrincipalName", + "surname", + "firstname", + ]) .top(25) .orderby("userPrincipalName") .get(), @@ -247,7 +287,9 @@ export class MSClient { async getUserByUpnAsync(upn: string): Promise { try { - return await this.graphGet((client) => client.api("/users/" + upn).get()); + return await this.graphGet((client) => + client.api("/users/" + upn).get(), + ); } catch (e) { if (hasStatusCode(e) && e.statusCode === 404) { console.log(`User ${upn} not found`); @@ -260,12 +302,17 @@ export class MSClient { async addUserToGroupAsync(user: User, group: Group): Promise { const userDirectoryObject = { - "@odata.id": "https://graph.microsoft.com/v1.0/users/" + user.userPrincipalName, + "@odata.id": + "https://graph.microsoft.com/v1.0/users/" + user.userPrincipalName, }; try { - console.log(`Adding user ${user.userPrincipalName} to group ${group.displayName}`); + console.log( + `Adding user ${user.userPrincipalName} to group ${group.displayName}`, + ); await this.graphMutate((client) => - client.api("/groups/" + group.id + "/members/$ref").post(userDirectoryObject), + client + .api("/groups/" + group.id + "/members/$ref") + .post(userDirectoryObject), ); } catch (e) { console.error("Failed to add user to group:", e); @@ -275,7 +322,9 @@ export class MSClient { async removeUserFromGroupAsync(user: User, group: Group): Promise { try { - console.log(`Removing user ${user.userPrincipalName} from group ${group.displayName}`); + console.log( + `Removing user ${user.userPrincipalName} from group ${group.displayName}`, + ); await this.graphDelete((client) => client.api(`/groups/${group.id}/members/${user.id}/$ref`).delete(), ); @@ -290,9 +339,13 @@ export class MSClient { "@odata.id": "https://graph.microsoft.com/v1.0/groups/" + subject.id, }; try { - console.log(`Adding group ${subject.displayName} to group ${target.displayName}`); + console.log( + `Adding group ${subject.displayName} to group ${target.displayName}`, + ); await this.graphMutate((client) => - client.api("/groups/" + target.id + "/members/$ref").post(userDirectoryObject), + client + .api("/groups/" + target.id + "/members/$ref") + .post(userDirectoryObject), ); } catch (e) { console.error("Failed to add group to group:", e); @@ -344,11 +397,15 @@ export class MSClient { async addAppRedirectUrlsAsync(redirectUrls: string[]): Promise { try { - console.log(`[AZURE] Adding ${redirectUrls.length} redirect URLs to app: ${this.clientId}`); + console.log( + `[AZURE] Adding ${redirectUrls.length} redirect URLs to app: ${this.clientId}`, + ); const currentUrls = await this.getAppRedirectUrlsAsync(); const newUrls = [...new Set([...currentUrls, ...redirectUrls])]; - console.log(`[AZURE] Updating app with ${newUrls.length} total redirect URLs`); + console.log( + `[AZURE] Updating app with ${newUrls.length} total redirect URLs`, + ); await this.graphMutate((client) => client.api(`/applications(appId='{${this.clientId}}')`).update({ web: { @@ -371,7 +428,9 @@ export class MSClient { const currentUrls = await this.getAppRedirectUrlsAsync(); const newUrls = currentUrls.filter((url) => !redirectUrls.includes(url)); - console.log(`[AZURE] Updating app with ${newUrls.length} remaining redirect URLs`); + console.log( + `[AZURE] Updating app with ${newUrls.length} remaining redirect URLs`, + ); await this.graphMutate((client) => client.api(`/applications(appId='{${this.clientId}}')`).update({ web: { @@ -409,31 +468,21 @@ export class MSClient { return user.replace("@", "_"); } - async getNetworkSecurityGroupRuleAsync( + getNetworkSecurityGroupRuleAsync( resourceGroupName: string, nsgName: string, ruleName: string, ): Promise { this.ensureArmInitialized(); - try { - console.log( - `Getting network security group rule ${ruleName} from NSG ${nsgName} in resource group ${resourceGroupName}`, - ); - - const rule = await this.armNetworkClient?.securityRules.get( - resourceGroupName, - nsgName, - ruleName, - ); - return rule ?? null; - } catch (e) { - if (hasStatusCode(e) && e.statusCode === 404) { - console.log(`Network security group rule ${ruleName} not found in NSG ${nsgName}`); - return null; - } - console.error("Failed to get network security group rule:", e); - throw e; + if (this.armNetworkClient === undefined) { + throw new Error("ARM network client not initialized"); } + return getNetworkSecurityGroupRule( + this.armNetworkClient, + resourceGroupName, + nsgName, + ruleName, + ); } async getPublicIpAsync(): Promise { @@ -442,7 +491,9 @@ export class MSClient { const response = await fetch("https://api.ipify.org?format=json"); if (!response.ok) { - throw new Error(`Failed to fetch public IP: ${response.status} ${response.statusText}`); + throw new Error( + `Failed to fetch public IP: ${response.status} ${response.statusText}`, + ); } const data: unknown = await response.json(); @@ -459,31 +510,19 @@ export class MSClient { } } - async getNetworkSecurityGroupAsync( - resourceGroupName: string, - nsgName: string, - ): Promise { + getNetworkSecurityGroupAsync(resourceGroupName: string, nsgName: string) { this.ensureArmInitialized(); - try { - console.log( - `Getting network security group ${nsgName} from resource group ${resourceGroupName}`, - ); - - const nsg = await this.armNetworkClient?.networkSecurityGroups.get( - resourceGroupName, - nsgName, - ); - if (!nsg) { - throw new Error(`Network security group ${nsgName} not found in ${resourceGroupName}`); - } - return nsg; - } catch (e) { - console.error("Failed to get network security group:", e); - throw e; + if (this.armNetworkClient === undefined) { + throw new Error("ARM network client not initialized"); } + return getNetworkSecurityGroup( + this.armNetworkClient, + resourceGroupName, + nsgName, + ); } - async allowPublicIpInNSG( + allowPublicIpInNSG( resourceGroupName: string, nsgName: string, baseRuleName: string = "AllowE2EJobs", @@ -495,147 +534,15 @@ export class MSClient { cleanup: () => Promise; }> { this.ensureArmInitialized(); - - try { - // Step 1: Get public IP (for logging purposes only) - console.log("[NSG] Getting current public IP address..."); - const publicIp = await this.getPublicIpAsync(); - console.log(`[NSG] Public IP obtained: ${publicIp}`); - - // Step 2: Generate unique rule name - const timestamp = Date.now(); - const randomSuffix = Math.random().toString(36).slice(2, 8); - const ruleName = `${baseRuleName}-${timestamp}-${randomSuffix}`; - console.log(`[NSG] Generated unique rule name: ${ruleName}`); - - // Step 3: Verify NSG exists - console.log(`[NSG] Verifying NSG exists: ${nsgName} in resource group: ${resourceGroupName}`); - const nsg = await this.getNetworkSecurityGroupAsync(resourceGroupName, nsgName); - console.log(`[NSG] NSG verified: ${nsg.name} (ID: ${nsg.id})`); - - // Step 4: Get existing rule to use as template - console.log(`[NSG] Getting existing rule as template: ${baseRuleName}`); - const templateRule = await this.getNetworkSecurityGroupRuleAsync( - resourceGroupName, - nsgName, - baseRuleName, - ); - - if (!templateRule) { - throw new Error(`Template rule ${baseRuleName} not found in NSG ${nsgName}`); - } - console.log( - `[NSG] Template rule found: ${templateRule.name} (Priority: ${templateRule.priority})`, - ); - - // Step 5: Create new rule with wildcard IP (*) - // Find an available priority to avoid conflicts - const existingRules = this.armNetworkClient?.securityRules.list(resourceGroupName, nsgName); - const existingPriorities = new Set(); - - if (existingRules) { - for await (const rule of existingRules) { - existingPriorities.add(rule.priority); - } - } - - // Find the first available priority starting from 100 - let availablePriority = 200; - while (existingPriorities.has(availablePriority)) { - availablePriority++; - } - - console.log( - `[NSG] Template rule priority: ${templateRule.priority}, Using available priority: ${availablePriority}`, - ); - - const newRule: SecurityRule = { - protocol: templateRule.protocol, - sourcePortRange: templateRule.sourcePortRange, - destinationPortRange: templateRule.destinationPortRange, - sourceAddressPrefix: "*", - destinationAddressPrefix: templateRule.destinationAddressPrefix, - access: templateRule.access, - priority: availablePriority, - direction: templateRule.direction, - description: `Temporary E2E test rule allowing all IPs - Created at ${new Date().toISOString()}`, - }; - - console.log(`[NSG] Creating new rule: ${ruleName} with wildcard IP (*)`); - console.log( - `[NSG] Rule details: Priority=${newRule.priority}, Protocol=${newRule.protocol}, Access=${newRule.access}`, - ); - - if (!this.armNetworkClient) { - throw new Error("ARM network client not initialized"); - } - const rulePoller = await this.armNetworkClient.securityRules.beginCreateOrUpdate( - resourceGroupName, - nsgName, - ruleName, - newRule, - ); - - console.log(`[NSG] Waiting for rule creation to complete...`); - const createdRule = await rulePoller.pollUntilDone(); - - console.log(`[NSG] Rule created successfully: ${ruleName}`); - console.log(`[NSG] Rule ID: ${createdRule.id}`); - - // Step 6: Create cleanup function - const cleanup = async (): Promise => { - try { - console.log(`[NSG] Starting cleanup for rule: ${ruleName}`); - console.log(`[NSG] Verifying rule exists before deletion...`); - - const existingRule = await this.getNetworkSecurityGroupRuleAsync( - resourceGroupName, - nsgName, - ruleName, - ); - if (!existingRule) { - console.log( - `[NSG] Rule ${ruleName} not found during cleanup - may have been already deleted`, - ); - return; - } - - console.log(`[NSG] Deleting rule: ${ruleName}`); - if (!this.armNetworkClient) { - throw new Error("ARM network client not initialized"); - } - const deletePoller = await this.armNetworkClient.securityRules.beginDelete( - resourceGroupName, - nsgName, - ruleName, - ); - console.log(`[NSG] Waiting for rule deletion to complete...`); - await deletePoller.pollUntilDone(); - console.log(`[NSG] Rule deleted successfully: ${ruleName}`); - } catch (error) { - console.error(`[NSG] Failed to cleanup rule ${ruleName}:`, error); - console.error(`[NSG] Cleanup error details:`, { - message: getErrorMessage(error), - statusCode: hasStatusCode(error) ? error.statusCode : undefined, - }); - // Don't throw - cleanup failures shouldn't break tests - } - }; - - return { - publicIp, - ruleName, - resourceGroupName, - nsgName, - cleanup, - }; - } catch (error) { - console.error(`[NSG] Failed to allow public IP in NSG:`, error); - console.error(`[NSG] Error details:`, { - message: getErrorMessage(error), - statusCode: hasStatusCode(error) ? error.statusCode : undefined, - }); - throw error; - } + if (this.armNetworkClient === undefined) { + throw new Error("ARM network client not initialized"); + } + return allowPublicIpInNsg( + this.armNetworkClient, + () => this.getPublicIpAsync(), + resourceGroupName, + nsgName, + baseRuleName, + ); } } diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-auth.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-auth.ts new file mode 100644 index 0000000000..825341f6ed --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-auth.ts @@ -0,0 +1,396 @@ +import { expect } from "@playwright/test"; +import * as yaml from "yaml"; +import { RHDHDeploymentState } from "./rhdh-deployment-types"; + +export interface AuthConfigActions { + setDynamicPluginEnabled(pluginName: string, enabled: boolean): void; + setAppConfigProperty(path: string, value: unknown): void; +} + +const OIDC_CALLBACK_URL = + "${BASE_URL:-http://localhost:7007}/api/auth/oidc/handler/frame"; + +export function enableOIDCLoginWithIngestion(actions: AuthConfigActions): void { + console.log("Enabling OIDC login with ingestion..."); + expect(process.env.RHBK_BASE_URL).toBeDefined(); + expect(process.env.RHBK_REALM).toBeDefined(); + expect(process.env.RHBK_CLIENT_ID).toBeDefined(); + expect(process.env.RHBK_CLIENT_SECRET).toBeDefined(); + + actions.setDynamicPluginEnabled( + "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic", + true, + ); + actions.setAppConfigProperty("catalog.providers", { + keycloakOrg: { + default: { + baseUrl: "${RHBK_BASE_URL}", + loginRealm: "${RHBK_REALM}", + realm: "${RHBK_REALM}", + clientId: "${RHBK_CLIENT_ID}", + clientSecret: "${RHBK_CLIENT_SECRET}", + schedule: { + frequency: { minutes: 1 }, + timeout: { minutes: 1 }, + }, + }, + }, + }); + + actions.setAppConfigProperty("auth.providers.oidc", { + production: { + metadataUrl: "${RHBK_BASE_URL}/realms/${RHBK_REALM}", + clientId: "${RHBK_CLIENT_ID}", + clientSecret: "${RHBK_CLIENT_SECRET}", + prompt: "auto", + callbackUrl: OIDC_CALLBACK_URL, + }, + }); + actions.setAppConfigProperty("auth.environment", "production"); + actions.setAppConfigProperty("signInPage", "oidc"); +} + +export function enablePingFederateOIDCLogin(actions: AuthConfigActions): void { + console.log("Enabling PingFederate OIDC login..."); + expect(process.env.PINGFEDERATE_BASE_URL).toBeDefined(); + expect(process.env.PINGFEDERATE_CLIENT_ID).toBeDefined(); + expect(process.env.PINGFEDERATE_CLIENT_SECRET).toBeDefined(); + + actions.setAppConfigProperty("auth.providers.oidc", { + production: { + metadataUrl: "${PINGFEDERATE_BASE_URL}/.well-known/openid-configuration", + clientId: "${PINGFEDERATE_CLIENT_ID}", + clientSecret: "${PINGFEDERATE_CLIENT_SECRET}", + prompt: "auto", + callbackUrl: OIDC_CALLBACK_URL, + }, + }); + actions.setAppConfigProperty("auth.environment", "production"); + actions.setAppConfigProperty("signInPage", "oidc"); +} + +export function enableLDAPLoginWithIngestion(actions: AuthConfigActions): void { + console.log("Enabling LDAP login with ingestion..."); + expect(process.env.RHBK_BASE_URL).toBeDefined(); + expect(process.env.RHBK_LDAP_REALM).toBeDefined(); + expect(process.env.RHBK_LDAP_CLIENT_ID).toBeDefined(); + expect(process.env.RHBK_LDAP_CLIENT_SECRET).toBeDefined(); + + actions.setDynamicPluginEnabled( + "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-ldap-dynamic", + true, + ); + actions.setAppConfigProperty("catalog.providers", { + ldapOrg: { + default: { + target: "${LDAP_TARGET_URL}", + bind: { + dn: "${LDAP_BIND_DN}", + secret: "${LDAP_BIND_SECRET}", + }, + users: [ + { + dn: "${LDAP_USERS_DN}", + options: { + filter: "(uid=*)", + scope: "sub", + }, + }, + ], + groups: [ + { + dn: "${LDAP_GROUPS_DN}", + options: { + filter: + "(&(objectClass=group)(groupType:1.2.840.113556.1.4.803:=2147483648))", + scope: "sub", + }, + }, + ], + schedule: { + frequency: "PT1M", + timeout: "PT1M", + }, + }, + }, + }); + + actions.setAppConfigProperty("auth.providers.oidc", { + production: { + metadataUrl: "${RHBK_BASE_URL}/realms/${RHBK_LDAP_REALM}", + clientId: "${RHBK_LDAP_CLIENT_ID}", + clientSecret: "${RHBK_LDAP_CLIENT_SECRET}", + prompt: "auto", + callbackUrl: OIDC_CALLBACK_URL, + }, + }); + actions.setAppConfigProperty("auth.environment", "production"); + actions.setAppConfigProperty("signInPage", "oidc"); +} + +export function enableMicrosoftLoginWithIngestion( + actions: AuthConfigActions, +): void { + console.log("Enabling Microsoft login with ingestion..."); + expect(process.env.AUTH_PROVIDERS_AZURE_CLIENT_ID).toBeDefined(); + expect(process.env.AUTH_PROVIDERS_AZURE_CLIENT_SECRET).toBeDefined(); + expect(process.env.AUTH_PROVIDERS_AZURE_TENANT_ID).toBeDefined(); + + actions.setDynamicPluginEnabled( + "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-msgraph-dynamic", + true, + ); + actions.setAppConfigProperty("catalog.providers", { + microsoftGraphOrg: { + default: { + target: "https://graph.microsoft.com/v1.0", + authority: "https://login.microsoftonline.com", + tenantId: "${AUTH_PROVIDERS_AZURE_TENANT_ID}", + clientId: "${AUTH_PROVIDERS_AZURE_CLIENT_ID}", + clientSecret: "${AUTH_PROVIDERS_AZURE_CLIENT_SECRET}", + user: { + filter: + "accountEnabled eq true and userType eq 'member' and startswith(displayName,'TEST')", + }, + group: { + filter: + "securityEnabled eq true and mailEnabled eq false and startswith(displayName,'TEST_')\n", + }, + schedule: { + frequency: "PT1M", + timeout: "PT1M", + }, + }, + }, + }); + + actions.setAppConfigProperty("auth.providers.microsoft", { + production: { + clientId: "${AUTH_PROVIDERS_AZURE_CLIENT_ID}", + clientSecret: "${AUTH_PROVIDERS_AZURE_CLIENT_SECRET}", + prompt: "auto", + tenantId: "${AUTH_PROVIDERS_AZURE_TENANT_ID}", + callbackUrl: + "${BASE_URL:-http://localhost:7007}/api/auth/microsoft/handler/frame", + }, + }); + actions.setAppConfigProperty("auth.environment", "production"); + actions.setAppConfigProperty("signInPage", "microsoft"); +} + +export function enableGithubLoginWithIngestion( + actions: AuthConfigActions, + isRunningLocal: boolean, +): void { + console.log("Enabling Github login with ingestion..."); + expect(process.env.AUTH_PROVIDERS_GH_ORG_NAME).toBeDefined(); + expect(process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET).toBeDefined(); + expect(process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_ID).toBeDefined(); + expect(process.env.AUTH_PROVIDERS_GH_ORG_APP_ID).toBeDefined(); + expect(process.env.AUTH_PROVIDERS_GH_ORG1_PRIVATE_KEY).toBeDefined(); + expect(process.env.AUTH_PROVIDERS_GH_ORG_WEBHOOK_SECRET).toBeDefined(); + + actions.setDynamicPluginEnabled( + "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-org-dynamic", + true, + ); + + const transformerPluginPath = isRunningLocal + ? "./dynamic-plugins/dist/@internal/backstage-plugin-catalog-backend-module-github-org-transformer-dynamic" + : "oci://quay.io/rh-ee-jhe/catalog-github-org-transformer:v0.3.0!internal-backstage-plugin-catalog-backend-module-github-org-transformer"; + + actions.setDynamicPluginEnabled(transformerPluginPath, true); + + actions.setAppConfigProperty("catalog.providers", { + githubOrg: [ + { + id: "github", + githubUrl: "https://github.com", + orgs: ["${AUTH_PROVIDERS_GH_ORG_NAME}"], + schedule: { + initialDelay: { seconds: 0 }, + frequency: { minutes: 1 }, + timeout: { minutes: 1 }, + }, + }, + ], + }); + + actions.setAppConfigProperty("integrations", { + github: [ + { + host: "github.com", + apps: [ + { + appId: "${AUTH_PROVIDERS_GH_ORG_APP_ID}", + clientId: "${AUTH_PROVIDERS_GH_ORG_CLIENT_ID}", + clientSecret: "${AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET}", + privateKey: "${AUTH_PROVIDERS_GH_ORG1_PRIVATE_KEY}", + webhookSecret: "${AUTH_PROVIDERS_GH_ORG_WEBHOOK_SECRET}", + }, + ], + }, + ], + }); + + actions.setAppConfigProperty("auth.providers.github", { + production: { + clientId: "${AUTH_PROVIDERS_GH_ORG_CLIENT_ID}", + clientSecret: "${AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET}", + callbackUrl: + "${BASE_URL:-http://localhost:7007}/api/auth/github/handler/frame", + }, + }); + + actions.setAppConfigProperty("auth.environment", "production"); + actions.setAppConfigProperty("signInPage", "github"); +} + +export function enableGitlabLoginWithIngestion( + actions: AuthConfigActions, +): void { + console.log("Enabling GitLab login with ingestion..."); + expect(process.env.AUTH_PROVIDERS_GITLAB_HOST).toBeDefined(); + expect(process.env.AUTH_PROVIDERS_GITLAB_TOKEN).toBeDefined(); + expect(process.env.AUTH_PROVIDERS_GITLAB_PARENT_ORG).toBeDefined(); + + actions.setDynamicPluginEnabled( + "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-gitlab-org-dynamic", + true, + ); + + actions.setAppConfigProperty("catalog.providers", { + gitlab: { + default: { + host: "${AUTH_PROVIDERS_GITLAB_HOST}", + orgEnabled: true, + group: "${AUTH_PROVIDERS_GITLAB_PARENT_ORG}", + restrictUsersToGroup: true, + includeUsersWithoutSeat: true, + schedule: { + initialDelay: { seconds: 0 }, + frequency: { minutes: 1 }, + timeout: { minutes: 1 }, + }, + }, + }, + }); + + actions.setAppConfigProperty("integrations", { + gitlab: [ + { + host: "${AUTH_PROVIDERS_GITLAB_HOST}", + token: "${AUTH_PROVIDERS_GITLAB_TOKEN}", + apiBaseUrl: "https://${AUTH_PROVIDERS_GITLAB_HOST}/api/v4", + }, + ], + }); + + actions.setAppConfigProperty("auth.providers.gitlab", { + production: { + audience: "https://${AUTH_PROVIDERS_GITLAB_HOST}", + clientId: "${AUTH_PROVIDERS_GITLAB_CLIENT_ID}", + clientSecret: "${AUTH_PROVIDERS_GITLAB_CLIENT_SECRET}", + callbackUrl: + "${BASE_URL:-http://localhost:7007}/api/auth/gitlab/handler/frame", + }, + }); + + actions.setAppConfigProperty("auth.environment", "production"); + actions.setAppConfigProperty("signInPage", "gitlab"); +} + +export function setOIDCResolver( + actions: AuthConfigActions, + resolver: string, + dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, +): void { + actions.setAppConfigProperty( + "auth.providers.oidc.production.signIn.resolvers", + [ + { + resolver, + dangerouslyAllowSignInWithoutUserInCatalog, + }, + ], + ); +} + +export function setMicrosoftResolver( + actions: AuthConfigActions, + resolver: string, + dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, +): void { + actions.setAppConfigProperty( + "auth.providers.microsoft.production.signIn.resolvers", + [ + { + resolver, + dangerouslyAllowSignInWithoutUserInCatalog, + }, + ], + ); +} + +export function setGithubResolver( + actions: AuthConfigActions, + resolver: string, + dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, +): void { + actions.setAppConfigProperty( + "auth.providers.github.production.signIn.resolvers", + [ + { + resolver, + dangerouslyAllowSignInWithoutUserInCatalog, + }, + ], + ); +} + +export function setGitlabResolver( + actions: AuthConfigActions, + resolver: string, + dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, +): void { + actions.setAppConfigProperty( + "auth.providers.gitlab.production.signIn.resolvers", + [ + { + resolver, + dangerouslyAllowSignInWithoutUserInCatalog, + }, + ], + ); +} + +export function setDynamicPluginEnabled( + state: RHDHDeploymentState, + pluginName: string, + enabled: boolean, +): void { + const plugin = state.dynamicPluginsConfig.plugins.find( + (p) => p.package === pluginName, + ); + if (plugin === undefined) { + state.dynamicPluginsConfig.plugins = [ + ...state.dynamicPluginsConfig.plugins, + { + package: pluginName, + disabled: !enabled, + }, + ]; + console.log( + `Plugin ${pluginName} has been added to the dynamic plugins config and set to ${enabled ? "enabled" : "disabled"}.`, + ); + return; + } + plugin.disabled = !enabled; + console.log( + `Plugin ${pluginName} has been ${enabled ? "enabled" : "disabled"}.`, + ); +} + +export function printDynamicPluginsConfig(state: RHDHDeploymentState): void { + console.log(yaml.stringify(state.dynamicPluginsConfig.plugins)); +} diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-catalog.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-catalog.ts new file mode 100644 index 0000000000..2ce4fd7d53 --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-catalog.ts @@ -0,0 +1,158 @@ +import { GroupEntity } from "@backstage/catalog-model"; +import { APIHelper } from "../api-helper"; +import { + getCatalogGroups, + getCatalogUsers, + isGroupEntity, + isUserEntity, + RHDHDeploymentState, +} from "./rhdh-deployment-types"; + +export function parseGroupMemberFromEntity(group: GroupEntity): string[] { + if (group.relations === undefined) { + return []; + } + return group.relations + .filter((r) => r.type === "hasMember") + .map((r) => r.targetRef.split("/")[1]); +} + +export function parseGroupChildrenFromEntity(group: GroupEntity): string[] { + if (group.relations === undefined) { + return []; + } + return group.relations + .filter((r) => r.type === "parentOf") + .map((r) => r.targetRef.split("/")[1]); +} + +export function parseGroupParentFromEntity(group: GroupEntity): string[] { + if (group.relations === undefined) { + return []; + } + return group.relations + .filter((r) => r.type === "childOf") + .map((r) => r.targetRef.split("/")[1]); +} + +async function createCatalogApi( + state: RHDHDeploymentState, + computeBackstageBackendUrl: () => Promise, +): Promise { + const api = new APIHelper(); + api.UseStaticToken(state.staticToken); + api.UseBaseUrl(await computeBackstageBackendUrl()); + return api; +} + +export async function checkUserIsIngestedInCatalog( + state: RHDHDeploymentState, + users: string[], + computeBackstageBackendUrl: () => Promise, +): Promise { + const api = await createCatalogApi(state, computeBackstageBackendUrl); + const response: unknown = await api.getAllCatalogUsersFromAPI(); + const catalogUsers = getCatalogUsers(response); + const { expect } = await import("@playwright/test"); + expect(catalogUsers.length).toBeGreaterThan(0); + const catalogUsersDisplayNames: string[] = catalogUsers + .map((u) => u.spec.profile?.displayName) + .filter((name): name is string => name !== undefined); + console.log( + `Checking ${JSON.stringify(catalogUsersDisplayNames)} contains users ${JSON.stringify(users)}`, + ); + return users.every((elem) => catalogUsersDisplayNames.includes(elem)); +} + +export async function checkGroupIsIngestedInCatalog( + state: RHDHDeploymentState, + groups: string[], + computeBackstageBackendUrl: () => Promise, +): Promise { + const api = await createCatalogApi(state, computeBackstageBackendUrl); + const response: unknown = await api.getAllCatalogGroupsFromAPI(); + const catalogGroups = getCatalogGroups(response); + const { expect } = await import("@playwright/test"); + expect(catalogGroups.length).toBeGreaterThan(0); + const catalogGroupsDisplayNames: string[] = catalogGroups + .map((u) => u.spec.profile?.displayName) + .filter((name): name is string => name !== undefined); + console.log( + `Checking ${JSON.stringify(catalogGroupsDisplayNames)} contains groups ${JSON.stringify(groups)}`, + ); + return groups.every((elem) => catalogGroupsDisplayNames.includes(elem)); +} + +export async function checkUserIsInGroup( + state: RHDHDeploymentState, + user: string, + group: string, + computeBackstageBackendUrl: () => Promise, +): Promise { + const api = await createCatalogApi(state, computeBackstageBackendUrl); + const entity: unknown = await api.getGroupEntityFromAPI(group); + if (!isGroupEntity(entity)) { + throw new Error(`Invalid group entity for ${group}`); + } + const members = parseGroupMemberFromEntity(entity); + console.log( + `Checking group ${group} (${JSON.stringify(members)}) contains user ${user}`, + ); + return members.includes(user); +} + +export async function checkGroupIsParentOfGroup( + state: RHDHDeploymentState, + parent: string, + child: string, + computeBackstageBackendUrl: () => Promise, +): Promise { + const api = await createCatalogApi(state, computeBackstageBackendUrl); + const entity: unknown = await api.getGroupEntityFromAPI(parent); + if (!isGroupEntity(entity)) { + throw new Error(`Invalid group entity for ${parent}`); + } + const children = parseGroupChildrenFromEntity(entity); + console.log( + `Checking children of ${parent} (${JSON.stringify(children)}) contain group ${child}`, + ); + return children.includes(child); +} + +export async function checkGroupIsChildOfGroup( + state: RHDHDeploymentState, + child: string, + parent: string, + computeBackstageBackendUrl: () => Promise, +): Promise { + const api = await createCatalogApi(state, computeBackstageBackendUrl); + const entity: unknown = await api.getGroupEntityFromAPI(child); + if (!isGroupEntity(entity)) { + throw new Error(`Invalid group entity for ${child}`); + } + const parents = parseGroupParentFromEntity(entity); + console.log( + `Checking parents of ${child} (${JSON.stringify(parents)}) contain group ${parent}`, + ); + return parents.includes(parent); +} + +export async function checkUserHasAnnotation( + state: RHDHDeploymentState, + user: string, + annotationKey: string, + expectedValue: string, + computeBackstageBackendUrl: () => Promise, +): Promise { + const api = await createCatalogApi(state, computeBackstageBackendUrl); + const entity: unknown = await api.getCatalogUserFromAPI(user); + if (!isUserEntity(entity)) { + throw new Error(`Invalid user entity for ${user}`); + } + const annotations = entity.metadata?.annotations ?? {}; + const actualValue = annotations[annotationKey]; + console.log( + `Checking user ${user} has annotation ${annotationKey}=${expectedValue}, actual value: ${actualValue}`, + ); + return actualValue === expectedValue; +} diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-k8s.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-k8s.ts new file mode 100644 index 0000000000..2cc6cde9cd --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-k8s.ts @@ -0,0 +1,464 @@ +import * as k8s from "@kubernetes/client-node"; +import * as yaml from "yaml"; +import { promises as fs } from "fs"; +import { join, resolve as resolvePath } from "path"; +import { expect } from "@playwright/test"; +import { spawn } from "child_process"; +import { hasErrorResponse } from "../errors"; +import { + BackstageCr, + currentDirName, + isBackstageCr, + isDynamicPluginsConfig, + isRecord, + RHDHDeploymentState, + rootDirName, +} from "./rhdh-deployment-types"; +import { + ensureBackstageCRIsAvailable, + waitForDeploymentReady, +} from "./rhdh-deployment-wait"; + +export async function readYamlToJson(filePath: string): Promise { + const fileContent = await fs.readFile(filePath, "utf8"); + return yaml.parse(fileContent); +} + +export async function createNamespace( + state: RHDHDeploymentState, +): Promise { + if (state.isRunningLocal) { + console.log("Skipping namespace creation as isRunningLocal is true."); + return; + } + + const namespaceObj: k8s.V1Namespace = { + apiVersion: "v1", + kind: "Namespace", + metadata: { + name: state.namespace, + }, + }; + + try { + await state.k8sApi.createNamespace(namespaceObj); + } catch (e) { + if (hasErrorResponse(e) && e.response?.statusCode === 409) { + return; + } + throw e; + } +} + +async function createConfigMap( + state: RHDHDeploymentState, + name: string, + data: Record, +): Promise { + const configMap: k8s.V1ConfigMap = { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { + name, + namespace: state.namespace, + }, + data, + }; + await state.k8sApi.createNamespacedConfigMap(state.namespace, configMap); +} + +async function updateConfigMap( + state: RHDHDeploymentState, + name: string, + data: Record, +): Promise { + if (state.isRunningLocal) { + console.log("Skipping configmap update as isRunningLocal is true."); + return; + } + + const patch = [{ op: "replace", path: "/data", value: data }]; + await state.k8sApi.patchNamespacedConfigMap( + name, + state.namespace, + patch, + undefined, + undefined, + undefined, + undefined, + undefined, + { headers: { "Content-Type": "application/json-patch+json" } }, + ); +} + +export async function loadBaseConfig( + state: RHDHDeploymentState, +): Promise { + const configPath = join(currentDirName, "yamls", "configmap.yaml"); + const yamlContent = await fs.readFile(configPath, "utf8"); + const configData: unknown = yaml.parse(yamlContent); + + if (isRecord(configData)) { + state.appConfig = configData; + } +} + +export async function createAppConfig( + state: RHDHDeploymentState, +): Promise { + if (state.isRunningLocal) { + const appConfigPath = join(currentDirName, "app-config.test.yaml"); + const appConfigYaml = yaml.stringify(state.appConfig); + await fs.writeFile(appConfigPath, appConfigYaml, "utf8"); + console.log(`App config written to ${appConfigPath}`); + return; + } + + await createConfigMap(state, state.appConfigMap, { + "app-config.yaml": yaml.stringify(state.appConfig), + }); +} + +export async function updateAppConfig( + state: RHDHDeploymentState, +): Promise { + if (state.isRunningLocal) { + const appConfigPath = join(currentDirName, "app-config.test.yaml"); + const appConfigYaml = yaml.stringify(state.appConfig); + await fs.writeFile(appConfigPath, appConfigYaml, "utf8"); + console.log(`App config updated in ${appConfigPath}`); + return; + } + + await updateConfigMap(state, state.appConfigMap, { + "app-config.yaml": yaml.stringify(state.appConfig), + }); +} + +export async function deleteConfigMap( + state: RHDHDeploymentState, +): Promise { + await state.k8sApi.deleteNamespacedConfigMap( + state.appConfigMap, + state.namespace, + ); +} + +export async function createSecret(state: RHDHDeploymentState): Promise { + if (state.isRunningLocal) { + console.log("Skipping secret creation as isRunningLocal is true."); + return; + } + const secret: k8s.V1Secret = { + apiVersion: "v1", + kind: "Secret", + metadata: { + name: state.secretName, + namespace: state.namespace, + }, + data: state.secretData, + }; + await state.k8sApi.createNamespacedSecret(state.namespace, secret); +} + +export async function updateSecret(state: RHDHDeploymentState): Promise { + if (state.isRunningLocal) { + console.log("Skipping secret update as isRunningLocal is true."); + return; + } + const secret: k8s.V1Secret = { + apiVersion: "v1", + kind: "Secret", + metadata: { + name: state.secretName, + namespace: state.namespace, + }, + data: state.secretData, + }; + await state.k8sApi.replaceNamespacedSecret( + state.secretName, + state.namespace, + secret, + ); +} + +export async function deleteSecret(state: RHDHDeploymentState): Promise { + if (state.isRunningLocal) { + console.log("Skipping secret deletion as isRunningLocal is true."); + return; + } + await state.k8sApi.deleteNamespacedSecret(state.secretName, state.namespace); +} + +export async function loadRbacConfig( + state: RHDHDeploymentState, +): Promise { + const configPath = join(currentDirName, "yamls", "rbac-policy.csv"); + state.rbacConfig = await fs.readFile(configPath, "utf8"); +} + +export async function createRbacConfig( + state: RHDHDeploymentState, +): Promise { + if (state.isRunningLocal) { + const rbacConfigPath = join(currentDirName, "rbac.test.csv"); + await fs.writeFile(rbacConfigPath, state.rbacConfig, "utf8"); + console.log(`RBAC config written to ${rbacConfigPath}`); + return; + } + + await createConfigMap(state, state.rbacConfigMap, { + "rbac-policy.csv": state.rbacConfig, + }); +} + +export async function updateRbacConfig( + state: RHDHDeploymentState, +): Promise { + if (state.isRunningLocal) { + const rbacConfigPath = join(currentDirName, "rbac.test.csv"); + await fs.writeFile(rbacConfigPath, state.rbacConfig, "utf8"); + console.log(`RBAC config updated in ${rbacConfigPath}`); + return; + } + + await updateConfigMap(state, state.rbacConfigMap, { + "rbac-policy.csv": state.rbacConfig, + }); +} + +export async function loadDynamicPluginsConfig( + state: RHDHDeploymentState, +): Promise { + const configPath = join( + currentDirName, + "yamls", + "dynamic-plugins-config.yaml", + ); + const yamlContent = await fs.readFile(configPath, "utf8"); + const configData: unknown = yaml.parse(yamlContent); + + if (isDynamicPluginsConfig(configData)) { + state.dynamicPluginsConfig = configData; + } +} + +export async function createDynamicPluginsConfig( + state: RHDHDeploymentState, + setAppConfigProperty: (path: string, value: unknown) => void, + updateAppConfigFn: (state: RHDHDeploymentState) => Promise, +): Promise { + if (state.isRunningLocal) { + const dynamicPluginsConfigPath = join( + currentDirName, + "dynamic-plugins.test.yaml", + ); + const dynamicPluginsConfigYaml = yaml.stringify(state.dynamicPluginsConfig); + await fs.writeFile( + dynamicPluginsConfigPath, + dynamicPluginsConfigYaml, + "utf8", + ); + console.log( + `Dynamic plugins config written to ${dynamicPluginsConfigPath}`, + ); + setAppConfigProperty( + "dynamicPlugins.rootDirectory", + rootDirName + "/dynamic-plugins-root", + ); + await updateAppConfigFn(state); + return; + } + + await createConfigMap(state, state.dynamicPluginsConfigMap, { + "dynamic-plugins.yaml": yaml.stringify(state.dynamicPluginsConfig), + }); +} + +export async function updateDynamicPluginsConfig( + state: RHDHDeploymentState, +): Promise { + if (state.isRunningLocal) { + const dynamicPluginsConfigPath = join( + currentDirName, + "dynamic-plugins.test.yaml", + ); + const dynamicPluginsConfigYaml = yaml.stringify(state.dynamicPluginsConfig); + await fs.writeFile( + dynamicPluginsConfigPath, + dynamicPluginsConfigYaml, + "utf8", + ); + console.log( + `Dynamic plugins config updated in ${dynamicPluginsConfigPath}`, + ); + console.log( + "Dynamic plugins config in dynamic-plugins.test.yaml has no effect on local deployment. Make sure to update the app-config.test.yaml file to use the dynamic-plugins-root directory and your plugin are already copied there.", + ); + return; + } + + await updateConfigMap(state, state.dynamicPluginsConfigMap, { + "dynamic-plugins.yaml": yaml.stringify(state.dynamicPluginsConfig), + }); +} + +export async function loadBackstageCR( + state: RHDHDeploymentState, +): Promise { + const configPath = join(currentDirName, "yamls", "backstage.yaml"); + const parsed: unknown = await readYamlToJson(configPath); + if (!isBackstageCr(parsed)) { + throw new Error("Invalid Backstage CR config"); + } + const imageRegistry = process.env.IMAGE_REGISTRY ?? "quay.io"; + const imageRepo = + process.env.IMAGE_REPO ?? process.env.QUAY_REPO ?? undefined; + const tagName = process.env.TAG_NAME; + expect(imageRepo, "IMAGE_REPO or QUAY_REPO must be set").toBeTruthy(); + expect(tagName, "TAG_NAME must be set").toBeTruthy(); + const image = `${imageRegistry}/${imageRepo}:${tagName}`; + parsed.spec.deployment = { + patch: { + spec: { + template: { + spec: { + containers: [ + { + name: "backstage-backend", + image, + imagePullPolicy: "Always", + }, + ], + }, + }, + }, + }, + }; + console.log(`Setting Backstage CR image via deployment.patch to ${image}`); + state.cr = parsed; + state.instanceName = parsed.metadata.name; + return parsed; +} + +export async function applyCustomResource( + state: RHDHDeploymentState, + resource: BackstageCr, +): Promise { + console.log("Applying CR."); + try { + const customObjectsApi = state.kc.makeApiClient(k8s.CustomObjectsApi); + await customObjectsApi.createNamespacedCustomObject( + resource.apiVersion.split("/")[0], + resource.apiVersion.split("/")[1], + state.namespace, + resource.kind.toLowerCase() + "s", + resource, + ); + } catch (e) { + console.error(JSON.stringify(e)); + throw e; + } +} + +function startLocalBackstageProcess(state: RHDHDeploymentState): void { + state.runningProcess = spawn( + "yarn", + [ + "dev", + "--env-mode=loose", + "--", + "--config", + currentDirName + "/app-config.test.yaml", + "--config", + currentDirName + "/dynamic-plugins.test.yaml", + ], + { + shell: true, + cwd: resolvePath(rootDirName), + detached: true, + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }, + ); + state.runningProcess.unref(); + console.log( + `Local production server started with PID: ${state.runningProcess.pid}`, + ); +} + +export async function createBackstageDeployment( + state: RHDHDeploymentState, +): Promise { + try { + if (state.isRunningLocal) { + startLocalBackstageProcess(state); + return; + } + await ensureBackstageCRIsAvailable(state, 60000); + const backstageConfig = await loadBackstageCR(state); + await applyCustomResource(state, backstageConfig); + await waitForDeploymentReady(state); + } catch (e) { + console.log(JSON.stringify(e)); + throw e; + } +} + +export async function killRunningProcess( + state: RHDHDeploymentState, + getBackstageUrl: () => Promise, +): Promise { + const processPid = state.runningProcess?.pid; + if (processPid === undefined || processPid === 0) { + console.log("No running process to kill."); + return; + } + + const killed = process.kill(-processPid); + console.log("Local production server process killed?", killed); + + await new Promise((resolvePromise) => { + state.runningProcess?.on("exit", () => { + setTimeout(() => { + console.log("Process termination timeout reached after 5 seconds."); + state.runningProcess = null; + resolvePromise(); + }, 5000); + }); + }); + + const baseUrl = await getBackstageUrl(); + try { + const response = await fetch(baseUrl, { method: "HEAD" }); + if (response.status === 200) { + throw new Error("Homepage is still accessible after process termination"); + } + } catch (error) { + console.log("Homepage is not accessible as expected: ", error); + } +} + +export function computeBackstageUrl(state: RHDHDeploymentState): string { + if (state.isRunningLocal) { + return "http://localhost:3000"; + } + const cluster = state.kc.getCurrentCluster(); + if (cluster?.server === undefined || cluster.server === "") { + throw new Error("Unable to retrieve cluster information."); + } + const regex = /^https?:\/\/(?:api\.)?([^:/]+)/u; + const match = cluster.server.match(regex); + const clusterBaseUrl = match?.[1] ?? ""; + if (clusterBaseUrl === "") { + console.log("No match found."); + } + return `https://backstage-${state.instanceName}-${state.namespace}.apps.${clusterBaseUrl}`; +} + +export function computeBackstageBackendUrl(state: RHDHDeploymentState): string { + if (state.isRunningLocal) { + return "http://localhost:7007"; + } + return computeBackstageUrl(state); +} diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-logs.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-logs.ts new file mode 100644 index 0000000000..ffe70801c4 --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-logs.ts @@ -0,0 +1,214 @@ +import * as k8s from "@kubernetes/client-node"; +import stream from "stream"; +import { getErrorMessage, hasErrorResponse } from "../errors"; +import { + RHDHDeploymentState, + sleep, + syncedLogRegex, +} from "./rhdh-deployment-types"; + +async function resolvePodName( + state: RHDHDeploymentState, + podName: string | undefined, + podLabels: Record | undefined, +): Promise { + if (podName !== undefined && podName !== "") { + return podName; + } + + if (podLabels === undefined) { + throw new Error("Either podName or podLabels must be provided"); + } + + try { + const labelSelector = Object.entries(podLabels) + .map(([key, value]) => `${key}=${value}`) + .join(","); + + const pods = await state.k8sApi.listNamespacedPod( + state.namespace, + undefined, + undefined, + undefined, + "status.phase=Running", + labelSelector, + ); + + if (pods.body.items.length === 0) { + throw new Error(`No pod found with labels: ${labelSelector}`); + } + + const activePods = pods.body.items.filter((pod) => { + const isTerminating = pod.metadata?.deletionTimestamp !== undefined; + return !isTerminating; + }); + + if (activePods.length === 0) { + throw new Error(`No active pods found with labels: ${labelSelector}`); + } + + const pod = activePods[0]; + const resolvedName = pod.metadata?.name; + if (resolvedName === undefined || resolvedName === "") { + throw new Error(`Pod name missing for labels: ${labelSelector}`); + } + return resolvedName; + } catch (error) { + throw new Error(`Error getting pod name: ${getErrorMessage(error)}`, { + cause: error, + }); + } +} + +async function streamPodLogsUntilMatch( + state: RHDHDeploymentState, + podName: string, + searchString: RegExp, + timeoutMs: number, +): Promise { + console.log(`Reading logs for pod ${podName}`); + const startTime = Date.now(); + let found = false; + const log = new k8s.Log(state.kc); + const logStream = new stream.PassThrough(); + + logStream.on("data", (chunk: Buffer | string) => { + const text = typeof chunk === "string" ? chunk : chunk.toString(); + if (searchString.test(text)) { + process.stdout.write(chunk); + found = true; + } + }); + + logStream.on("error", (error) => { + throw new Error(`Error getting pod name: ${getErrorMessage(error)}`); + }); + + logStream.on("end", () => { + console.log("Log stream ended."); + }); + + await log.log(state.namespace, podName, "backstage-backend", logStream, { + follow: true, + tailLines: 1, + pretty: false, + timestamps: false, + }); + + while (Date.now() - startTime < timeoutMs) { + if (found) { + break; + } + await sleep(1000); + } + if (found) { + logStream.end(); + logStream.removeAllListeners(); + } + return found; +} + +export async function followPodLogs( + state: RHDHDeploymentState, + searchString: RegExp, + podName?: string, + podLabels?: Record, + timeoutMs: number = 300000, +): Promise { + const resolvedPodName = await resolvePodName(state, podName, podLabels); + + try { + return await streamPodLogsUntilMatch( + state, + resolvedPodName, + searchString, + timeoutMs, + ); + } catch (error) { + const message = hasErrorResponse(error) + ? error.body?.message + : getErrorMessage(error); + console.log(`Error: ${message}`); + throw new Error( + `Timeout waiting for string "${searchString}" in logs after ${timeoutMs}ms. Error: ${message}`, + { cause: error }, + ); + } +} + +export async function followLocalLogs( + state: RHDHDeploymentState, + searchString: RegExp, + timeoutMs: number = 30000, +): Promise { + if (!state.isRunningLocal) { + throw new Error("Not running in local mode. Cannot follow local logs."); + } + + let found = false; + + console.log( + "Following logs from the local production server. Looking for string: ", + searchString, + ); + + const logStream = new stream.PassThrough(); + state.runningProcess?.stdout?.pipe(logStream); + + logStream.on("data", (chunk: Buffer | string) => { + const text = typeof chunk === "string" ? chunk : chunk.toString(); + const isLocalDebug = + process.env.ISRUNNINGLOCAL === "true" && + process.env.ISRUNNINGLOCALDEBUG !== undefined && + process.env.ISRUNNINGLOCALDEBUG !== ""; + if (isLocalDebug) { + console.log(`\t${text.replaceAll("\n", "\t")}`); + } + if (searchString.test(text)) { + console.log("Found string in local logs."); + found = true; + } + }); + + logStream.on("error", (error) => { + throw new Error(`Error reading local logs: ${getErrorMessage(error)}`); + }); + + logStream.on("end", () => { + console.log("Local log stream ended."); + }); + + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + if (found) { + break; + } + await sleep(1000); + } + + return found; +} + +export function followLogs( + state: RHDHDeploymentState, + searchString: RegExp, + timeoutMs: number = 300000, +): Promise { + if (state.isRunningLocal) { + return followLocalLogs(state, searchString, timeoutMs); + } + return followPodLogs( + state, + searchString, + undefined, + { "rhdh.redhat.com/app": `backstage-${state.instanceName}` }, + timeoutMs, + ); +} + +export async function waitForSynced(state: RHDHDeploymentState): Promise { + const synced = await followLogs(state, syncedLogRegex, 120000); + const { expect } = await import("@playwright/test"); + expect(synced).toBe(true); + await sleep(2000); +} diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-types.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-types.ts new file mode 100644 index 0000000000..e8138d36b8 --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-types.ts @@ -0,0 +1,124 @@ +import { GroupEntity, UserEntity } from "@backstage/catalog-model"; +import { ChildProcess } from "child_process"; +import * as k8s from "@kubernetes/client-node"; +import { resolve as resolvePath } from "path"; + +export type YamlConfig = Record; + +export interface DynamicPluginConfig { + package: string; + disabled?: boolean; +} + +export type DynamicPluginsConfig = Record & { + plugins: DynamicPluginConfig[]; +}; + +export interface BackstageCrSpec { + replicas?: number; + deployment?: unknown; +} + +export interface BackstageCr { + apiVersion: string; + kind: string; + metadata: { name: string }; + spec: BackstageCrSpec; +} + +export interface RHDHDeploymentState { + instanceName: string; + kc: k8s.KubeConfig; + k8sApi: k8s.CoreV1Api; + appsV1Api: k8s.AppsV1Api; + namespace: string; + appConfigMap: string; + rbacConfigMap: string; + dynamicPluginsConfigMap: string; + secretName: string; + appConfig: YamlConfig; + dynamicPluginsConfig: DynamicPluginsConfig; + rbacConfig: string; + secretData: Record; + isRunningLocal: boolean; + runningProcess: ChildProcess | null; + staticToken: string; + cr: BackstageCr; + configReconcileBaselineGeneration: number | undefined; +} + +export function sleep(ms: number): Promise { + return new Promise((resolvePromise) => { + setTimeout(() => { + resolvePromise(); + }, ms); + }); +} + +export function isRecord(value: unknown): value is YamlConfig { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isBackstageCr(value: unknown): value is BackstageCr { + return ( + isRecord(value) && + typeof value.apiVersion === "string" && + typeof value.kind === "string" && + isRecord(value.metadata) && + typeof value.metadata.name === "string" && + isRecord(value.spec) + ); +} + +export function isDynamicPluginsConfig( + value: unknown, +): value is DynamicPluginsConfig { + if (!isRecord(value)) { + return false; + } + const { plugins } = value; + return ( + plugins === undefined || + (Array.isArray(plugins) && + plugins.every( + (plugin) => isRecord(plugin) && typeof plugin.package === "string", + )) + ); +} + +export function isUserEntity(value: unknown): value is UserEntity { + return isRecord(value) && value.kind === "User"; +} + +export function isGroupEntity(value: unknown): value is GroupEntity { + return isRecord(value) && value.kind === "Group"; +} + +export function getCatalogUsers(response: unknown): UserEntity[] { + if (!isRecord(response) || !Array.isArray(response.items)) { + return []; + } + return response.items.filter(isUserEntity); +} + +export function getCatalogGroups(response: unknown): GroupEntity[] { + if (!isRecord(response) || !Array.isArray(response.items)) { + return []; + } + return response.items.filter(isGroupEntity); +} + +export const currentDirName = import.meta.dirname; +export const rootDirName = resolvePath(currentDirName, "..", "..", "..", ".."); + +export const syncedLogRegex = + /(Committed \d+ (Keycloak|msgraph|GitHub|LDAP|GitLab) users? and \d+ (Keycloak|msgraph|GitHub|LDAP|GitLab) groups? in \d+(\.\d+)? seconds|Scanned \d+ users? and processed \d+ users?)/u; + +export function isRunningLocalMode(): boolean { + return process.env.ISRUNNINGLOCAL === "true"; +} + +export function shouldUseKubernetesClient(): boolean { + const isRunningLocalEnv = process.env.ISRUNNINGLOCAL; + return isRunningLocalEnv === undefined || isRunningLocalEnv === "false"; +} diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-wait.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-wait.ts new file mode 100644 index 0000000000..436e8fb850 --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-wait.ts @@ -0,0 +1,381 @@ +import * as k8s from "@kubernetes/client-node"; +import { getErrorMessage, hasErrorResponse } from "../errors"; +import { + BackstageCr, + RHDHDeploymentState, + sleep, +} from "./rhdh-deployment-types"; + +const BACKSTAGE_LABELS = { + "app.kubernetes.io/name": "backstage", +} as const; + +function buildLabelSelector(instanceName: string): string { + const labels = { + ...BACKSTAGE_LABELS, + "app.kubernetes.io/instance": instanceName, + }; + return Object.entries(labels) + .map(([key, value]) => `${key}=${value}`) + .join(","); +} + +export async function getDeploymentGeneration( + state: RHDHDeploymentState, +): Promise { + const labelSelector = buildLabelSelector(state.instanceName); + + const deployments = await state.appsV1Api.listNamespacedDeployment( + state.namespace, + undefined, + undefined, + undefined, + undefined, + labelSelector, + ); + + if (deployments.body.items.length === 0) { + throw new Error(`No deployment found with labels: ${labelSelector}`); + } + + return deployments.body.items[0].metadata?.generation ?? 0; +} + +export async function waitForConfigReconciled( + state: RHDHDeploymentState, + timeoutMs: number = 60000, +): Promise { + if (state.isRunningLocal) { + return; + } + + const baseline = + state.configReconcileBaselineGeneration ?? + (await getDeploymentGeneration(state)); + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const currentGeneration = await getDeploymentGeneration(state); + if (currentGeneration > baseline) { + console.log( + `[INFO] Config reconciled - deployment generation ${baseline} -> ${currentGeneration}`, + ); + return; + } + await sleep(1000); + } + + console.log( + `[INFO] No deployment generation change after ${timeoutMs}ms, proceeding`, + ); +} + +function hasRolloutStarted( + initialGeneration: number, + currentGeneration: number, + observedGeneration: number, + isProgressing: boolean, +): boolean { + return ( + currentGeneration > initialGeneration || + observedGeneration < currentGeneration || + isProgressing + ); +} + +function isDeploymentReady( + deployment: k8s.V1Deployment, + cr: BackstageCr, +): boolean { + const conditions = deployment.status?.conditions ?? []; + const currentGeneration = deployment.metadata?.generation ?? 0; + const observedGeneration = deployment.status?.observedGeneration ?? 0; + + const isAvailable = conditions.some( + (condition) => + condition.type === "Available" && condition.status === "True", + ); + + const isProgressingWithRollout = conditions.some( + (condition) => + condition.type === "Progressing" && + condition.status === "True" && + condition.reason !== "NewReplicaSetAvailable", + ); + + const replicas = deployment.spec?.replicas; + const desiredReplicas = cr.spec.replicas ?? 1; + const availableReplicas = deployment.status?.availableReplicas ?? 0; + const readyReplicas = deployment.status?.readyReplicas ?? 0; + const updatedReplicas = deployment.status?.updatedReplicas ?? 0; + + const replicasMatch = + availableReplicas === desiredReplicas && + readyReplicas === desiredReplicas && + updatedReplicas === desiredReplicas; + + return ( + isAvailable && + !isProgressingWithRollout && + replicas === desiredReplicas && + replicasMatch && + observedGeneration >= currentGeneration + ); +} + +async function waitForRolloutStart( + state: RHDHDeploymentState, + labelSelector: string, + rolloutStartTimeout: number, + startTime: number, +): Promise<{ rolloutStarted: boolean; initialGeneration: number }> { + let initialGeneration = 0; + let rolloutStarted = false; + + while (Date.now() - startTime < rolloutStartTimeout) { + const deployments = await state.appsV1Api.listNamespacedDeployment( + state.namespace, + undefined, + undefined, + undefined, + undefined, + labelSelector, + ); + + if (deployments.body.items.length === 0) { + throw new Error(`No deployment found with labels: ${labelSelector}`); + } + + const deployment = deployments.body.items[0]; + const conditions = deployment.status?.conditions ?? []; + + if (initialGeneration === 0) { + initialGeneration = deployment.metadata?.generation ?? 0; + console.log(`[INFO] Initial deployment generation: ${initialGeneration}`); + } + + const currentGeneration = deployment.metadata?.generation ?? 0; + const observedGeneration = deployment.status?.observedGeneration ?? 0; + const isProgressing = conditions.some( + (condition) => + condition.type === "Progressing" && condition.status === "True", + ); + + if ( + hasRolloutStarted( + initialGeneration, + currentGeneration, + observedGeneration, + isProgressing, + ) + ) { + rolloutStarted = true; + console.log( + `[INFO] Rollout detected - Generation: ${currentGeneration}, Observed: ${observedGeneration}`, + ); + return { rolloutStarted, initialGeneration }; + } + + const elapsedSinceStart = Date.now() - startTime; + console.log( + `[INFO] Waiting for rollout to start... (${Math.round(elapsedSinceStart / 1000)}s elapsed)`, + ); + await sleep(2000); + } + + console.log( + `[INFO] No rollout detected after ${rolloutStartTimeout}ms, checking if deployment is already ready`, + ); + return { rolloutStarted: true, initialGeneration }; +} + +async function pollDeploymentReady( + state: RHDHDeploymentState, + labelSelector: string, + timeoutMs: number, + startTime: number, +): Promise { + const rolloutStartTimeout = 60000; + const { rolloutStarted } = await waitForRolloutStart( + state, + labelSelector, + rolloutStartTimeout, + startTime, + ); + + if (!rolloutStarted) { + throw new Error("Rollout did not start within timeout"); + } + + while (Date.now() - startTime < timeoutMs) { + try { + const deployments = await state.appsV1Api.listNamespacedDeployment( + state.namespace, + undefined, + undefined, + undefined, + undefined, + labelSelector, + ); + + if (deployments.body.items.length === 0) { + throw new Error(`No deployment found with labels: ${labelSelector}`); + } + + const deployment = deployments.body.items[0]; + + if (isDeploymentReady(deployment, state.cr)) { + await sleep(5000); + return; + } + + const desiredReplicas = state.cr.spec.replicas ?? 1; + const availableReplicas = deployment.status?.availableReplicas ?? 0; + const readyReplicas = deployment.status?.readyReplicas ?? 0; + const updatedReplicas = deployment.status?.updatedReplicas ?? 0; + const observedGeneration = deployment.status?.observedGeneration ?? 0; + const currentGeneration = deployment.metadata?.generation ?? 0; + + console.log( + `[INFO] Deployment is progressing - Available: ${availableReplicas}, Ready: ${readyReplicas}, Updated: ${updatedReplicas}, Desired: ${desiredReplicas}, Observed Gen: ${observedGeneration}/${currentGeneration}`, + ); + + await sleep(5000); + } catch (error) { + if (Date.now() - startTime >= timeoutMs) { + throw new Error( + `Timeout waiting for deployment to be ready: ${getErrorMessage(error)}`, + { cause: error }, + ); + } + await sleep(5000); + } + } + + throw new Error( + `Timeout waiting for deployment to be ready after ${timeoutMs}ms`, + ); +} + +export async function waitForDeploymentReady( + state: RHDHDeploymentState, + timeoutMs: number = 600000, +): Promise { + if (state.isRunningLocal) { + console.log("Skipping deployment ready check as isRunningLocal is true."); + return; + } + + const labelSelector = buildLabelSelector(state.instanceName); + const startTime = Date.now(); + await pollDeploymentReady(state, labelSelector, timeoutMs, startTime); +} + +export async function waitForNamespaceActive( + state: RHDHDeploymentState, + timeoutMs: number = 30000, +): Promise { + const startTime = Date.now(); + if (state.isRunningLocal) { + console.log("Skipping namespace active check as isRunningLocal is true."); + return; + } + + while (Date.now() - startTime < timeoutMs) { + try { + const response = await state.k8sApi.readNamespace(state.namespace); + const phase = response.body.status?.phase; + + if (phase === "Active") { + return; + } + + await sleep(1000); + } catch (error) { + if (Date.now() - startTime >= timeoutMs) { + throw new Error( + `Timeout waiting for namespace to be active: ${getErrorMessage(error)}`, + { cause: error }, + ); + } + await sleep(1000); + } + } + + throw new Error( + `Timeout waiting for namespace to be active after ${timeoutMs}ms`, + ); +} + +export async function ensureBackstageCRIsAvailable( + state: RHDHDeploymentState, + timeoutMs: number = 60000, +): Promise { + if (state.isRunningLocal) { + console.log("Skipping CRD check as isRunningLocal is true."); + return; + } + + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + try { + const customObjectsApi = state.kc.makeApiClient(k8s.CustomObjectsApi); + await customObjectsApi.getClusterCustomObject( + "apiextensions.k8s.io", + "v1", + "customresourcedefinitions", + "backstages.rhdh.redhat.com", + ); + return; + } catch (error) { + console.log( + `Timeout waiting for Backstage CRD to be available: ${getErrorMessage(error)}`, + ); + if (Date.now() - startTime >= timeoutMs) { + throw new Error( + `Timeout waiting for Backstage CRD to be available: ${getErrorMessage(error)}`, + { cause: error }, + ); + } + await sleep(5000); + } + } + throw new Error( + `Timeout waiting for Backstage CRD to be available after ${timeoutMs}ms`, + ); +} + +export async function deleteNamespaceIfExists( + state: RHDHDeploymentState, + timeoutMs: number = 60000, +): Promise { + if (state.isRunningLocal) { + console.log("Skipping namespace deletion as isRunningLocal is true."); + return; + } + + try { + await state.k8sApi.deleteNamespace(state.namespace); + + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + try { + await state.k8sApi.readNamespace(state.namespace); + await sleep(1000); + } catch (error) { + if (hasErrorResponse(error) && error.response?.statusCode === 404) { + return; + } + throw error; + } + } + throw new Error( + `Timeout waiting for namespace to be deleted after ${timeoutMs}ms`, + ); + } catch (e) { + if (hasErrorResponse(e) && e.response?.statusCode === 404) { + return; + } + throw e; + } +} diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts index 2416cb501f..7c978c4632 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts @@ -1,126 +1,102 @@ -import { ChildProcess, spawn } from "child_process"; -import { promises as fs } from "fs"; -import { join, resolve as resolvePath } from "path"; -import stream from "stream"; - -import { GroupEntity, UserEntity } from "@backstage/catalog-model"; import * as k8s from "@kubernetes/client-node"; +import { ChildProcess } from "child_process"; import { expect } from "@playwright/test"; import { v4 as uuidv4 } from "uuid"; -import * as yaml from "yaml"; - -import { APIHelper } from "../api-helper"; -import { getErrorMessage, hasErrorResponse } from "../errors"; - -type YamlConfig = Record; - -interface DynamicPluginConfig { - package: string; - disabled?: boolean; -} - -type DynamicPluginsConfig = Record & { - plugins: DynamicPluginConfig[]; -}; - -interface BackstageCrSpec { - replicas?: number; - deployment?: unknown; -} - -interface BackstageCr { - apiVersion: string; - kind: string; - metadata: { name: string }; - spec: BackstageCrSpec; -} - -function sleep(ms: number): Promise { - return new Promise((resolvePromise) => { - setTimeout(() => { - resolvePromise(); - }, ms); - }); -} - -function isRecord(value: unknown): value is YamlConfig { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isBackstageCr(value: unknown): value is BackstageCr { - return ( - isRecord(value) && - typeof value.apiVersion === "string" && - typeof value.kind === "string" && - isRecord(value.metadata) && - typeof value.metadata.name === "string" && - isRecord(value.spec) - ); -} - -function isDynamicPluginsConfig(value: unknown): value is DynamicPluginsConfig { - if (!isRecord(value)) { - return false; - } - const { plugins } = value; - return ( - plugins === undefined || - (Array.isArray(plugins) && - plugins.every((plugin) => isRecord(plugin) && typeof plugin.package === "string")) - ); -} - -function isUserEntity(value: unknown): value is UserEntity { - return isRecord(value) && value.kind === "User"; -} - -function isGroupEntity(value: unknown): value is GroupEntity { - return isRecord(value) && value.kind === "Group"; -} - -function getCatalogUsers(response: unknown): UserEntity[] { - if (!isRecord(response) || !Array.isArray(response.items)) { - return []; - } - return response.items.filter(isUserEntity); -} - -function getCatalogGroups(response: unknown): GroupEntity[] { - if (!isRecord(response) || !Array.isArray(response.items)) { - return []; - } - return response.items.filter(isGroupEntity); -} - -const currentDirName = import.meta.dirname; -const rootDirName = resolvePath(currentDirName, "..", "..", "..", ".."); -const syncedLogRegex = - /(Committed \d+ (Keycloak|msgraph|GitHub|LDAP|GitLab) users? and \d+ (Keycloak|msgraph|GitHub|LDAP|GitLab) groups? in \d+(\.\d+)? seconds|Scanned \d+ users? and processed \d+ users?)/; - -class RHDHDeployment { - instanceName!: string; - private kc!: k8s.KubeConfig; - private k8sApi!: k8s.CoreV1Api; - private appsV1Api!: k8s.AppsV1Api; - private namespace: string; - private appConfigMap: string; - private rbacConfigMap: string; - private dynamicPluginsConfigMap: string; - private secretName: string; - private appConfig: YamlConfig = {}; - private dynamicPluginsConfig: DynamicPluginsConfig = { plugins: [] }; - private rbacConfig: string = ""; - private secretData: Record = {}; - private isRunningLocal: boolean = false; - private runningProcess: ChildProcess | null = null; - private staticToken: string = ""; - private cr: BackstageCr = { +import { + enableGithubLoginWithIngestion, + enableGitlabLoginWithIngestion, + enableLDAPLoginWithIngestion, + enableMicrosoftLoginWithIngestion, + enableOIDCLoginWithIngestion, + enablePingFederateOIDCLogin, + printDynamicPluginsConfig, + setDynamicPluginEnabled as setDynamicPluginEnabledImpl, + setGithubResolver as setGithubResolverImpl, + setGitlabResolver as setGitlabResolverImpl, + setMicrosoftResolver as setMicrosoftResolverImpl, + setOIDCResolver as setOIDCResolverImpl, +} from "./rhdh-deployment-auth"; +import { + checkGroupIsChildOfGroup, + checkGroupIsIngestedInCatalog, + checkGroupIsParentOfGroup, + checkUserHasAnnotation, + checkUserIsIngestedInCatalog, + checkUserIsInGroup, + parseGroupChildrenFromEntity, + parseGroupMemberFromEntity, + parseGroupParentFromEntity, +} from "./rhdh-deployment-catalog"; +import { + applyCustomResource, + computeBackstageBackendUrl as computeBackstageBackendUrlImpl, + computeBackstageUrl as computeBackstageUrlImpl, + createAppConfig as createAppConfigImpl, + createBackstageDeployment as createBackstageDeploymentImpl, + createDynamicPluginsConfig as createDynamicPluginsConfigImpl, + createNamespace as createNamespaceImpl, + createRbacConfig as createRbacConfigImpl, + createSecret as createSecretImpl, + deleteConfigMap as deleteConfigMapImpl, + deleteSecret as deleteSecretImpl, + killRunningProcess as killRunningProcessImpl, + loadBackstageCR as loadBackstageCRImpl, + loadBaseConfig as loadBaseConfigImpl, + loadDynamicPluginsConfig as loadDynamicPluginsConfigImpl, + loadRbacConfig as loadRbacConfigImpl, + readYamlToJson, + updateAppConfig as updateAppConfigImpl, + updateDynamicPluginsConfig as updateDynamicPluginsConfigImpl, + updateRbacConfig as updateRbacConfigImpl, + updateSecret as updateSecretImpl, +} from "./rhdh-deployment-k8s"; +import { + followLocalLogs as followLocalLogsImpl, + followLogs as followLogsImpl, + followPodLogs as followPodLogsImpl, + waitForSynced as waitForSyncedImpl, +} from "./rhdh-deployment-logs"; +import { + BackstageCr, + DynamicPluginsConfig, + isRecord, + isRunningLocalMode, + RHDHDeploymentState, + shouldUseKubernetesClient, + YamlConfig, +} from "./rhdh-deployment-types"; +import { + deleteNamespaceIfExists as deleteNamespaceIfExistsImpl, + getDeploymentGeneration as getDeploymentGenerationImpl, + waitForConfigReconciled as waitForConfigReconciledImpl, + waitForDeploymentReady as waitForDeploymentReadyImpl, + waitForNamespaceActive as waitForNamespaceActiveImpl, +} from "./rhdh-deployment-wait"; + +class RHDHDeployment implements RHDHDeploymentState { + instanceName = ""; + kc!: k8s.KubeConfig; + k8sApi!: k8s.CoreV1Api; + appsV1Api!: k8s.AppsV1Api; + namespace: string; + appConfigMap: string; + rbacConfigMap: string; + dynamicPluginsConfigMap: string; + secretName: string; + appConfig: YamlConfig = {}; + dynamicPluginsConfig: DynamicPluginsConfig = { plugins: [] }; + rbacConfig = ""; + secretData: Record = {}; + isRunningLocal = false; + runningProcess: ChildProcess | null = null; + staticToken = ""; + cr: BackstageCr = { apiVersion: "", kind: "", metadata: { name: "" }, spec: {}, }; - private configReconcileBaselineGeneration: number | undefined; + configReconcileBaselineGeneration: number | undefined; constructor( namespace: string, @@ -129,7 +105,7 @@ class RHDHDeployment { dynamicPluginsConfigMap: string, secretName: string, ) { - if (!process.env.ISRUNNINGLOCAL || process.env.ISRUNNINGLOCAL === "false") { + if (shouldUseKubernetesClient()) { this.kc = new k8s.KubeConfig(); this.kc.loadFromDefault(); this.k8sApi = this.kc.makeApiClient(k8s.CoreV1Api); @@ -140,10 +116,10 @@ class RHDHDeployment { this.rbacConfigMap = rbacConfigMap; this.dynamicPluginsConfigMap = dynamicPluginsConfigMap; this.secretName = secretName; - this.isRunningLocal = process.env.ISRUNNINGLOCAL === "true"; + this.isRunningLocal = isRunningLocalMode(); } - async addSecretData(key: string, value: string): Promise { + addSecretData(key: string, value: string): Promise { if (value.length === 0) { throw new Error("Value cannot be empty"); } @@ -154,77 +130,34 @@ class RHDHDeployment { process.env[key] = value; } this.secretData[key] = Buffer.from(value).toString("base64"); - return this; + return Promise.resolve(this); } - async removeSecretData(key: string): Promise { + removeSecretData(key: string): Promise { if (key.length === 0) { throw new Error("Key cannot be empty"); } if (key in this.secretData) { delete this.secretData[key]; } - return this; + return Promise.resolve(this); } async createNamespace(): Promise { - // Skip namespace creation if running locally - if (this.isRunningLocal) { - console.log("Skipping namespace creation as isRunningLocal is true."); - return this; - } - - const namespaceObj: k8s.V1Namespace = { - apiVersion: "v1", - kind: "Namespace", - metadata: { - name: this.namespace, - }, - }; - - try { - await this.k8sApi.createNamespace(namespaceObj); - return this; - } catch (e) { - if (hasErrorResponse(e) && e.response?.statusCode === 409) { - return this; - } - throw e; - } + await createNamespaceImpl(this); + return this; } - async deleteNamespaceIfExists(timeoutMs: number = 60000): Promise { - // Skip namespace deletion if running locally - if (this.isRunningLocal) { - console.log("Skipping namespace deletion as isRunningLocal is true."); - return this; - } - - try { - await this.k8sApi.deleteNamespace(this.namespace); - - const startTime = Date.now(); - while (Date.now() - startTime < timeoutMs) { - try { - await this.k8sApi.readNamespace(this.namespace); - await sleep(1000); - } catch (error) { - if (hasErrorResponse(error) && error.response?.statusCode === 404) { - return this; - } - throw error; - } - } - throw new Error(`Timeout waiting for namespace to be deleted after ${timeoutMs}ms`); - } catch (e) { - if (hasErrorResponse(e) && e.response?.statusCode === 404) { - return this; - } - throw e; - } + async deleteNamespaceIfExists(timeoutMs = 60000): Promise { + await deleteNamespaceIfExistsImpl(this, timeoutMs); + return this; } - setConfigProperty(config: Record, path: string, value: unknown): RHDHDeployment { + setConfigProperty( + config: Record, + path: string, + value: unknown, + ): RHDHDeployment { const parts = path.split("."); let current: Record = config; @@ -251,7 +184,6 @@ class RHDHDeployment { throw new Error(`Invalid config path: ${path}`); } current[lastPart] = value; - return this; } @@ -267,7 +199,10 @@ class RHDHDeployment { return this.getConfig(this.appConfig); } - setDynamicPluginsConfigProperty(path: string, value: unknown): RHDHDeployment { + setDynamicPluginsConfigProperty( + path: string, + value: unknown, + ): RHDHDeployment { return this.setConfigProperty(this.dynamicPluginsConfig, path, value); } @@ -276,416 +211,80 @@ class RHDHDeployment { } async loadBaseConfig(): Promise { - const configPath = join(currentDirName, "yamls", "configmap.yaml"); - const yamlContent = await fs.readFile(configPath, "utf8"); - const configData: unknown = yaml.parse(yamlContent); - - if (isRecord(configData)) { - this.appConfig = configData; - } - + await loadBaseConfigImpl(this); return this; } async applyCustomResource(resource: BackstageCr): Promise { - console.log("Applying CR."); - try { - const customObjectsApi = this.kc.makeApiClient(k8s.CustomObjectsApi); - await customObjectsApi.createNamespacedCustomObject( - resource.apiVersion.split("/")[0], - resource.apiVersion.split("/")[1], - this.namespace, - resource.kind.toLowerCase() + "s", - resource, - ); - return this; - } catch (e) { - console.error(JSON.stringify(e)); - throw e; - } - } - - async readYamlToJson(filePath: string): Promise { - const fileContent = await fs.readFile(filePath, "utf8"); - return yaml.parse(fileContent); - } - - async createConfigMap(name: string, data: Record): Promise { - const configMap: k8s.V1ConfigMap = { - apiVersion: "v1", - kind: "ConfigMap", - metadata: { - name: name, - namespace: this.namespace, - }, - data: data, - }; - await this.k8sApi.createNamespacedConfigMap(this.namespace, configMap); + await applyCustomResource(this, resource); return this; } - async updateConfigMap(name: string, data: Record): Promise { - if (this.isRunningLocal) { - console.log("Skipping configmap update as isRunningLocal is true."); - return this; - } - - const patch = [ - { - op: "replace", - path: "/data", - value: data, - }, - ]; - - await this.k8sApi.patchNamespacedConfigMap( - name, - this.namespace, - patch, - undefined, - undefined, - undefined, - undefined, - undefined, - { headers: { "Content-Type": "application/json-patch+json" } }, - ); - return this; + readYamlToJson(filePath: string): Promise { + return readYamlToJson(filePath); } async createAppConfig(): Promise { - if (this.isRunningLocal) { - const appConfigPath = join(currentDirName, "app-config.test.yaml"); // Path to the local file - const appConfigYaml = yaml.stringify(this.appConfig); // Stringify the appConfig - await fs.writeFile(appConfigPath, appConfigYaml, "utf8"); // Write the stringified YAML to the local file - console.log(`App config written to ${appConfigPath}`); - return this; - } - - const appConfig = { - "app-config.yaml": yaml.stringify(this.appConfig), - }; - await this.createConfigMap(this.appConfigMap, appConfig); + await createAppConfigImpl(this); return this; } async updateAppConfig(): Promise { - if (this.isRunningLocal) { - const appConfigPath = join(currentDirName, "app-config.test.yaml"); // Path to the local file - const appConfigYaml = yaml.stringify(this.appConfig); // Stringify the appConfig - await fs.writeFile(appConfigPath, appConfigYaml, "utf8"); // Write the stringified YAML to the local file - console.log(`App config updated in ${appConfigPath}`); - return this; - } - - const appConfig = { - "app-config.yaml": yaml.stringify(this.appConfig), - }; - await this.updateConfigMap(this.appConfigMap, appConfig); + await updateAppConfigImpl(this); return this; } async deleteConfigMap(): Promise { - await this.k8sApi.deleteNamespacedConfigMap(this.appConfigMap, this.namespace); + await deleteConfigMapImpl(this); return this; } async createSecret(): Promise { - if (this.isRunningLocal) { - console.log("Skipping secret creation as isRunningLocal is true."); - return this; - } - const secret: k8s.V1Secret = { - apiVersion: "v1", - kind: "Secret", - metadata: { - name: this.secretName, - namespace: this.namespace, - }, - data: this.secretData, - }; - await this.k8sApi.createNamespacedSecret(this.namespace, secret); + await createSecretImpl(this); return this; } async updateSecret(): Promise { - if (this.isRunningLocal) { - console.log("Skipping secret update as isRunningLocal is true."); - return this; - } - const secret: k8s.V1Secret = { - apiVersion: "v1", - kind: "Secret", - metadata: { - name: this.secretName, - namespace: this.namespace, - }, - data: this.secretData, - }; - await this.k8sApi.replaceNamespacedSecret(this.secretName, this.namespace, secret); + await updateSecretImpl(this); return this; } async deleteSecret(): Promise { - if (this.isRunningLocal) { - console.log("Skipping secret deletion as isRunningLocal is true."); - return this; - } - await this.k8sApi.deleteNamespacedSecret(this.secretName, this.namespace); + await deleteSecretImpl(this); return this; } - private async getDeploymentGeneration(): Promise { - const labels = { - "app.kubernetes.io/name": "backstage", - "app.kubernetes.io/instance": this.instanceName, - }; - const labelSelector = Object.entries(labels) - .map(([key, value]) => `${key}=${value}`) - .join(","); - - const deployments = await this.appsV1Api.listNamespacedDeployment( - this.namespace, - undefined, - undefined, - undefined, - undefined, - labelSelector, - ); - - if (deployments.body.items.length === 0) { - throw new Error(`No deployment found with labels: ${labelSelector}`); - } - - return deployments.body.items[0].metadata?.generation ?? 0; + getDeploymentGeneration(): Promise { + return getDeploymentGenerationImpl(this); } - async waitForConfigReconciled(timeoutMs: number = 60000): Promise { - if (this.isRunningLocal) { - return this; - } - - const baseline = - this.configReconcileBaselineGeneration ?? (await this.getDeploymentGeneration()); - const startTime = Date.now(); - - while (Date.now() - startTime < timeoutMs) { - const currentGeneration = await this.getDeploymentGeneration(); - if (currentGeneration > baseline) { - console.log( - `[INFO] Config reconciled - deployment generation ${baseline} -> ${currentGeneration}`, - ); - return this; - } - await sleep(1000); - } - - console.log(`[INFO] No deployment generation change after ${timeoutMs}ms, proceeding`); + async waitForConfigReconciled(timeoutMs = 60000): Promise { + await waitForConfigReconciledImpl(this, timeoutMs); return this; } - async waitForDeploymentReady(timeoutMs: number = 600000): Promise { - if (this.isRunningLocal) { - console.log("Skipping deployment ready check as isRunningLocal is true."); - return this; - } - const startTime = Date.now(); - const labels = { - "app.kubernetes.io/name": "backstage", - "app.kubernetes.io/instance": this.instanceName, - }; - const labelSelector = Object.entries(labels) - .map(([key, value]) => `${key}=${value}`) - .join(","); - - // First, capture the initial deployment state to detect when rollout starts - let initialGeneration: number | undefined; - let rolloutStarted = false; - const rolloutStartTimeout = 60000; // Wait up to 60 seconds for rollout to start - - while (Date.now() - startTime < timeoutMs) { - try { - const deployments = await this.appsV1Api.listNamespacedDeployment( - this.namespace, - undefined, - undefined, - undefined, - undefined, - labelSelector, - ); - - if (deployments.body.items.length === 0) { - throw new Error(`No deployment found with labels: ${labelSelector}`); - } - - const deployment = deployments.body.items[0]; - const conditions = deployment.status?.conditions || []; - - // Capture initial generation on first check - if (initialGeneration === undefined) { - initialGeneration = deployment.metadata?.generation || 0; - console.log(`[INFO] Initial deployment generation: ${initialGeneration}`); - } - - // Check if rollout has started (generation changed or progressing condition indicates rollout) - const currentGeneration = deployment.metadata?.generation || 0; - const observedGeneration = deployment.status?.observedGeneration || 0; - const isProgressing = conditions.some( - (condition) => condition.type === "Progressing" && condition.status === "True", - ); - - // Rollout has started if: - // 1. Generation increased (new spec applied) - // 2. Observed generation is less than current generation (rollout in progress) - // 3. Progressing condition is true - if ( - !rolloutStarted && - (currentGeneration > initialGeneration || - observedGeneration < currentGeneration || - isProgressing) - ) { - rolloutStarted = true; - console.log( - `[INFO] Rollout detected - Generation: ${currentGeneration}, Observed: ${observedGeneration}`, - ); - } - - // If we haven't detected rollout start yet, wait a bit and check again - // This handles the delay between configmap update and Kubernetes detecting the change - if (!rolloutStarted) { - const elapsedSinceStart = Date.now() - startTime; - if (elapsedSinceStart < rolloutStartTimeout) { - console.log( - `[INFO] Waiting for rollout to start... (${Math.round(elapsedSinceStart / 1000)}s elapsed)`, - ); - await sleep(2000); // Check every 2 seconds - continue; - } else { - // If no rollout detected but deployment is ready, assume no restart was needed - console.log( - `[INFO] No rollout detected after ${rolloutStartTimeout}ms, checking if deployment is already ready`, - ); - rolloutStarted = true; // Proceed to check readiness - } - } - - const isAvailable = conditions.some( - (condition) => condition.type === "Available" && condition.status === "True", - ); - - const isProgressingWithRollout = conditions.some( - (condition) => - condition.type === "Progressing" && - condition.status === "True" && - condition.reason !== "NewReplicaSetAvailable", - ); - - const replicas = deployment.spec?.replicas; - const desiredReplicas = this.cr.spec.replicas ? this.cr.spec.replicas : 1; - - // Check replica counts to ensure rollout has completed - const availableReplicas = deployment.status?.availableReplicas || 0; - const readyReplicas = deployment.status?.readyReplicas || 0; - const updatedReplicas = deployment.status?.updatedReplicas || 0; - - const replicasMatch = - availableReplicas === desiredReplicas && - readyReplicas === desiredReplicas && - updatedReplicas === desiredReplicas; - - // Deployment is ready when: - // - Available condition is true - // - Not progressing (or only NewReplicaSetAvailable which is fine) - // - All replica counts match - if ( - isAvailable && - !isProgressingWithRollout && - replicas == desiredReplicas && - replicasMatch && - observedGeneration >= currentGeneration - ) { - await sleep(5000); - return this; - } else if (isProgressingWithRollout || !replicasMatch) { - console.log( - `[INFO] Deployment is progressing - Available: ${availableReplicas}, Ready: ${readyReplicas}, Updated: ${updatedReplicas}, Desired: ${desiredReplicas}, Observed Gen: ${observedGeneration}/${currentGeneration}`, - ); - } - - await sleep(5000); - } catch (error) { - if (Date.now() - startTime >= timeoutMs) { - throw new Error(`Timeout waiting for deployment to be ready: ${getErrorMessage(error)}`, { - cause: error, - }); - } - await sleep(5000); - } - } - - throw new Error(`Timeout waiting for deployment to be ready after ${timeoutMs}ms`); + async waitForDeploymentReady(timeoutMs = 600000): Promise { + await waitForDeploymentReadyImpl(this, timeoutMs); + return this; } - async waitForNamespaceActive(timeoutMs: number = 30000): Promise { - const startTime = Date.now(); - if (this.isRunningLocal) { - console.log("Skipping namespace active check as isRunningLocal is true."); - return this; - } - - while (Date.now() - startTime < timeoutMs) { - try { - const response = await this.k8sApi.readNamespace(this.namespace); - const phase = response.body.status?.phase; - - if (phase === "Active") { - return this; - } - - await sleep(1000); - } catch (error) { - if (Date.now() - startTime >= timeoutMs) { - throw new Error(`Timeout waiting for namespace to be active: ${getErrorMessage(error)}`, { - cause: error, - }); - } - await sleep(1000); - } - } - - throw new Error(`Timeout waiting for namespace to be active after ${timeoutMs}ms`); + async waitForNamespaceActive(timeoutMs = 30000): Promise { + await waitForNamespaceActiveImpl(this, timeoutMs); + return this; } async loadRbacConfig(): Promise { - const configPath = join(currentDirName, "yamls", "rbac-policy.csv"); - this.rbacConfig = await fs.readFile(configPath, "utf8"); // Load CSV content directly + await loadRbacConfigImpl(this); return this; } async createRbacConfig(): Promise { - if (this.isRunningLocal) { - const rbacConfigPath = join(currentDirName, "rbac.test.csv"); // Path to the local file - await fs.writeFile(rbacConfigPath, this.rbacConfig, "utf8"); // Write the RBAC config to the local file - console.log(`RBAC config written to ${rbacConfigPath}`); - return this; - } - - await this.createConfigMap(this.rbacConfigMap, { - "rbac-policy.csv": this.rbacConfig, - }); + await createRbacConfigImpl(this); return this; } async updateRbacConfig(): Promise { - if (this.isRunningLocal) { - const rbacConfigPath = join(currentDirName, "rbac.test.csv"); // Path to the local file - await fs.writeFile(rbacConfigPath, this.rbacConfig, "utf8"); // Write the RBAC config to the local file - console.log(`RBAC config updated in ${rbacConfigPath}`); - return this; - } - - await this.updateConfigMap(this.rbacConfigMap, { - "rbac-policy.csv": this.rbacConfig, - }); + await updateRbacConfigImpl(this); return this; } @@ -700,395 +299,75 @@ class RHDHDeployment { } async loadDynamicPluginsConfig(): Promise { - const configPath = join(currentDirName, "yamls", "dynamic-plugins-config.yaml"); - const yamlContent = await fs.readFile(configPath, "utf8"); - const configData: unknown = yaml.parse(yamlContent); - - if (isDynamicPluginsConfig(configData)) { - this.dynamicPluginsConfig = configData; - } - + await loadDynamicPluginsConfigImpl(this); return this; } async createDynamicPluginsConfig(): Promise { - if (this.isRunningLocal) { - const dynamicPluginsConfigPath = join(currentDirName, "dynamic-plugins.test.yaml"); // Path to the local file - const dynamicPluginsConfigYaml = yaml.stringify(this.dynamicPluginsConfig); // Stringify the dynamic plugins config - await fs.writeFile(dynamicPluginsConfigPath, dynamicPluginsConfigYaml, "utf8"); // Write the stringified YAML to the local file - console.log(`Dynamic plugins config written to ${dynamicPluginsConfigPath}`); - this.setAppConfigProperty( - "dynamicPlugins.rootDirectory", - rootDirName + "/dynamic-plugins-root", - ); - await this.updateAppConfig(); - return this; - } - - await this.createConfigMap(this.dynamicPluginsConfigMap, { - "dynamic-plugins.yaml": yaml.stringify(this.dynamicPluginsConfig), - }); + await createDynamicPluginsConfigImpl( + this, + (path, value) => { + this.setAppConfigProperty(path, value); + }, + updateAppConfigImpl, + ); return this; } async updateDynamicPluginsConfig(): Promise { - if (this.isRunningLocal) { - const dynamicPluginsConfigPath = join(currentDirName, "dynamic-plugins.test.yaml"); // Path to the local file - const dynamicPluginsConfigYaml = yaml.stringify(this.dynamicPluginsConfig); // Stringify the dynamic plugins config - await fs.writeFile(dynamicPluginsConfigPath, dynamicPluginsConfigYaml, "utf8"); // Write the stringified YAML to the local file - console.log(`Dynamic plugins config updated in ${dynamicPluginsConfigPath}`); - console.log( - `Dynamic plugins config in ${dynamicPluginsConfigPath} has no effect on local deployment. Make sure to update the app-config.test.yaml file to use the dynamic-plugins-root directory and your plugin are already copied there.`, - ); - return this; - } - - await this.updateConfigMap(this.dynamicPluginsConfigMap, { - "dynamic-plugins.yaml": yaml.stringify(this.dynamicPluginsConfig), - }); + await updateDynamicPluginsConfigImpl(this); return this; } - async loadBackstageCR(): Promise { - const configPath = join(currentDirName, "yamls", "backstage.yaml"); - const parsed: unknown = await this.readYamlToJson(configPath); - if (!isBackstageCr(parsed)) { - throw new Error("Invalid Backstage CR config"); - } - const backstageConfig = parsed; - const imageRegistry = process.env.IMAGE_REGISTRY || "quay.io"; - const imageRepo = process.env.IMAGE_REPO || process.env.QUAY_REPO; - const tagName = process.env.TAG_NAME; - expect(imageRepo, "IMAGE_REPO or QUAY_REPO must be set").toBeTruthy(); - expect(tagName, "TAG_NAME must be set").toBeTruthy(); - const image = `${imageRegistry}/${imageRepo}:${tagName}`; - backstageConfig.spec.deployment = { - patch: { - spec: { - template: { - spec: { - containers: [ - { - name: "backstage-backend", - image, - imagePullPolicy: "Always", - }, - ], - }, - }, - }, - }, - }; - console.log(`Setting Backstage CR image via deployment.patch to ${image}`); - this.cr = backstageConfig; - this.instanceName = backstageConfig.metadata.name; - return backstageConfig; - } - - async ensureBackstageCRIsAvailable(timeoutMs: number = 60000): Promise { - if (this.isRunningLocal) { - console.log("Skipping CRD check as isRunningLocal is true."); - return; - } - - const startTime = Date.now(); - while (Date.now() - startTime < timeoutMs) { - try { - const customObjectsApi = this.kc.makeApiClient(k8s.CustomObjectsApi); - await customObjectsApi.getClusterCustomObject( - "apiextensions.k8s.io", - "v1", - "customresourcedefinitions", - "backstages.rhdh.redhat.com", - ); - return; - } catch (error) { - console.log(`Timeout waiting for Backstage CRD to be available: ${getErrorMessage(error)}`); - if (Date.now() - startTime >= timeoutMs) { - throw new Error( - `Timeout waiting for Backstage CRD to be available: ${getErrorMessage(error)}`, - { cause: error }, - ); - } - await sleep(5000); - } - } - throw new Error(`Timeout waiting for Backstage CRD to be available after ${timeoutMs}ms`); + loadBackstageCR(): Promise { + return loadBackstageCRImpl(this); } async createBackstageDeployment(): Promise { - try { - if (this.isRunningLocal) { - this.runningProcess = spawn( - "yarn", - [ - "dev", - "--env-mode=loose", - "--", - "--config", - currentDirName + "/app-config.test.yaml", - "--config", - currentDirName + "/dynamic-plugins.test.yaml", - ], - { - shell: true, - cwd: resolvePath(rootDirName), - detached: true, - stdio: ["ignore", "pipe", "pipe"], - env: process.env, - }, - ); - this.runningProcess.unref(); - console.log(`Local production server started with PID: ${this.runningProcess.pid}`); - return this; - } - await this.ensureBackstageCRIsAvailable(60000); - const backstageConfig = await this.loadBackstageCR(); - await this.applyCustomResource(backstageConfig); - await this.waitForDeploymentReady(); - return this; - } catch (e) { - console.log(JSON.stringify(e)); - throw e; - } + await createBackstageDeploymentImpl(this); + return this; } async killRunningProcess(): Promise { - if (this.runningProcess?.pid) { - const killed = process.kill(-this.runningProcess.pid); - console.log("Local production server process killed?", killed); - - // Wait for the process to actually terminate with a 5-second timeout - await new Promise((resolvePromise) => { - this.runningProcess?.on("exit", () => { - setTimeout(() => { - console.log("Process termination timeout reached after 5 seconds."); - this.runningProcess = null; - resolvePromise(); - }, 5000); - }); - }); - - // Verify homepage is not accessible - const baseUrl = await this.computeBackstageUrl(); - try { - const response = await fetch(baseUrl, { method: "HEAD" }); - if (response.status === 200) { - throw new Error("Homepage is still accessible after process termination"); - } - } catch (error) { - // Expected error - connection refused - console.log("Homepage is not accessible as expected: ", error); - } - } else { - console.log("No running process to kill."); - } + await killRunningProcessImpl(this, () => this.computeBackstageUrl()); } - async followPodLogs( + followPodLogs( searchString: RegExp, podName?: string, podLabels?: Record, - timeoutMs: number = 300000, + timeoutMs = 300000, ): Promise { - const namespace = this.namespace; - if (!podName && podLabels) { - try { - const labelSelector = Object.entries(podLabels) - .map(([key, value]) => `${key}=${value}`) - .join(","); - - const pods = await this.k8sApi.listNamespacedPod( - namespace, - undefined, - undefined, - undefined, - "status.phase=Running", - labelSelector, - ); - - if (pods.body.items.length === 0) { - throw new Error(`No pod found with labels: ${labelSelector}`); - } - - // Filter out pods in terminating phase - const activePods = pods.body.items.filter((pod) => { - const isTerminating = pod.metadata?.deletionTimestamp !== undefined; - return !isTerminating; - }); - - if (activePods.length === 0) { - throw new Error(`No active pods found with labels: ${labelSelector}`); - } - - const pod = activePods[0]; - podName = pod.metadata!.name!; - } catch (error) { - throw new Error(`Error getting pod name: ${getErrorMessage(error)}`, { - cause: error, - }); - } - } - - try { - console.log(`Reading logs for pod ${podName}`); - const startTime = Date.now(); - let found = false; - const log = new k8s.Log(this.kc); - const logStream = new stream.PassThrough(); - - logStream.on("data", (chunk: Buffer | string) => { - const text = typeof chunk === "string" ? chunk : chunk.toString(); - if (searchString.test(text)) { - process.stdout.write(chunk); - found = true; - } - }); - - logStream.on("error", (error) => { - throw new Error(`Error getting pod name: ${getErrorMessage(error)}`); - }); - - logStream.on("end", () => { - console.log("Log stream ended."); - }); - - await log.log(namespace, podName!, "backstage-backend", logStream, { - follow: true, - tailLines: 1, - pretty: false, - timestamps: false, - }); - - // Keep the function alive to allow streaming - - while (Date.now() - startTime < timeoutMs) { - if (found) { - break; - } - await sleep(1000); - } - if (found) { - logStream.end(); - logStream.removeAllListeners(); - } - return found; - } catch (error) { - const message = hasErrorResponse(error) ? error.body?.message : getErrorMessage(error); - console.log(`Error: ${message}`); - throw new Error( - `Timeout waiting for string "${searchString}" in logs after ${timeoutMs}ms. Error: ${message}`, - { cause: error }, - ); - } + return followPodLogsImpl(this, searchString, podName, podLabels, timeoutMs); } - async followLocalLogs(searchString: RegExp, timeoutMs: number = 30000): Promise { - if (!this.isRunningLocal) { - throw new Error("Not running in local mode. Cannot follow local logs."); - } - - let found = false; - - console.log( - "Following logs from the local production server. Looking for string: ", - searchString, - ); - - // Create a readable stream from the running process's stdout - const logStream = new stream.PassThrough(); - - // Pipe the stdout of the running process to the logStream - this.runningProcess?.stdout?.pipe(logStream); - - logStream.on("data", (chunk: Buffer | string) => { - const text = typeof chunk === "string" ? chunk : chunk.toString(); - if (process.env.ISRUNNINGLOCAL && process.env.ISRUNNINGLOCALDEBUG) { - console.log(`\t${text.replaceAll(/\n/g, "\t")}`); - } - if (searchString.test(text)) { - console.log("Found string in local logs."); - found = true; - } - }); - - logStream.on("error", (error) => { - throw new Error(`Error reading local logs: ${getErrorMessage(error)}`); - }); - - logStream.on("end", () => { - console.log("Local log stream ended."); - }); - - // Keep the function alive to allow streaming - const startTime = Date.now(); - while (Date.now() - startTime < timeoutMs) { - if (found) { - break; - } - await sleep(1000); - } - - return found; + followLocalLogs(searchString: RegExp, timeoutMs = 30000): Promise { + return followLocalLogsImpl(this, searchString, timeoutMs); } - async followLogs(searchString: RegExp, timeoutMs: number = 300000): Promise { - if (this.isRunningLocal) { - return this.followLocalLogs(searchString, timeoutMs); - } - return this.followPodLogs( - searchString, - undefined, - { "rhdh.redhat.com/app": `backstage-${this.instanceName}` }, - timeoutMs, - ); + followLogs(searchString: RegExp, timeoutMs = 300000): Promise { + return followLogsImpl(this, searchString, timeoutMs); } - async computeBackstageUrl(): Promise { - if (this.isRunningLocal) { - return `http://localhost:3000`; - } - const cluster = this.kc.getCurrentCluster(); - if (!cluster || !cluster.server) { - throw new Error("Unable to retrieve cluster information."); - } - const regex = /^https?:\/\/(?:api\.)?([^:/]+)/; - const match = cluster.server.match(regex); - let clusterBaseUrl = ""; - if (match) { - clusterBaseUrl = match[1]; - } else { - console.log("No match found."); - } - return `https://backstage-${this.instanceName}-${this.namespace}.apps.${clusterBaseUrl}`; + computeBackstageUrl(): Promise { + return Promise.resolve(computeBackstageUrlImpl(this)); } - async computeBackstageBackendUrl() { - if (this.isRunningLocal) { - return `http://localhost:7007`; - } - return this.computeBackstageUrl(); + computeBackstageBackendUrl(): Promise { + return Promise.resolve(computeBackstageBackendUrlImpl(this)); } async loadAllConfigs(): Promise { - // Load base config if defined - if (this.appConfigMap) { + if (this.appConfigMap !== "") { await this.loadBaseConfig(); } - - // Load dynamic plugins config if defined - if (this.dynamicPluginsConfigMap) { + if (this.dynamicPluginsConfigMap !== "") { await this.loadDynamicPluginsConfig(); } - - // Load RBAC config if defined - if (this.rbacConfigMap) { + if (this.rbacConfigMap !== "") { await this.loadRbacConfig(); } - - // Load Backstage CR await this.loadBackstageCR(); - return this; } @@ -1098,6 +377,7 @@ class RHDHDeployment { const response = await fetch(baseUrl, { method: "HEAD" }); return response.status === 200; } catch (error: unknown) { + const { getErrorMessage } = await import("../errors"); console.log(`Error: ${getErrorMessage(error)}`); return false; } @@ -1108,303 +388,41 @@ class RHDHDeployment { expect(isReachable).toBe(true); } - // TODO: Enable Github - // TODO: ENABLE RBAC - // TODO: Enable Redis - - // New method to enable or disable a dynamic plugin - - setDynamicPluginEnabled(pluginName: string, enabled: boolean): RHDHDeployment { - const plugin = this.dynamicPluginsConfig.plugins.find((p) => p.package === pluginName); - if (plugin) { - plugin.disabled = !enabled; - console.log(`Plugin ${pluginName} has been ${enabled ? "enabled" : "disabled"}.`); - } else { - this.dynamicPluginsConfig.plugins = [ - ...this.dynamicPluginsConfig.plugins, - { - package: pluginName, - disabled: !enabled, - }, - ]; - console.log( - `Plugin ${pluginName} has been added to the dynamic plugins config and set to ${enabled ? "enabled" : "disabled"}.`, - ); - } + setDynamicPluginEnabled( + pluginName: string, + enabled: boolean, + ): RHDHDeployment { + setDynamicPluginEnabledImpl(this, pluginName, enabled); return this; } printDynamicPluginsConfig(): void { - console.log(yaml.stringify(this.dynamicPluginsConfig.plugins)); - } - - async enableOIDCLoginWithIngestion(): Promise { - console.log("Enabling OIDC login with ingestion..."); - //expect the config variable to be set - expect(process.env.RHBK_BASE_URL).toBeDefined(); - expect(process.env.RHBK_REALM).toBeDefined(); - expect(process.env.RHBK_CLIENT_ID).toBeDefined(); - expect(process.env.RHBK_CLIENT_SECRET).toBeDefined(); - - // enable the catalog backend dynamic plugin - // and set the required configuration properties - this.setDynamicPluginEnabled( - "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic", - true, - ); - this.setAppConfigProperty("catalog.providers", { - keycloakOrg: { - default: { - baseUrl: "${RHBK_BASE_URL}", - loginRealm: "${RHBK_REALM}", - realm: "${RHBK_REALM}", - clientId: "${RHBK_CLIENT_ID}", - clientSecret: "${RHBK_CLIENT_SECRET}", - schedule: { - frequency: { - minutes: 1, - }, - timeout: { - minutes: 1, - }, - }, - }, - }, - }); - - // enable the keycloak login provider - this.setAppConfigProperty("auth.providers.oidc", { - production: { - metadataUrl: "${RHBK_BASE_URL}/realms/${RHBK_REALM}", - clientId: "${RHBK_CLIENT_ID}", - clientSecret: "${RHBK_CLIENT_SECRET}", - prompt: "auto", - callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/oidc/handler/frame", - }, - }); - this.setAppConfigProperty("auth.environment", "production"); - this.setAppConfigProperty("signInPage", "oidc"); - - return this; + printDynamicPluginsConfig(this); } - async enablePingFederateOIDCLogin(): Promise { - console.log("Enabling PingFederate OIDC login..."); - - // Expect the config variables to be set - expect(process.env.PINGFEDERATE_BASE_URL).toBeDefined(); - expect(process.env.PINGFEDERATE_CLIENT_ID).toBeDefined(); - expect(process.env.PINGFEDERATE_CLIENT_SECRET).toBeDefined(); - - // Enable the PingFederate OIDC login provider - this.setAppConfigProperty("auth.providers.oidc", { - production: { - metadataUrl: "${PINGFEDERATE_BASE_URL}/.well-known/openid-configuration", - clientId: "${PINGFEDERATE_CLIENT_ID}", - clientSecret: "${PINGFEDERATE_CLIENT_SECRET}", - prompt: "auto", - callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/oidc/handler/frame", - }, - }); - this.setAppConfigProperty("auth.environment", "production"); - this.setAppConfigProperty("signInPage", "oidc"); - - return this; + enableOIDCLoginWithIngestion(): Promise { + enableOIDCLoginWithIngestion(this); + return Promise.resolve(this); } - async enableLDAPLoginWithIngestion(): Promise { - console.log("Enabling LDAP login with ingestion..."); - //expect the config variable to be set - expect(process.env.RHBK_BASE_URL).toBeDefined(); - expect(process.env.RHBK_LDAP_REALM).toBeDefined(); - expect(process.env.RHBK_LDAP_CLIENT_ID).toBeDefined(); - expect(process.env.RHBK_LDAP_CLIENT_SECRET).toBeDefined(); - - // enable the catalog backend dynamic plugin - // and set the required configuration properties - this.setDynamicPluginEnabled( - "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-ldap-dynamic", - true, - ); - this.setAppConfigProperty("catalog.providers", { - ldapOrg: { - default: { - target: "${LDAP_TARGET_URL}", - bind: { - dn: "${LDAP_BIND_DN}", - secret: "${LDAP_BIND_SECRET}", - }, - users: [ - { - dn: "${LDAP_USERS_DN}", - options: { - filter: "(uid=*)", - scope: "sub", - }, - }, - ], - groups: [ - { - dn: "${LDAP_GROUPS_DN}", - options: { - filter: "(&(objectClass=group)(groupType:1.2.840.113556.1.4.803:=2147483648))", // filter only security groups - scope: "sub", - }, - }, - ], - schedule: { - frequency: "PT1M", - timeout: "PT1M", - }, - }, - }, - }); - - // enable the keycloak login provider - this.setAppConfigProperty("auth.providers.oidc", { - production: { - metadataUrl: "${RHBK_BASE_URL}/realms/${RHBK_LDAP_REALM}", - clientId: "${RHBK_LDAP_CLIENT_ID}", - clientSecret: "${RHBK_LDAP_CLIENT_SECRET}", - prompt: "auto", - callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/oidc/handler/frame", - }, - }); - this.setAppConfigProperty("auth.environment", "production"); - this.setAppConfigProperty("signInPage", "oidc"); - - return this; + enablePingFederateOIDCLogin(): Promise { + enablePingFederateOIDCLogin(this); + return Promise.resolve(this); } - async enableMicrosoftLoginWithIngestion(): Promise { - console.log("Enabling Microsoft login with ingestion..."); - //expect the config variable to be set - expect(process.env.AUTH_PROVIDERS_AZURE_CLIENT_ID).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_AZURE_CLIENT_SECRET).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_AZURE_TENANT_ID).toBeDefined(); - - // enable the catalog backend dynamic plugin - // and set the required configuration properties - this.setDynamicPluginEnabled( - "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-msgraph-dynamic", - true, - ); - this.setAppConfigProperty("catalog.providers", { - microsoftGraphOrg: { - default: { - target: "https://graph.microsoft.com/v1.0", - authority: "https://login.microsoftonline.com", - tenantId: "${AUTH_PROVIDERS_AZURE_TENANT_ID}", - clientId: "${AUTH_PROVIDERS_AZURE_CLIENT_ID}", - clientSecret: "${AUTH_PROVIDERS_AZURE_CLIENT_SECRET}", - user: { - filter: - "accountEnabled eq true and userType eq 'member' and startswith(displayName,'TEST')", - }, - group: { - filter: - "securityEnabled eq true and mailEnabled eq false and startswith(displayName,'TEST_')\n", - }, - schedule: { - frequency: "PT1M", - timeout: "PT1M", - }, - }, - }, - }); - - // enable the keycloak login provider - this.setAppConfigProperty("auth.providers.microsoft", { - production: { - clientId: "${AUTH_PROVIDERS_AZURE_CLIENT_ID}", - clientSecret: "${AUTH_PROVIDERS_AZURE_CLIENT_SECRET}", - prompt: "auto", - tenantId: "${AUTH_PROVIDERS_AZURE_TENANT_ID}", - callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/microsoft/handler/frame", - }, - }); - this.setAppConfigProperty("auth.environment", "production"); - this.setAppConfigProperty("signInPage", "microsoft"); - - return this; + enableLDAPLoginWithIngestion(): Promise { + enableLDAPLoginWithIngestion(this); + return Promise.resolve(this); } - async enableGithubLoginWithIngestion(): Promise { - console.log("Enabling Github login with ingestion..."); - - //expect the config variable to be set - expect(process.env.AUTH_PROVIDERS_GH_ORG_NAME).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_ID).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GH_ORG_APP_ID).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GH_ORG1_PRIVATE_KEY).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GH_ORG_WEBHOOK_SECRET).toBeDefined(); - - // enable the catalog backend dynamic plugin - // and set the required configuration properties - this.setDynamicPluginEnabled( - "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-github-org-dynamic", - true, - ); - // Use local path for local development, OCI path for CI/CD - const transformerPluginPath = this.isRunningLocal - ? "./dynamic-plugins/dist/@internal/backstage-plugin-catalog-backend-module-github-org-transformer-dynamic" - : "oci://quay.io/rh-ee-jhe/catalog-github-org-transformer:v0.3.0!internal-backstage-plugin-catalog-backend-module-github-org-transformer"; - - this.setDynamicPluginEnabled(transformerPluginPath, true); - - this.setAppConfigProperty("catalog.providers", { - githubOrg: [ - { - id: "github", - githubUrl: "https://github.com", - orgs: ["${AUTH_PROVIDERS_GH_ORG_NAME}"], - schedule: { - initialDelay: { - seconds: 0, - }, - frequency: { - minutes: 1, - }, - timeout: { - minutes: 1, - }, - }, - }, - ], - }); - - // enable github integration - this.setAppConfigProperty("integrations", { - github: [ - { - host: "github.com", - apps: [ - { - appId: "${AUTH_PROVIDERS_GH_ORG_APP_ID}", - clientId: "${AUTH_PROVIDERS_GH_ORG_CLIENT_ID}", - clientSecret: "${AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET}", - privateKey: "${AUTH_PROVIDERS_GH_ORG1_PRIVATE_KEY}", - webhookSecret: "${AUTH_PROVIDERS_GH_ORG_WEBHOOK_SECRET}", - }, - ], - }, - ], - }); - - // enable the github login provider - this.setAppConfigProperty("auth.providers.github", { - production: { - clientId: "${AUTH_PROVIDERS_GH_ORG_CLIENT_ID}", - clientSecret: "${AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET}", - callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/github/handler/frame", - }, - }); - - this.setAppConfigProperty("auth.environment", "production"); - this.setAppConfigProperty("signInPage", "github"); + enableMicrosoftLoginWithIngestion(): Promise { + enableMicrosoftLoginWithIngestion(this); + return Promise.resolve(this); + } - return this; + enableGithubLoginWithIngestion(): Promise { + enableGithubLoginWithIngestion(this, this.isRunningLocal); + return Promise.resolve(this); } async createAllConfigs(): Promise { @@ -1416,12 +434,12 @@ class RHDHDeployment { async updateAllConfigs(): Promise { if (!this.isRunningLocal) { - this.configReconcileBaselineGeneration = await this.getDeploymentGeneration(); + this.configReconcileBaselineGeneration = + await this.getDeploymentGeneration(); } await this.updateAppConfig(); await this.updateDynamicPluginsConfig(); await this.updateRbacConfig(); - return this; } @@ -1429,269 +447,125 @@ class RHDHDeployment { if (this.isRunningLocal) { console.log("Restarting local deployment..."); await this.killRunningProcess(); - await this.createBackstageDeployment(); } return this; } - async generateStaticToken(): Promise { + generateStaticToken(): Promise { const token = uuidv4(); - await this.addSecretData("STATIC_TOKEN", token); this.staticToken = token; - return this; + return this.addSecretData("STATIC_TOKEN", token); } getCurrentStaticToken(): string { return this.staticToken; } - async setOIDCResolver( + setOIDCResolver( resolver: string, - dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, + dangerouslyAllowSignInWithoutUserInCatalog = false, ): Promise { - this.setAppConfigProperty("auth.providers.oidc.production.signIn.resolvers", [ - { - resolver: resolver, - dangerouslyAllowSignInWithoutUserInCatalog: dangerouslyAllowSignInWithoutUserInCatalog, - }, - ]); - return this; + setOIDCResolverImpl( + this, + resolver, + dangerouslyAllowSignInWithoutUserInCatalog, + ); + return Promise.resolve(this); } - async setMicrosoftResolver( + setMicrosoftResolver( resolver: string, - dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, + dangerouslyAllowSignInWithoutUserInCatalog = false, ): Promise { - this.setAppConfigProperty("auth.providers.microsoft.production.signIn.resolvers", [ - { - resolver: resolver, - dangerouslyAllowSignInWithoutUserInCatalog: dangerouslyAllowSignInWithoutUserInCatalog, - }, - ]); - return this; + setMicrosoftResolverImpl( + this, + resolver, + dangerouslyAllowSignInWithoutUserInCatalog, + ); + return Promise.resolve(this); } - async setGithubResolver( + setGithubResolver( resolver: string, - dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, + dangerouslyAllowSignInWithoutUserInCatalog = false, ): Promise { - this.setAppConfigProperty("auth.providers.github.production.signIn.resolvers", [ - { - resolver: resolver, - dangerouslyAllowSignInWithoutUserInCatalog: dangerouslyAllowSignInWithoutUserInCatalog, - }, - ]); - return this; - } - - async enableGitlabLoginWithIngestion(): Promise { - console.log("Enabling GitLab login with ingestion..."); - - //expect the config variable to be set - expect(process.env.AUTH_PROVIDERS_GITLAB_HOST).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GITLAB_TOKEN).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GITLAB_PARENT_ORG).toBeDefined(); - - // enable the catalog backend dynamic plugin - // and set the required configuration properties - this.setDynamicPluginEnabled( - "./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-gitlab-org-dynamic", - true, + setGithubResolverImpl( + this, + resolver, + dangerouslyAllowSignInWithoutUserInCatalog, ); + return Promise.resolve(this); + } - this.setAppConfigProperty("catalog.providers", { - gitlab: { - default: { - host: "${AUTH_PROVIDERS_GITLAB_HOST}", - orgEnabled: true, - group: "${AUTH_PROVIDERS_GITLAB_PARENT_ORG}", - restrictUsersToGroup: true, - includeUsersWithoutSeat: true, - schedule: { - initialDelay: { - seconds: 0, - }, - frequency: { - minutes: 1, - }, - timeout: { - minutes: 1, - }, - }, - }, - }, - }); - - // enable gitlab integration - this.setAppConfigProperty("integrations", { - gitlab: [ - { - host: "${AUTH_PROVIDERS_GITLAB_HOST}", - token: "${AUTH_PROVIDERS_GITLAB_TOKEN}", - apiBaseUrl: "https://${AUTH_PROVIDERS_GITLAB_HOST}/api/v4", - }, - ], - }); - - // enable the gitlab login provider - this.setAppConfigProperty("auth.providers.gitlab", { - production: { - audience: "https://${AUTH_PROVIDERS_GITLAB_HOST}", - clientId: "${AUTH_PROVIDERS_GITLAB_CLIENT_ID}", - clientSecret: "${AUTH_PROVIDERS_GITLAB_CLIENT_SECRET}", - callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/gitlab/handler/frame", - }, - }); - - this.setAppConfigProperty("auth.environment", "production"); - this.setAppConfigProperty("signInPage", "gitlab"); - - return this; + enableGitlabLoginWithIngestion(): Promise { + enableGitlabLoginWithIngestion(this); + return Promise.resolve(this); } - async setGitlabResolver( + setGitlabResolver( resolver: string, - dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, + dangerouslyAllowSignInWithoutUserInCatalog = false, ): Promise { - this.setAppConfigProperty("auth.providers.gitlab.production.signIn.resolvers", [ - { - resolver: resolver, - dangerouslyAllowSignInWithoutUserInCatalog: dangerouslyAllowSignInWithoutUserInCatalog, - }, - ]); - return this; + setGitlabResolverImpl( + this, + resolver, + dangerouslyAllowSignInWithoutUserInCatalog, + ); + return Promise.resolve(this); } async waitForSynced(): Promise { - const synced = await this.followLogs(syncedLogRegex, 120000); - expect(synced).toBe(true); - await sleep(2000); + await waitForSyncedImpl(this); return this; } - parseGroupMemberFromEntity(group: GroupEntity): string[] { - if (!group.relations) { - return []; - } - return group.relations - .filter((r) => r.type === "hasMember") - .map((r) => r.targetRef.split("/")[1]); - } + parseGroupMemberFromEntity = parseGroupMemberFromEntity; + parseGroupChildrenFromEntity = parseGroupChildrenFromEntity; + parseGroupParentFromEntity = parseGroupParentFromEntity; - parseGroupChildrenFromEntity(group: GroupEntity): string[] { - if (!group.relations) { - return []; - } - return group.relations - .filter((r) => r.type === "parentOf") - .map((r) => r.targetRef.split("/")[1]); + checkUserIsIngestedInCatalog(users: string[]): Promise { + return checkUserIsIngestedInCatalog(this, users, () => + this.computeBackstageBackendUrl(), + ); } - parseGroupParentFromEntity(group: GroupEntity): string[] { - if (!group.relations) { - return []; - } - return group.relations - .filter((r) => r.type === "childOf") - .map((r) => r.targetRef.split("/")[1]); - } - - async checkUserIsIngestedInCatalog(users: string[]): Promise { - const api = new APIHelper(); - await api.UseStaticToken(this.staticToken); - await api.UseBaseUrl(await this.computeBackstageBackendUrl()); - const response: unknown = await api.getAllCatalogUsersFromAPI(); - const catalogUsers = getCatalogUsers(response); - expect(catalogUsers.length).toBeGreaterThan(0); - const catalogUsersDisplayNames: string[] = catalogUsers - .map((u) => u.spec.profile?.displayName) - .filter((name): name is string => name !== undefined); - console.log( - `Checking ${JSON.stringify(catalogUsersDisplayNames)} contains users ${JSON.stringify(users)}`, + checkGroupIsIngestedInCatalog(groups: string[]): Promise { + return checkGroupIsIngestedInCatalog(this, groups, () => + this.computeBackstageBackendUrl(), ); - const hasAllElems = users.every((elem) => catalogUsersDisplayNames.includes(elem)); - return hasAllElems; - } - - async checkGroupIsIngestedInCatalog(groups: string[]): Promise { - const api = new APIHelper(); - await api.UseStaticToken(this.staticToken); - await api.UseBaseUrl(await this.computeBackstageBackendUrl()); - const response: unknown = await api.getAllCatalogGroupsFromAPI(); - const catalogGroups = getCatalogGroups(response); - expect(catalogGroups.length).toBeGreaterThan(0); - const catalogGroupsDisplayNames: string[] = catalogGroups - .map((u) => u.spec.profile?.displayName) - .filter((name): name is string => name !== undefined); - console.log( - `Checking ${JSON.stringify(catalogGroupsDisplayNames)} contains groups ${JSON.stringify(groups)}`, + } + + checkUserIsInGroup(user: string, group: string): Promise { + return checkUserIsInGroup(this, user, group, () => + this.computeBackstageBackendUrl(), ); - const hasAllElems = groups.every((elem) => catalogGroupsDisplayNames.includes(elem)); - return hasAllElems; } - async checkUserIsInGroup(user: string, group: string): Promise { - const api = new APIHelper(); - await api.UseStaticToken(this.staticToken); - await api.UseBaseUrl(await this.computeBackstageBackendUrl()); - const entity: unknown = await api.getGroupEntityFromAPI(group); - if (!isGroupEntity(entity)) { - throw new Error(`Invalid group entity for ${group}`); - } - const members = this.parseGroupMemberFromEntity(entity); - console.log(`Checking group ${group} (${JSON.stringify(members)}) contains user ${user}`); - return members.includes(user); - } - - async checkGroupIsParentOfGroup(parent: string, child: string): Promise { - const api = new APIHelper(); - await api.UseStaticToken(this.staticToken); - await api.UseBaseUrl(await this.computeBackstageBackendUrl()); - const entity: unknown = await api.getGroupEntityFromAPI(parent); - if (!isGroupEntity(entity)) { - throw new Error(`Invalid group entity for ${parent}`); - } - const children = this.parseGroupChildrenFromEntity(entity); - console.log( - `Checking children of ${parent} (${JSON.stringify(children)}) contain group ${child}`, + checkGroupIsParentOfGroup(parent: string, child: string): Promise { + return checkGroupIsParentOfGroup(this, parent, child, () => + this.computeBackstageBackendUrl(), ); - return children.includes(child); } - async checkGroupIsChildOfGroup(child: string, parent: string): Promise { - const api = new APIHelper(); - await api.UseStaticToken(this.staticToken); - await api.UseBaseUrl(await this.computeBackstageBackendUrl()); - const entity: unknown = await api.getGroupEntityFromAPI(child); - if (!isGroupEntity(entity)) { - throw new Error(`Invalid group entity for ${child}`); - } - const parents = this.parseGroupParentFromEntity(entity); - console.log( - `Checking parents of ${child} (${JSON.stringify(parents)}) contain group ${parent}`, + checkGroupIsChildOfGroup(child: string, parent: string): Promise { + return checkGroupIsChildOfGroup(this, child, parent, () => + this.computeBackstageBackendUrl(), ); - return parents.includes(parent); } - async checkUserHasAnnotation( + checkUserHasAnnotation( user: string, annotationKey: string, expectedValue: string, ): Promise { - const api = new APIHelper(); - await api.UseStaticToken(this.staticToken); - await api.UseBaseUrl(await this.computeBackstageBackendUrl()); - const entity: unknown = await api.getCatalogUserFromAPI(user); - if (!isUserEntity(entity)) { - throw new Error(`Invalid user entity for ${user}`); - } - const annotations = entity.metadata?.annotations || {}; - const actualValue = annotations[annotationKey]; - console.log( - `Checking user ${user} has annotation ${annotationKey}=${expectedValue}, actual value: ${actualValue}`, + return checkUserHasAnnotation( + this, + user, + annotationKey, + expectedValue, + () => this.computeBackstageBackendUrl(), ); - return actualValue === expectedValue; } } diff --git a/e2e-tests/playwright/utils/common-auth-popup.ts b/e2e-tests/playwright/utils/common-auth-popup.ts new file mode 100644 index 0000000000..583a1c6ade --- /dev/null +++ b/e2e-tests/playwright/utils/common-auth-popup.ts @@ -0,0 +1,272 @@ +import { authenticator } from "otplib"; +import { expect, type Locator, type Page } from "@playwright/test"; + +export async function waitForAuthPopupReady(popup: Page): Promise { + await expect(async () => { + await popup.waitForLoadState("domcontentloaded"); + expect(popup).toBeTruthy(); + }).toPass({ + intervals: [5_000, 10_000], + timeout: 20 * 1000, + }); +} + +export async function tryAlreadyLoggedIn(popup: Page): Promise { + try { + await popup.waitForEvent("close", { timeout: 5000 }); + return "Already logged in"; + } catch { + return null; + } +} + +export async function handleGitHubPopupLogin( + popup: Page, + username: string, + password: string, + twofactor: string, +): Promise { + await waitForAuthPopupReady(popup); + const alreadyLoggedIn = await tryAlreadyLoggedIn(popup); + if (alreadyLoggedIn !== null) { + return alreadyLoggedIn; + } + + /* oxlint-disable playwright/no-raw-locators -- GitHub login popup (third-party) */ + 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"; + } + throw e; + } + /* oxlint-enable playwright/no-raw-locators */ +} + +async function findGitlabAuthorizeButton(popup: Page): Promise { + /* oxlint-disable playwright/no-raw-locators -- GitLab authorize popup (third-party) */ + const authorization = popup.getByTestId("authorize-button"); + const authorizationByText = popup.locator('button:has-text("Authorize")'); + /* oxlint-enable playwright/no-raw-locators */ + + let buttonToClick: Locator | undefined; + await expect(async () => { + if (await authorization.isVisible({ timeout: 2000 }).catch(() => false)) { + buttonToClick = authorization; + return true; + } + if ( + await authorizationByText.isVisible({ timeout: 2000 }).catch(() => false) + ) { + buttonToClick = authorizationByText; + return true; + } + throw new Error("Authorization button not found"); + }).toPass({ + intervals: [1000, 2000], + timeout: 15000, + }); + + if (buttonToClick === undefined) { + throw new Error("Failed to find authorization button"); + } + return buttonToClick; +} + +async function clickGitlabAuthorizeButton( + popup: Page, + authorizeButton: Locator, +): Promise { + await popup + .getByRole("document") + .click({ timeout: 1000 }) + .catch(() => {}); + + await authorizeButton.waitFor({ state: "visible", timeout: 5000 }); + await expect(authorizeButton).toBeEnabled({ timeout: 10000 }); + await authorizeButton.scrollIntoViewIfNeeded({ timeout: 5000 }); + + try { + await authorizeButton.click({ timeout: 5000 }); + } catch { + // oxlint-disable-next-line playwright/no-force-option -- overlay dismissal is unreliable in CI + await authorizeButton.click({ force: true, timeout: 5000 }); + } +} + +export async function handleGitlabPopupLogin( + popup: Page, + username: string, + password: string, +): Promise { + await waitForAuthPopupReady(popup); + const alreadyLoggedIn = await tryAlreadyLoggedIn(popup); + if (alreadyLoggedIn !== null) { + return alreadyLoggedIn; + } + + /* oxlint-disable playwright/no-raw-locators -- GitLab login popup (third-party) */ + try { + await popup.locator("#user_login").click({ timeout: 5000 }); + await popup.locator("#user_login").fill(username, { timeout: 5000 }); + await popup.locator("#user_password").click({ timeout: 5000 }); + await popup.locator("#user_password").fill(password, { timeout: 5000 }); + await popup.getByTestId("sign-in-button").click({ timeout: 5000 }); + + await popup + .waitForLoadState("domcontentloaded", { timeout: 10000 }) + .catch(() => {}); + + const twoFactorInput = popup.locator("#user_otp_attempt"); + if (await twoFactorInput.isVisible({ timeout: 5000 })) { + await popup.waitForEvent("close", { timeout: 20000 }); + return "Login successful"; + } + + const authorizeButton = await findGitlabAuthorizeButton(popup); + await clickGitlabAuthorizeButton(popup, authorizeButton); + + await popup.waitForEvent("close", { timeout: 20000 }); + return "Login successful"; + } catch (e) { + if (popup.isClosed()) { + return "Login successful"; + } + throw e; + } + /* oxlint-enable playwright/no-raw-locators */ +} + +async function fillMicrosoftCredentials( + popup: Page, + username: string, + password: string, +): Promise { + /* oxlint-disable playwright/no-raw-locators -- Microsoft Azure login popup (third-party) */ + 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"; + } + throw e; + } + /* oxlint-enable playwright/no-raw-locators */ +} + +export async function handleMicrosoftAzurePopupLogin( + popup: Page, + username: string, + password: string, +): Promise { + await waitForAuthPopupReady(popup); + const alreadyLoggedIn = await tryAlreadyLoggedIn(popup); + if (alreadyLoggedIn !== null) { + return alreadyLoggedIn; + } + return fillMicrosoftCredentials(popup, username, password); +} + +async function fillPingFederateCredentials( + popup: Page, + username: string, + password: string, +): Promise { + /* oxlint-disable playwright/no-raw-locators -- PingFederate login popup (third-party) */ + try { + await popup.locator("#username").click(); + await popup.locator("#username").fill(username, { timeout: 5000 }); + await popup.locator("#password").click(); + await popup.locator("#password").fill(password, { timeout: 5000 }); + await popup.locator("#signOnButton").click({ timeout: 5000 }); + await popup.locator("#allowButton").click({ timeout: 10000 }); + await popup.waitForEvent("close", { timeout: 2000 }); + return "Login successful"; + } catch (e) { + const errorElement = popup.locator(".ping-error, .error, [role=alert]"); + if (await errorElement.isVisible()) { + await popup.close(); + return "Login failed - invalid credentials"; + } + throw e; + } + /* oxlint-enable playwright/no-raw-locators */ +} + +export async function handlePingFederatePopupLogin( + popup: Page, + username: string, + password: string, +): Promise { + await waitForAuthPopupReady(popup); + const alreadyLoggedIn = await tryAlreadyLoggedIn(popup); + if (alreadyLoggedIn !== null) { + return alreadyLoggedIn; + } + return fillPingFederateCredentials(popup, username, password); +} + +export async function handleKeycloakPopupLogin( + popup: Page, + username: string, + password: string, +): Promise { + await waitForAuthPopupReady(popup); + const alreadyLoggedIn = await tryAlreadyLoggedIn(popup); + if (alreadyLoggedIn !== null) { + return alreadyLoggedIn; + } + + /* oxlint-disable playwright/no-raw-locators -- Keycloak OIDC login popup (third-party) */ + 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"; + } + throw e; + } + /* oxlint-enable playwright/no-raw-locators */ +} diff --git a/e2e-tests/playwright/utils/common-browser.ts b/e2e-tests/playwright/utils/common-browser.ts new file mode 100644 index 0000000000..0e8fe752e3 --- /dev/null +++ b/e2e-tests/playwright/utils/common-browser.ts @@ -0,0 +1,64 @@ +import { + type Browser, + type Cookie, + type Page, + type TestInfo, +} from "@playwright/test"; +import * as path from "path"; +import { + startCoverageForPage, + stopCoverageForPage, +} from "../support/coverage/test"; + +export function parseAuthStateCookies(content: string): Cookie[] { + const parsed: unknown = JSON.parse(content); + if ( + typeof parsed !== "object" || + parsed === null || + !("cookies" in parsed) || + !Array.isArray(parsed.cookies) + ) { + throw new TypeError( + "Invalid auth state: expected object with cookies array", + ); + } + const rawCookies: unknown[] = parsed.cookies; + const cookies = rawCookies.filter( + (cookie): cookie is Cookie => + typeof cookie === "object" && + cookie !== null && + "name" in cookie && + typeof cookie.name === "string" && + "value" in cookie && + typeof cookie.value === "string", + ); + if (cookies.length !== rawCookies.length) { + throw new TypeError("Invalid auth state: cookies must have name and value"); + } + return cookies; +} + +export async function setupBrowser(browser: Browser, testInfo: TestInfo) { + const context = await browser.newContext({ + ...(testInfo.retry > 0 && { + recordVideo: { + dir: `test-results/${path + .parse(testInfo.file) + .name.replace(".spec", "")}/${testInfo.titlePath[1]}`, + size: { width: 1280, height: 720 }, + }, + }), + }); + const page = await context.newPage(); + await startCoverageForPage(page); + + return { page, context }; +} + +export async function teardownBrowser( + page: Page, + testInfo: TestInfo, +): Promise { + await stopCoverageForPage(page, testInfo); + await page.close(); +} diff --git a/e2e-tests/playwright/utils/common.ts b/e2e-tests/playwright/utils/common.ts index c987738437..9f3d6f152c 100644 --- a/e2e-tests/playwright/utils/common.ts +++ b/e2e-tests/playwright/utils/common.ts @@ -1,44 +1,31 @@ -import * as fs from "fs"; -import * as path from "path"; - -import { test, Browser, Cookie, expect, Page, TestInfo, Locator } from "@playwright/test"; +import { UIhelper } from "./ui-helper"; import { authenticator } from "otplib"; - -import { getTranslations, getCurrentLanguage } from "../e2e/localization/locale"; -import { startCoverageForPage, stopCoverageForPage } from "../support/coverage/test"; -import { WAIT_OBJECTS } from "../support/page-objects/global-obj"; +import { test, Page } from "@playwright/test"; import { SETTINGS_PAGE_COMPONENTS } from "../support/page-objects/page-obj"; +import * as fs from "fs"; +import { + getTranslations, + getCurrentLanguage, +} from "../e2e/localization/locale"; import { getErrorMessage } from "./errors"; -import { UIhelper } from "./ui-helper"; +import { parseAuthStateCookies } from "./common-browser"; +import { + handleGitHubPopupLogin, + handleGitlabPopupLogin, + handleKeycloakPopupLogin, + handleMicrosoftAzurePopupLogin, + handlePingFederatePopupLogin, +} from "./common-auth-popup"; + +export { setupBrowser, teardownBrowser } from "./common-browser"; const t = getTranslations(); const lang = getCurrentLanguage(); -function parseAuthStateCookies(content: string): Cookie[] { - const parsed: unknown = JSON.parse(content); - if ( - typeof parsed !== "object" || - parsed === null || - !("cookies" in parsed) || - !Array.isArray(parsed.cookies) - ) { - throw new TypeError("Invalid auth state: expected object with cookies array"); - } - const rawCookies: unknown[] = parsed.cookies; - const cookies = rawCookies.filter( - (cookie): cookie is Cookie => - typeof cookie === "object" && - cookie !== null && - "name" in cookie && - typeof cookie.name === "string" && - "value" in cookie && - typeof cookie.value === "string", - ); - if (cookies.length !== rawCookies.length) { - throw new TypeError("Invalid auth state: cookies must have name and value"); - } - return cookies; -} +const LOADING_INDICATOR_SELECTORS = [ + 'div[class*="MuiLinearProgress-root"]', + '[class*="MuiCircularProgress-root"]', +] as const; export class Common { page: Page; @@ -53,20 +40,22 @@ export class Common { async loginAsGuest() { await this.page.goto("/"); await this.waitForLoad(240000); - // TODO - Remove it after https://issues.redhat.com/browse/RHIDP-2043. A Dynamic plugin for Guest Authentication Provider needs to be created + // RHIDP-2043: Remove dialog handler after dynamic Guest Authentication Provider plugin is created this.page.on("dialog", async (dialog) => { console.log(`Dialog message: ${dialog.message()}`); await dialog.accept(); }); await this.uiHelper.verifyHeading(t["rhdh"][lang]["signIn.page.title"]); - await this.uiHelper.clickButton(t["core-components"][lang]["signIn.guestProvider.enter"]); + await this.uiHelper.clickButton( + t["core-components"][lang]["signIn.guestProvider.enter"], + ); await this.uiHelper.waitForSideBarVisible(); } async waitForLoad(timeout = 120000) { - for (const item of Object.values(WAIT_OBJECTS)) { - await this.page.waitForSelector(item, { + for (const selector of LOADING_INDICATOR_SELECTORS) { + await this.page.waitForSelector(selector, { state: "hidden", timeout: timeout, }); @@ -90,7 +79,7 @@ export class Common { : userid === process.env.GH_USER2_ID ? process.env.GH_USER2_PASS : undefined; - if (!password) { + if (password === undefined || password === "") { throw new Error("Invalid User ID"); } await this.page.fill("#password", password); @@ -102,7 +91,10 @@ export class Common { (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.uiHelper.isTextVisible( + "too many codes have been submitted", + 3000, + )) ) { // GitHub TOTP codes cannot be reused within ~30s; wait for the next window. await new Promise((resolve) => { @@ -121,11 +113,9 @@ export class Common { await popup.waitForLoadState(); await popup.locator("#username").fill(userid); await popup.locator("#password").fill(password); - // Handle popup close during navigation (popup may close before navigation completes) try { await popup.locator("#kc-login").click({ timeout: 5000 }); } catch (error) { - // Popup likely closed - this is expected behavior if (!getErrorMessage(error).includes("Target closed")) { throw error; } @@ -150,22 +140,25 @@ export class Common { async loginAsGithubUser(userid: string = process.env.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 = parseAuthStateCookies(fs.readFileSync(sessionFileName, "utf-8")); + const cookies = parseAuthStateCookies( + fs.readFileSync(sessionFileName, "utf-8"), + ); await this.page.context().addCookies(cookies); console.log(`Reusing existing authentication state for user: ${userid}`); await this.page.goto("/"); await this.waitForLoad(12000); - await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); + await this.uiHelper.clickButton( + t["core-components"][lang]["signIn.title"], + ); await this.checkAndReauthorizeGithubApp(); } else { - // Perform login if no session file exists, then save the state await this.logintoGithub(userid); await this.page.goto("/"); await this.waitForLoad(240000); - await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); + await this.uiHelper.clickButton( + t["core-components"][lang]["signIn.title"], + ); await this.checkAndReauthorizeGithubApp(); await this.uiHelper.waitForSideBarVisible(); await this.page.context().storageState({ path: sessionFileName }); @@ -214,11 +207,15 @@ export class Common { await this.uiHelper.clickButton( t["user-settings"][lang]["providerSettingsItem.buttonTitle.signIn"], ); - await this.uiHelper.clickButton(t["core-components"][lang]["oauthRequestDialog.login"]); + await this.uiHelper.clickButton( + t["core-components"][lang]["oauthRequestDialog.login"], + ); await this.checkAndReauthorizeGithubApp(); await this.uiHelper.waitForLoginBtnDisappear(); } else { - console.log('"Log in" button is not visible. Skipping login popup actions.'); + console.log( + '"Log in" button is not visible. Skipping login popup actions.', + ); } } @@ -226,15 +223,15 @@ export class Common { const ghUserId = process.env.GH_USER_ID; const ghUser2Id = process.env.GH_USER2_ID; const secrets: Record = {}; - if (ghUserId) { + if (ghUserId !== undefined && ghUserId !== "") { secrets[ghUserId] = process.env.GH_2FA_SECRET; } - if (ghUser2Id) { + if (ghUser2Id !== undefined && ghUser2Id !== "") { secrets[ghUser2Id] = process.env.GH_USER2_2FA_SECRET; } const secret = secrets[userid]; - if (!secret) { + if (secret === undefined || secret === "") { throw new Error("Invalid User ID"); } @@ -243,7 +240,7 @@ export class Common { getGoogle2FAOTP(): string { const secret = process.env.GOOGLE_2FA_SECRET; - if (!secret) { + if (secret === undefined || secret === "") { throw new Error("GOOGLE_2FA_SECRET is not set"); } return authenticator.generate(secret); @@ -260,93 +257,7 @@ export class Common { this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), ]); - // Wait for the popup to appear - await expect(async () => { - await popup.waitForLoadState("domcontentloaded"); - expect(popup).toBeTruthy(); - }).toPass({ - intervals: [5_000, 10_000], - timeout: 20 * 1000, - }); - - // 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 - } - - /* oxlint-disable playwright/no-raw-locators -- Keycloak OIDC login popup (third-party) */ - 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"; - } - throw e; - } - /* oxlint-enable playwright/no-raw-locators */ - } - - private async handleGitHubPopupLogin( - popup: Page, - username: string, - password: string, - twofactor: string, - ): Promise { - 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 - } - - /* oxlint-disable playwright/no-raw-locators -- GitHub login popup (third-party) */ - 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"; - } - throw e; - } - /* oxlint-enable playwright/no-raw-locators */ + return handleKeycloakPopupLogin(popup, username, password); } async githubLogin(username: string, password: string, twofactor: string) { @@ -360,10 +271,14 @@ export class Common { this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), ]); - return this.handleGitHubPopupLogin(popup, username, password, twofactor); + return handleGitHubPopupLogin(popup, username, password, twofactor); } - async githubLoginFromSettingsPage(username: string, password: string, twofactor: string) { + async githubLoginFromSettingsPage( + username: string, + password: string, + twofactor: string, + ) { await this.page.goto("/settings/auth-providers"); const [popup] = await Promise.all([ @@ -376,117 +291,12 @@ export class Common { ), ) .click(), - this.uiHelper.clickButton(t["core-components"][lang]["oauthRequestDialog.login"]), + this.uiHelper.clickButton( + t["core-components"][lang]["oauthRequestDialog.login"], + ), ]); - return this.handleGitHubPopupLogin(popup, username, password, twofactor); - } - - private async handleGitlabPopupLogin( - popup: Page, - username: string, - password: string, - ): Promise { - 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 - } - - /* oxlint-disable playwright/no-raw-locators -- GitLab login popup (third-party) */ - try { - await popup.locator("#user_login").click({ timeout: 5000 }); - await popup.locator("#user_login").fill(username, { timeout: 5000 }); - await popup.locator("#user_password").click({ timeout: 5000 }); - await popup.locator("#user_password").fill(password, { timeout: 5000 }); - await popup.getByTestId("sign-in-button").click({ timeout: 5000 }); - - // Wait for navigation after sign-in (either to 2FA, authorization, or close) - await popup.waitForLoadState("domcontentloaded", { timeout: 10000 }).catch(() => { - // Continue if load state check fails - }); - - // Handle 2FA if present - const twoFactorInput = popup.locator("#user_otp_attempt"); - if (await twoFactorInput.isVisible({ timeout: 5000 })) { - // If 2FA is required, we'll need to handle it - // For now, we'll wait for the popup to close or authorization - await popup.waitForEvent("close", { timeout: 20000 }); - return "Login successful"; - } - - // Wait for authorization button to appear and click it - // Try data-testid first, then fallback to text-based selector - const authorization = popup.getByTestId("authorize-button"); - const authorizationByText = popup.locator('button:has-text("Authorize")'); - - // Wait for button to appear with retry logic - let buttonToClick: Locator | undefined; - await expect(async () => { - // Check data-testid first - if (await authorization.isVisible({ timeout: 2000 }).catch(() => false)) { - buttonToClick = authorization; - return true; - } - // Fallback to text-based selector - if (await authorizationByText.isVisible({ timeout: 2000 }).catch(() => false)) { - buttonToClick = authorizationByText; - return true; - } - throw new Error("Authorization button not found"); - }).toPass({ - intervals: [1000, 2000], - timeout: 15000, - }); - - if (!buttonToClick) { - throw new Error("Failed to find authorization button"); - } - - const authorizeButton = buttonToClick; - - // Click on document/body first to potentially dismiss any overlays (similar to GitHub flow) - await popup - .getByRole("document") - .click({ timeout: 1000 }) - .catch(() => { - // Ignore if document click fails - }); - - // Wait for button to be enabled and clickable - await authorizeButton.waitFor({ state: "visible", timeout: 5000 }); - await expect(authorizeButton).toBeEnabled({ timeout: 10000 }); - await authorizeButton.scrollIntoViewIfNeeded({ timeout: 5000 }); - - try { - await authorizeButton.click({ timeout: 5000 }); - } catch { - // Force click fallback when overlay blocks the authorization button. - // oxlint-disable-next-line playwright/no-force-option -- overlay dismissal is unreliable in CI - await authorizeButton.click({ force: true, timeout: 5000 }); - } - - await popup.waitForEvent("close", { timeout: 20000 }); - return "Login successful"; - } catch (e) { - // If popup close timeout, check if popup is already closed - if (popup.isClosed()) { - return "Login successful"; - } - // Re-throw other errors - throw e; - } - /* oxlint-enable playwright/no-raw-locators */ + return handleGitHubPopupLogin(popup, username, password, twofactor); } async gitlabLogin(username: string, password: string) { @@ -500,7 +310,7 @@ export class Common { this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), ]); - return this.handleGitlabPopupLogin(popup, username, password); + return handleGitlabPopupLogin(popup, username, password); } async MicrosoftAzureLogin(username: string, password: string) { @@ -514,42 +324,7 @@ export class Common { this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), ]); - // Wait for the popup to appear - await expect(async () => { - await popup.waitForLoadState("domcontentloaded"); - expect(popup).toBeTruthy(); - }).toPass({ - intervals: [5_000, 10_000], - timeout: 20 * 1000, - }); - - // 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 - } - - /* oxlint-disable playwright/no-raw-locators -- Microsoft Azure login popup (third-party) */ - 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"; - } - throw e; - } - /* oxlint-enable playwright/no-raw-locators */ + return handleMicrosoftAzurePopupLogin(popup, username, password); } async pingFederateLogin(username: string, password: string) { @@ -563,83 +338,6 @@ export class Common { this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), ]); - // Wait for the popup to appear - await expect(async () => { - await popup.waitForLoadState("domcontentloaded"); - expect(popup).toBeTruthy(); - }).toPass({ - intervals: [5_000, 10_000], - timeout: 20 * 1000, - }); - - // 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 - } - - /* oxlint-disable playwright/no-raw-locators -- PingFederate login popup (third-party) */ - try { - // Fill in username - await popup.locator("#username").click(); - await popup.locator("#username").fill(username, { timeout: 5000 }); - - // Fill in password - await popup.locator("#password").click(); - await popup.locator("#password").fill(password, { timeout: 5000 }); - - // Click sign in/login button (PingFederate uses id="signOnButton") - await popup.locator("#signOnButton").click({ timeout: 5000 }); - - // Click "Allow" button for scope authorization/consent - await popup.locator("#allowButton").click({ timeout: 10000 }); - - await popup.waitForEvent("close", { timeout: 2000 }); - return "Login successful"; - } catch (e) { - // Check for login error indicators - const errorElement = popup.locator(".ping-error, .error, [role=alert]"); - if (await errorElement.isVisible()) { - await popup.close(); - return "Login failed - invalid credentials"; - } - throw e; - } - /* oxlint-enable playwright/no-raw-locators */ + return handlePingFederatePopupLogin(popup, username, password); } } - -// Creates an isolated browser context for tests that share a page via beforeAll -// instead of using the built-in { page } fixture. Video recording must be configured -// here explicitly because the use.video option in playwright.config.ts only applies -// to the built-in fixtures, not to manually created contexts. -// -// Coverage is started automatically so specs that bypass the { page } fixture -// still participate in V8 JS coverage collection (RHIDP-13243). -// Call teardownBrowser() in afterAll to flush coverage and close the page. -export async function setupBrowser(browser: Browser, testInfo: TestInfo) { - const context = await browser.newContext({ - // only record video when the test block is being retried - ...(testInfo.retry > 0 && { - recordVideo: { - dir: `test-results/${path - .parse(testInfo.file) - .name.replace(".spec", "")}/${testInfo.titlePath[1]}`, - size: { width: 1280, height: 720 }, - }, - }), - }); - const page = await context.newPage(); - await startCoverageForPage(page); - - return { page, context }; -} - -// Flush V8 JS coverage collected during the test run and close the page. -// Pair with setupBrowser() in afterAll to ensure coverage data is written. -export async function teardownBrowser(page: Page, testInfo: TestInfo): Promise { - await stopCoverageForPage(page, testInfo); - await page.close(); -} diff --git a/e2e-tests/playwright/utils/constants.ts b/e2e-tests/playwright/utils/constants.ts index 33e8bb3a8b..9ec9431da9 100644 --- a/e2e-tests/playwright/utils/constants.ts +++ b/e2e-tests/playwright/utils/constants.ts @@ -4,7 +4,7 @@ export const JANUS_QE_ORG = "janus-qe"; export const SHOWCASE_REPO = `${JANUS_ORG}/backstage-showcase`; export const CATALOG_FILE = "catalog-info.yaml"; export const NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE = - /Login failed; caused by Error: Failed to sign-in, unable to resolve user identity. Please verify that your catalog contains the expected User entities that would match your configured sign-in resolver./; + /Login failed;u caused by Error: Failed to sign-in, unable to resolve user identity. Please verify that your catalog contains the expected User entities that would match your configured sign-in resolver./u; /** * CI/CD Environment variable patterns used for conditional test execution @@ -37,7 +37,7 @@ export const JOB_NAME_REGEX_PATTERNS = { * Matches OCP version patterns like "ocp-v4.15-*", "ocp-v4.16-*", etc. * Example: "periodic-ci-redhat-developer-rhdh-main-e2e-ocp-v4.15-helm-nightly" */ - OCP_VERSION: /ocp-v\d+-\d+/, + OCP_VERSION: /ocp-v\d+-\d+/u, } as const; /** diff --git a/e2e-tests/playwright/utils/helper.ts b/e2e-tests/playwright/utils/helper.ts index 04e7a976ca..28026a9336 100644 --- a/e2e-tests/playwright/utils/helper.ts +++ b/e2e-tests/playwright/utils/helper.ts @@ -58,7 +58,7 @@ export function skipIfJobName(jobNamePattern: JobNamePattern): boolean { */ export function skipIfJobNameRegex(jobNameRegexPattern: JobNameRegexPattern): boolean { const jobName = process.env.JOB_NAME; - if (!jobName) { + if (jobName === undefined || jobName === "") { return false; } return jobNameRegexPattern.test(jobName); diff --git a/e2e-tests/playwright/utils/keycloak/keycloak.ts b/e2e-tests/playwright/utils/keycloak/keycloak.ts index ef9cb572f6..7e0cab190d 100644 --- a/e2e-tests/playwright/utils/keycloak/keycloak.ts +++ b/e2e-tests/playwright/utils/keycloak/keycloak.ts @@ -29,7 +29,7 @@ function isGroupArray(data: unknown): data is Group[] { function requireBase64Env(name: string): string { const value = process.env[name]; - if (!value) { + if (value === undefined || value === "") { throw new Error(`Missing required environment variable: ${name}`); } return Buffer.from(value, "base64").toString(); diff --git a/e2e-tests/playwright/utils/kube-client-configmap.ts b/e2e-tests/playwright/utils/kube-client-configmap.ts new file mode 100644 index 0000000000..474dd57e9d --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client-configmap.ts @@ -0,0 +1,200 @@ +import * as k8s from "@kubernetes/client-node"; +import * as yaml from "js-yaml"; +import { hasErrorResponse } from "./errors"; +import { + APP_CONFIG_NAMES, + getKubeApiErrorMessage, + isRecord, +} from "./kube-client-helpers"; + +function hasAppConfigDataKey(data: Record): boolean { + return Object.keys(data).some( + (key) => key.includes("app-config") && key.endsWith(".yaml"), + ); +} + +function resolveAppConfigDataKey( + actualConfigMapName: string, + dataKeys: string[], +): string | undefined { + const keyPatterns = [ + `${actualConfigMapName}.yaml`, + ...APP_CONFIG_NAMES.map((name) => `${name}.yaml`), + ]; + + for (const pattern of keyPatterns) { + if (dataKeys.includes(pattern)) { + return pattern; + } + } + + return ( + dataKeys.find( + (key) => key.endsWith(".yaml") && key.includes("app-config"), + ) ?? dataKeys.find((key) => key.endsWith(".yaml")) + ); +} + +export async function findAppConfigMapName( + coreV1Api: k8s.CoreV1Api, + listConfigMaps: ( + namespace: string, + ) => Promise<{ body: { items: k8s.V1ConfigMap[] } }>, + namespace: string, +): Promise { + try { + const configMapsResponse = await listConfigMaps(namespace); + const configMaps = configMapsResponse.body.items; + + console.log( + `Found ${configMaps.length} ConfigMaps in namespace ${namespace}`, + ); + configMaps.forEach((cm) => { + console.log(`ConfigMap: ${cm.metadata?.name}`); + }); + + for (const name of APP_CONFIG_NAMES) { + const found = configMaps.find((cm) => cm.metadata?.name === name); + if (found !== undefined) { + console.log(`Found app config ConfigMap: ${name}`); + return name; + } + } + + for (const cm of configMaps) { + if (cm.data !== undefined && hasAppConfigDataKey(cm.data)) { + const configMapName = cm.metadata?.name ?? ""; + console.log(`Found ConfigMap with app-config data: ${configMapName}`); + return configMapName; + } + } + + throw new Error( + `No suitable app-config ConfigMap found in namespace ${namespace}`, + ); + } catch (error) { + console.error( + `Error finding app config ConfigMap: ${getKubeApiErrorMessage(error)}`, + ); + throw error; + } +} + +async function resolveConfigMapName( + coreV1Api: k8s.CoreV1Api, + configMapName: string, + namespace: string, + findAppConfigMap: (namespace: string) => Promise, +): Promise { + try { + await coreV1Api.readNamespacedConfigMap(configMapName, namespace); + console.log(`Using provided ConfigMap name: ${configMapName}`); + return configMapName; + } catch (error) { + if (hasErrorResponse(error) && error.response?.statusCode === 404) { + console.log( + `ConfigMap ${configMapName} not found, searching for alternatives...`, + ); + return findAppConfigMap(namespace); + } + throw error; + } +} + +function applyTitleToConfigMap( + configMap: k8s.V1ConfigMap, + actualConfigMapName: string, + dataKey: string, + newTitle: string, +): void { + if (configMap.data === undefined) { + throw new Error(`ConfigMap '${actualConfigMapName}' has no data section`); + } + + const appConfigYaml = configMap.data[dataKey]; + if (appConfigYaml === undefined || appConfigYaml === "") { + throw new Error( + `Data key '${dataKey}' is empty in ConfigMap '${actualConfigMapName}'`, + ); + } + + const parsedConfig: unknown = yaml.load(appConfigYaml); + if (!isRecord(parsedConfig) || !isRecord(parsedConfig.app)) { + throw new Error( + `Invalid app-config structure in ConfigMap '${actualConfigMapName}'. Expected 'app' section not found.`, + ); + } + + const appSection = parsedConfig.app; + const currentTitle = + typeof appSection.title === "string" ? appSection.title : undefined; + console.log(`Current title: ${currentTitle ?? "(none)"}`); + appSection.title = newTitle; + console.log(`New title: ${newTitle}`); + + configMap.data[dataKey] = yaml.dump(parsedConfig); + + if (configMap.metadata !== undefined) { + delete configMap.metadata.creationTimestamp; + delete configMap.metadata.resourceVersion; + } +} + +export async function updateConfigMapTitleImpl( + coreV1Api: k8s.CoreV1Api, + getConfigMap: ( + configmapName: string, + namespace: string, + ) => Promise<{ body: k8s.V1ConfigMap }>, + findAppConfigMap: (namespace: string) => Promise, + configMapName: string, + namespace: string, + newTitle: string, +): Promise { + try { + const actualConfigMapName = await resolveConfigMapName( + coreV1Api, + configMapName, + namespace, + findAppConfigMap, + ); + + const configMapResponse = await getConfigMap( + actualConfigMapName, + namespace, + ); + const configMap = configMapResponse.body; + + console.log(`Using ConfigMap: ${actualConfigMapName}`); + console.log( + `Available data keys: ${Object.keys(configMap.data ?? {}).join(", ")}`, + ); + + const dataKeys = Object.keys(configMap.data ?? {}); + const dataKey = resolveAppConfigDataKey(actualConfigMapName, dataKeys); + + if (dataKey === undefined) { + throw new Error( + `No suitable YAML data key found in ConfigMap '${actualConfigMapName}'. Available keys: ${dataKeys.join(", ")}`, + ); + } + + console.log(`Using data key: ${dataKey}`); + applyTitleToConfigMap(configMap, actualConfigMapName, dataKey, newTitle); + + await coreV1Api.replaceNamespacedConfigMap( + actualConfigMapName, + namespace, + configMap, + ); + console.log( + `ConfigMap '${actualConfigMapName}' updated successfully with new title: '${newTitle}'`, + ); + } catch (error) { + console.error(`Error updating ConfigMap: ${getKubeApiErrorMessage(error)}`); + throw new Error( + `Failed to update ConfigMap: ${getKubeApiErrorMessage(error)}`, + { cause: error }, + ); + } +} diff --git a/e2e-tests/playwright/utils/kube-client-deployment-restart.ts b/e2e-tests/playwright/utils/kube-client-deployment-restart.ts new file mode 100644 index 0000000000..7b68704c80 --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client-deployment-restart.ts @@ -0,0 +1,105 @@ +import { getKubeApiErrorMessage, sleep } from "./kube-client-helpers"; + +async function scaleDeploymentDown( + scaleDeployment: ( + deploymentName: string, + namespace: string, + replicas: number, + ) => Promise, + waitForDeploymentReady: ( + deploymentName: string, + namespace: string, + expectedReplicas: number, + timeout?: number, + ) => Promise, + logPodConditionsForDeployment: ( + deploymentName: string, + namespace: string, + ) => Promise, + deploymentName: string, + namespace: string, +): Promise { + console.log(`Scaling down deployment ${deploymentName} to 0 replicas.`); + console.log(`Deployment: ${deploymentName}, Namespace: ${namespace}`); + await logPodConditionsForDeployment(deploymentName, namespace); + await scaleDeployment(deploymentName, namespace, 0); + await waitForDeploymentReady(deploymentName, namespace, 0, 300000); + console.log("Waiting for pods to be fully terminated..."); + await sleep(10000); +} + +async function scaleDeploymentUp( + scaleDeployment: ( + deploymentName: string, + namespace: string, + replicas: number, + ) => Promise, + waitForDeploymentReady: ( + deploymentName: string, + namespace: string, + expectedReplicas: number, + timeout?: number, + ) => Promise, + deploymentName: string, + namespace: string, +): Promise { + console.log(`Scaling up deployment ${deploymentName} to 1 replica.`); + await scaleDeployment(deploymentName, namespace, 1); + await waitForDeploymentReady(deploymentName, namespace, 1, 600000); +} + +export async function restartDeploymentImpl( + scaleDeployment: ( + deploymentName: string, + namespace: string, + replicas: number, + ) => Promise, + waitForDeploymentReady: ( + deploymentName: string, + namespace: string, + expectedReplicas: number, + timeout?: number, + ) => Promise, + logPodConditionsForDeployment: ( + deploymentName: string, + namespace: string, + ) => Promise, + logDeploymentEvents: ( + deploymentName: string, + namespace: string, + ) => Promise, + deploymentName: string, + namespace: string, +): Promise { + try { + console.log( + `Starting deployment restart for ${deploymentName} in namespace ${namespace}`, + ); + await scaleDeploymentDown( + scaleDeployment, + waitForDeploymentReady, + logPodConditionsForDeployment, + deploymentName, + namespace, + ); + await scaleDeploymentUp( + scaleDeployment, + waitForDeploymentReady, + deploymentName, + namespace, + ); + console.log( + `Restart of deployment ${deploymentName} completed successfully.`, + ); + } catch (error) { + console.error( + `Error during deployment restart: Deployment '${deploymentName}' in namespace '${namespace}': ${getKubeApiErrorMessage(error)}`, + ); + await logPodConditionsForDeployment(deploymentName, namespace); + await logDeploymentEvents(deploymentName, namespace); + throw new Error( + `Failed to restart deployment '${deploymentName}' in namespace '${namespace}': ${getKubeApiErrorMessage(error)}`, + { cause: error }, + ); + } +} diff --git a/e2e-tests/playwright/utils/kube-client-deployment-scale.ts b/e2e-tests/playwright/utils/kube-client-deployment-scale.ts new file mode 100644 index 0000000000..f3014bf658 --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client-deployment-scale.ts @@ -0,0 +1,103 @@ +import * as k8s from "@kubernetes/client-node"; +import { + getErrorStatusCode, + getKubeApiErrorMessage, + sleep, +} from "./kube-client-helpers"; + +export async function getDeploymentPodSelectorImpl( + appsApi: k8s.AppsV1Api, + deploymentName: string, + namespace: string, +): Promise { + const response = await appsApi.readNamespacedDeployment( + deploymentName, + namespace, + ); + const matchLabels = response.body.spec?.selector?.matchLabels ?? {}; + const entries = Object.entries(matchLabels); + if (entries.length === 0) { + throw new Error( + `Deployment '${deploymentName}' in namespace '${namespace}' has no matchLabels in selector`, + ); + } + return entries.map(([k, v]) => `${k}=${v}`).join(","); +} + +async function patchDeploymentScale( + appsApi: k8s.AppsV1Api, + deploymentName: string, + namespace: string, + replicas: number, +): Promise { + const patch = { spec: { replicas } }; + await appsApi.patchNamespacedDeploymentScale( + deploymentName, + namespace, + patch, + undefined, + undefined, + undefined, + undefined, + undefined, + { + headers: { + "Content-Type": "application/strategic-merge-patch+json", + }, + }, + ); +} + +async function handleScaleRetry( + error: unknown, + attempt: number, + maxRetries: number, + deploymentName: string, +): Promise { + const statusCode = getErrorStatusCode(error); + const isRetryable = + statusCode === 404 || statusCode === 503 || statusCode === 429; + + if (isRetryable && attempt < maxRetries) { + const delay = attempt * 2000; + console.log( + `Deployment ${deploymentName} not ready (${String(statusCode)}). Retry ${attempt}/${maxRetries} after ${delay}ms...`, + ); + await sleep(delay); + return true; + } + + console.error( + `Failed to scale deployment ${deploymentName} after ${attempt} attempts:`, + getKubeApiErrorMessage(error), + ); + throw error; +} + +export async function scaleDeploymentImpl( + appsApi: k8s.AppsV1Api, + deploymentName: string, + namespace: string, + replicas: number, + maxRetries: number = 3, +): Promise { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await patchDeploymentScale(appsApi, deploymentName, namespace, replicas); + console.log( + `Deployment ${deploymentName} scaled to ${replicas} replicas.`, + ); + return; + } catch (error) { + const shouldRetry = await handleScaleRetry( + error, + attempt, + maxRetries, + deploymentName, + ); + if (!shouldRetry) { + return; + } + } + } +} diff --git a/e2e-tests/playwright/utils/kube-client-deployment-wait.ts b/e2e-tests/playwright/utils/kube-client-deployment-wait.ts new file mode 100644 index 0000000000..170b5f97c1 --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client-deployment-wait.ts @@ -0,0 +1,216 @@ +import * as k8s from "@kubernetes/client-node"; +import { + getKubeApiErrorMessage, + PodFailureResult, + sleep, +} from "./kube-client-helpers"; + +export interface DeploymentDiagnostics { + logDeploymentEvents: ( + deploymentName: string, + namespace: string, + ) => Promise; + logReplicaSetStatus: ( + deploymentName: string, + namespace: string, + ) => Promise; + logPodEvents: (namespace: string, labelSelector: string) => Promise; + logPodConditions: (namespace: string, labelSelector: string) => Promise; + logPodContainerLogs: ( + namespace: string, + labelSelector: string, + containerName?: string, + ) => Promise; +} + +async function handlePodFailureDuringWait( + diagnostics: DeploymentDiagnostics, + deploymentName: string, + namespace: string, + finalLabelSelector: string, + podFailure: PodFailureResult, +): Promise { + console.error( + `Pod failure detected: ${podFailure.message}. Logging events and pod logs...`, + ); + await diagnostics.logDeploymentEvents(deploymentName, namespace); + await diagnostics.logReplicaSetStatus(deploymentName, namespace); + await diagnostics.logPodEvents(namespace, finalLabelSelector); + await diagnostics.logPodConditions(namespace, finalLabelSelector); + await diagnostics.logPodContainerLogs( + namespace, + finalLabelSelector, + podFailure.containerName, + ); + throw new Error( + `Deployment ${deploymentName} failed to start: ${podFailure.message}`, + ); +} + +function logDeploymentStatus(response: { body: k8s.V1Deployment }): number { + const availableReplicas = response.body.status?.availableReplicas ?? 0; + const readyReplicas = response.body.status?.readyReplicas ?? 0; + const updatedReplicas = response.body.status?.updatedReplicas ?? 0; + const replicas = response.body.status?.replicas ?? 0; + const conditions = response.body.status?.conditions ?? []; + + console.log(`Available replicas: ${availableReplicas}`); + console.log(`Ready replicas: ${readyReplicas}`); + console.log(`Updated replicas: ${updatedReplicas}`); + console.log(`Desired replicas: ${replicas}`); + console.log("Deployment conditions:", JSON.stringify(conditions, null, 2)); + + return availableReplicas; +} + +async function checkDeploymentReplicaStatus( + appsApi: k8s.AppsV1Api, + checkPodFailureStates: ( + namespace: string, + labelSelector: string, + ) => Promise, + logPodConditions: (namespace: string, labelSelector: string) => Promise, + diagnostics: DeploymentDiagnostics, + deploymentName: string, + namespace: string, + expectedReplicas: number, + podSelector: string, + finalLabelSelector: string, +): Promise { + const response = await appsApi.readNamespacedDeployment( + deploymentName, + namespace, + ); + const availableReplicas = logDeploymentStatus(response); + + if (expectedReplicas > 0 && podSelector !== "") { + const podFailure = await checkPodFailureStates(namespace, podSelector); + if (podFailure !== null) { + await handlePodFailureDuringWait( + diagnostics, + deploymentName, + namespace, + finalLabelSelector, + podFailure, + ); + } + } + + await logPodConditions(namespace, podSelector); + + if (availableReplicas === expectedReplicas) { + console.log( + `Deployment ${deploymentName} is ready with ${availableReplicas} replicas.`, + ); + return true; + } + + return false; +} + +function isPodStartupFailure(error: unknown): boolean { + return error instanceof Error && error.message.includes("failed to start"); +} + +async function logDeploymentWaitProgress( + appsApi: k8s.AppsV1Api, + deploymentName: string, + namespace: string, + expectedReplicas: number, +): Promise { + const response = await appsApi.readNamespacedDeployment( + deploymentName, + namespace, + ); + const readyReplicas = response.body.status?.readyReplicas ?? 0; + console.log( + `Waiting for ${deploymentName} to become ready (${readyReplicas}/${expectedReplicas} ready)...`, + ); +} + +async function logDeploymentTimeoutDiagnostics( + diagnostics: DeploymentDiagnostics, + deploymentName: string, + namespace: string, + finalLabelSelector: string, +): Promise { + console.error( + `Timeout waiting for deployment ${deploymentName}. Collecting diagnostics...`, + ); + await diagnostics.logDeploymentEvents(deploymentName, namespace); + await diagnostics.logReplicaSetStatus(deploymentName, namespace); + await diagnostics.logPodEvents(namespace, finalLabelSelector); + await diagnostics.logPodConditions(namespace, finalLabelSelector); +} + +export async function waitForDeploymentReadyImpl( + appsApi: k8s.AppsV1Api, + getDeploymentPodSelector: ( + deploymentName: string, + namespace: string, + ) => Promise, + checkPodFailureStates: ( + namespace: string, + labelSelector: string, + ) => Promise, + logPodConditions: (namespace: string, labelSelector: string) => Promise, + diagnostics: DeploymentDiagnostics, + deploymentName: string, + namespace: string, + expectedReplicas: number, + timeout: number = 300000, + checkInterval: number = 10000, + labelSelector?: string, +): Promise { + const endTime = Date.now() + timeout; + const podSelector = await getDeploymentPodSelector(deploymentName, namespace); + const finalLabelSelector = labelSelector ?? podSelector; + const progressLogStart = endTime - timeout + checkInterval * 2; + + while (Date.now() < endTime) { + try { + const isReady = await checkDeploymentReplicaStatus( + appsApi, + checkPodFailureStates, + logPodConditions, + diagnostics, + deploymentName, + namespace, + expectedReplicas, + podSelector, + finalLabelSelector, + ); + if (isReady) { + return; + } + + if (Date.now() > progressLogStart) { + await logDeploymentWaitProgress( + appsApi, + deploymentName, + namespace, + expectedReplicas, + ); + } + } catch (error) { + console.error( + `Error checking deployment status: ${getKubeApiErrorMessage(error)}`, + ); + if (isPodStartupFailure(error)) { + throw error; + } + } + + await sleep(checkInterval); + } + + await logDeploymentTimeoutDiagnostics( + diagnostics, + deploymentName, + namespace, + finalLabelSelector, + ); + throw new Error( + `Deployment ${deploymentName} did not become ready in time (timeout: ${timeout / 1000}s).`, + ); +} diff --git a/e2e-tests/playwright/utils/kube-client-diagnostics-events.ts b/e2e-tests/playwright/utils/kube-client-diagnostics-events.ts new file mode 100644 index 0000000000..d91754dbf3 --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client-diagnostics-events.ts @@ -0,0 +1,197 @@ +import * as k8s from "@kubernetes/client-node"; +import { + DEFAULT_BACKSTAGE_LABEL_SELECTOR, + formatEventTimestamp, + getEventSortTimestamp, + getKubeApiErrorMessage, + podNameOrUnknown, +} from "./kube-client-helpers"; + +const BACKSTAGE_POD_NAME_FRAGMENT = "backstage-developer-hub"; + +function collectPodNames( + podsResponse: { body: { items: k8s.V1Pod[] } }, + allPodsResponse: { body: { items: k8s.V1Pod[] } }, +): Set { + const podNames = new Set(); + podsResponse.body.items.forEach((pod) => { + const name = pod.metadata?.name; + if (name !== undefined && name !== "") { + podNames.add(name); + } + }); + allPodsResponse.body.items.forEach((pod) => { + const name = pod.metadata?.name; + if ( + name !== undefined && + name !== "" && + name.includes(BACKSTAGE_POD_NAME_FRAGMENT) + ) { + podNames.add(name); + } + }); + return podNames; +} + +function isRelevantPodEvent( + event: k8s.CoreV1Event, + podNames: Set, +): boolean { + const involvedObject = event.involvedObject; + if (involvedObject?.kind !== "Pod") { + return false; + } + + const podName = involvedObject.name; + if (podName === undefined || podName === "") { + return false; + } + + return podNames.has(podName) || podName.includes(BACKSTAGE_POD_NAME_FRAGMENT); +} + +function logPodEvent(event: k8s.CoreV1Event): void { + const podName = podNameOrUnknown(event.involvedObject?.name); + const timestamp = formatEventTimestamp(event); + console.log( + ` [${timestamp}] Pod ${podName}: [${event.type}] ${event.reason}: ${event.message}`, + ); +} + +async function logExistingPodLogs( + coreV1Api: k8s.CoreV1Api, + pods: k8s.V1Pod[], + namespace: string, +): Promise { + console.log("\nAttempting to get logs from existing pods:"); + for (const pod of pods.slice(0, 3)) { + const podName = pod.metadata?.name; + if (podName === undefined || podName === "") { + continue; + } + + try { + const logs = await coreV1Api.readNamespacedPodLog( + podName, + namespace, + undefined, + false, + undefined, + undefined, + undefined, + undefined, + 50, + ); + if (logs.body !== undefined && logs.body !== "") { + const logLines = logs.body.split("\n").slice(-20); + console.log(`\n Pod ${podName} logs (last 20 lines):`); + logLines.forEach((line) => { + if (line.trim() !== "") { + console.log(` ${line}`); + } + }); + } + } catch (logError) { + console.log( + ` Could not get logs from ${podName}: ${getKubeApiErrorMessage(logError)}`, + ); + } + } +} + +async function fetchPodEventContext( + coreV1Api: k8s.CoreV1Api, + namespace: string, + selector: string, +): Promise<{ + podsResponse: { body: { items: k8s.V1Pod[] } }; + podEvents: k8s.CoreV1Event[]; +}> { + const podsResponse = await coreV1Api.listNamespacedPod( + namespace, + undefined, + undefined, + undefined, + undefined, + selector, + ); + const allPodsResponse = await coreV1Api.listNamespacedPod(namespace); + const eventsResponse = await coreV1Api.listNamespacedEvent(namespace); + const podNames = collectPodNames(podsResponse, allPodsResponse); + + const podEvents = [...eventsResponse.body.items] + .filter((event) => isRelevantPodEvent(event, podNames)) + // oxlint-disable-next-line unicorn/no-array-sort -- es2022 lib has no Array#toSorted + .sort( + (a: k8s.CoreV1Event, b: k8s.CoreV1Event) => + getEventSortTimestamp(b) - getEventSortTimestamp(a), + ) + .slice(0, 30); + + return { podsResponse, podEvents }; +} + +export async function logPodEventsImpl( + coreV1Api: k8s.CoreV1Api, + namespace: string, + labelSelector?: string, +): Promise { + const selector = labelSelector ?? DEFAULT_BACKSTAGE_LABEL_SELECTOR; + + try { + const { podsResponse, podEvents } = await fetchPodEventContext( + coreV1Api, + namespace, + selector, + ); + + if (podEvents.length > 0) { + console.log(`Recent pod events (last ${podEvents.length}):`); + for (const event of podEvents) { + logPodEvent(event); + } + } else { + console.log("No recent pod events found"); + } + + if (podsResponse.body.items.length > 0) { + await logExistingPodLogs(coreV1Api, podsResponse.body.items, namespace); + } + } catch (error) { + console.error( + `Error retrieving pod events for selector '${selector}': ${getKubeApiErrorMessage(error)}`, + ); + } +} + +export async function logDeploymentEventsImpl( + coreV1Api: k8s.CoreV1Api, + deploymentName: string, + namespace: string, +): Promise { + try { + const eventsResponse = await coreV1Api.listNamespacedEvent( + namespace, + undefined, + undefined, + undefined, + `involvedObject.name=${deploymentName}`, + ); + + console.log( + `Events for deployment ${deploymentName}: ${JSON.stringify( + eventsResponse.body.items.map((event) => ({ + message: event.message, + reason: event.reason, + type: event.type, + })), + null, + 2, + )}`, + ); + } catch (error) { + console.error( + `Error retrieving events for deployment ${deploymentName}: ${getKubeApiErrorMessage(error)}`, + ); + } +} diff --git a/e2e-tests/playwright/utils/kube-client-diagnostics-pods.ts b/e2e-tests/playwright/utils/kube-client-diagnostics-pods.ts new file mode 100644 index 0000000000..8dbfea39e3 --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client-diagnostics-pods.ts @@ -0,0 +1,223 @@ +import * as k8s from "@kubernetes/client-node"; +import { + DEFAULT_BACKSTAGE_LABEL_SELECTOR, + formatContainerStartedAt, + getKubeApiErrorMessage, + podNameOrUnknown, +} from "./kube-client-helpers"; + +function logWaitingContainerStatus( + containerName: string, + waiting: k8s.V1ContainerStateWaiting, +): void { + console.log( + ` ${containerName}: Waiting - ${waiting.reason}: ${waiting.message}`, + ); +} + +function logRunningContainerStatus( + containerName: string, + running: k8s.V1ContainerStateRunning, +): void { + console.log( + ` ${containerName}: Running (started: ${formatContainerStartedAt(running.startedAt)})`, + ); +} + +function logTerminatedContainerStatus( + containerName: string, + terminated: k8s.V1ContainerStateTerminated, +): void { + console.log( + ` ${containerName}: Terminated - Exit Code: ${terminated.exitCode}, Reason: ${terminated.reason}`, + ); + if (terminated.message !== undefined && terminated.message !== "") { + console.log(` Message: ${terminated.message}`); + } +} + +function logSingleContainerStatus( + containerStatus: k8s.V1ContainerStatus, +): void { + const containerName = containerStatus.name; + const waiting = containerStatus.state?.waiting; + const running = containerStatus.state?.running; + const terminated = containerStatus.state?.terminated; + + if (waiting !== undefined) { + logWaitingContainerStatus(containerName, waiting); + return; + } + if (running !== undefined) { + logRunningContainerStatus(containerName, running); + return; + } + if (terminated !== undefined) { + logTerminatedContainerStatus(containerName, terminated); + } +} + +function logPodContainerStatuses(pod: k8s.V1Pod): void { + const containerStatuses = [ + ...(pod.status?.containerStatuses ?? []), + ...(pod.status?.initContainerStatuses ?? []), + ]; + + if (containerStatuses.length === 0) { + return; + } + + console.log("Container Statuses:"); + for (const containerStatus of containerStatuses) { + logSingleContainerStatus(containerStatus); + } +} + +export async function logPodConditionsImpl( + coreV1Api: k8s.CoreV1Api, + namespace: string, + labelSelector: string, +): Promise { + try { + const response = await coreV1Api.listNamespacedPod( + namespace, + undefined, + undefined, + undefined, + undefined, + labelSelector, + ); + + if (response.body.items.length === 0) { + console.warn(`No pods found for selector: ${labelSelector}`); + } + + for (const pod of response.body.items) { + const podName = podNameOrUnknown(pod.metadata?.name); + const phase = pod.status?.phase; + console.log(`Pod: ${podName} (Phase: ${phase})`); + console.log( + "Conditions:", + JSON.stringify(pod.status?.conditions, null, 2), + ); + logPodContainerStatuses(pod); + } + } catch (error) { + console.error( + `Error while retrieving pod conditions for selector '${labelSelector}': ${getKubeApiErrorMessage(error)}`, + ); + } +} + +async function readContainerLogs( + coreV1Api: k8s.CoreV1Api, + podName: string, + namespace: string, + containerName: string, +): Promise { + console.log( + `\n=== Pod ${podName} - Container ${containerName} Logs (last 100 lines) ===`, + ); + const logs = await coreV1Api.readNamespacedPodLog( + podName, + namespace, + containerName, + false, + undefined, + undefined, + undefined, + undefined, + 100, + ); + + if (logs.body !== undefined && logs.body !== "") { + const logLines = logs.body.split("\n"); + logLines.forEach((line) => { + if (line.trim() !== "") { + console.log(line); + } + }); + return; + } + + console.log("(No logs available)"); +} + +function resolvePodContainers( + pod: k8s.V1Pod, + containerName?: string, +): Array<{ name: string }> { + if (containerName !== undefined && containerName !== "") { + return [{ name: containerName }]; + } + + return [...(pod.spec?.initContainers ?? []), ...(pod.spec?.containers ?? [])]; +} + +export async function logPodContainerLogsImpl( + coreV1Api: k8s.CoreV1Api, + namespace: string, + labelSelector?: string, + containerName?: string, +): Promise { + const selector = labelSelector ?? DEFAULT_BACKSTAGE_LABEL_SELECTOR; + + try { + const podsResponse = await coreV1Api.listNamespacedPod( + namespace, + undefined, + undefined, + undefined, + undefined, + selector, + ); + + if (podsResponse.body.items.length === 0) { + console.log("No pods found to retrieve logs from."); + return; + } + + for (const pod of podsResponse.body.items.slice(0, 2)) { + const podName = pod.metadata?.name; + if (podName === undefined || podName === "") { + continue; + } + + const containers = resolvePodContainers(pod, containerName); + for (const container of containers) { + const cn = container.name; + try { + await readContainerLogs(coreV1Api, podName, namespace, cn); + } catch (logError) { + const errorMsg = getKubeApiErrorMessage(logError); + console.warn( + `Could not retrieve logs for pod ${podName} container ${cn}: ${errorMsg}`, + ); + } + } + } + } catch (error) { + console.error( + `Error retrieving pod logs: ${getKubeApiErrorMessage(error)}`, + ); + } +} + +export async function logPodConditionsForDeploymentImpl( + logPodConditions: (namespace: string, labelSelector: string) => Promise, + getDeploymentPodSelector: ( + deploymentName: string, + namespace: string, + ) => Promise, + deploymentName: string, + namespace: string, +): Promise { + try { + const selector = await getDeploymentPodSelector(deploymentName, namespace); + await logPodConditions(namespace, selector); + } catch (error) { + console.warn( + `Could not resolve pod selector for deployment '${deploymentName}': ${getKubeApiErrorMessage(error)}`, + ); + } +} diff --git a/e2e-tests/playwright/utils/kube-client-diagnostics-replicasets.ts b/e2e-tests/playwright/utils/kube-client-diagnostics-replicasets.ts new file mode 100644 index 0000000000..15a2857c06 --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client-diagnostics-replicasets.ts @@ -0,0 +1,113 @@ +import * as k8s from "@kubernetes/client-node"; +import { + getKubeApiErrorMessage, + podNameOrUnknown, +} from "./kube-client-helpers"; + +function sortReplicaSetsByCreation( + replicaSets: k8s.V1ReplicaSet[], +): k8s.V1ReplicaSet[] { + // oxlint-disable-next-line unicorn/no-array-sort -- es2022 lib has no Array#toSorted + return [...replicaSets].sort((a: k8s.V1ReplicaSet, b: k8s.V1ReplicaSet) => { + const aTime = a.metadata?.creationTimestamp?.getTime() ?? 0; + const bTime = b.metadata?.creationTimestamp?.getTime() ?? 0; + return bTime - aTime; + }); +} + +function logReplicaSetSummary(rs: k8s.V1ReplicaSet): void { + const rsName = podNameOrUnknown(rs.metadata?.name); + const readyReplicas = rs.status?.readyReplicas ?? 0; + const availableReplicas = rs.status?.availableReplicas ?? 0; + const replicas = rs.status?.replicas ?? 0; + const fullyLabeledReplicas = rs.status?.fullyLabeledReplicas ?? 0; + const conditions = rs.status?.conditions ?? []; + + console.log(` ReplicaSet: ${rsName}`); + console.log( + ` Ready: ${readyReplicas}, Available: ${availableReplicas}, Desired: ${replicas}, Fully Labeled: ${fullyLabeledReplicas}`, + ); + if (conditions.length > 0) { + console.log(` Conditions: ${JSON.stringify(conditions, null, 2)}`); + } +} + +async function logReplicaSetEvents( + coreV1Api: k8s.CoreV1Api, + namespace: string, + rsName: string, +): Promise { + try { + const rsEvents = await coreV1Api.listNamespacedEvent( + namespace, + undefined, + undefined, + undefined, + `involvedObject.name=${rsName}`, + ); + + if (rsEvents.body.items.length > 0) { + console.log(` Events for ReplicaSet ${rsName}:`); + rsEvents.body.items.slice(0, 10).forEach((event) => { + console.log(` [${event.type}] ${event.reason}: ${event.message}`); + }); + return; + } + + console.log(` No events found for ReplicaSet ${rsName}`); + } catch (error) { + console.warn( + ` Could not retrieve events for ReplicaSet ${rsName}: ${getKubeApiErrorMessage(error)}`, + ); + } +} + +export async function logReplicaSetStatusImpl( + coreV1Api: k8s.CoreV1Api, + appsApi: k8s.AppsV1Api, + deploymentName: string, + namespace: string, +): Promise { + try { + const deployment = await appsApi.readNamespacedDeployment( + deploymentName, + namespace, + ); + + const labelSelector = deployment.body.spec?.selector?.matchLabels; + if (labelSelector === undefined) { + console.warn(`Deployment ${deploymentName} has no label selector`); + return; + } + + const selectorString = Object.entries(labelSelector) + .map(([key, value]) => `${key}=${value}`) + .join(","); + + const rsResponse = await appsApi.listNamespacedReplicaSet( + namespace, + undefined, + undefined, + undefined, + undefined, + selectorString, + ); + + console.log( + `Found ${rsResponse.body.items.length} ReplicaSet(s) for deployment ${deploymentName}:`, + ); + + const sortedReplicaSets = sortReplicaSetsByCreation(rsResponse.body.items); + for (const rs of sortedReplicaSets) { + logReplicaSetSummary(rs); + const rsName = rs.metadata?.name; + if (rsName !== undefined && rsName !== "") { + await logReplicaSetEvents(coreV1Api, namespace, rsName); + } + } + } catch (error) { + console.error( + `Error retrieving ReplicaSet status for deployment ${deploymentName}: ${getKubeApiErrorMessage(error)}`, + ); + } +} diff --git a/e2e-tests/playwright/utils/kube-client-exec.ts b/e2e-tests/playwright/utils/kube-client-exec.ts new file mode 100644 index 0000000000..4d2ea17bdc --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client-exec.ts @@ -0,0 +1,88 @@ +import * as stream from "stream"; +import * as k8s from "@kubernetes/client-node"; +import { getKubeApiErrorMessage } from "./kube-client-helpers"; + +function createOutputCaptureStreams(): { + capture: { stdout: string; stderr: string }; + stdoutStream: stream.Writable; + stderrStream: stream.Writable; +} { + const capture = { + stdout: "", + stderr: "", + }; + + const stdoutStream = new stream.Writable({ + write(chunk: Buffer, encoding: string, callback: () => void) { + capture.stdout += chunk.toString(); + callback(); + }, + }); + const stderrStream = new stream.Writable({ + write(chunk: Buffer, encoding: string, callback: () => void) { + capture.stderr += chunk.toString(); + callback(); + }, + }); + + return { capture, stdoutStream, stderrStream }; +} + +function buildExecFailureMessage(status: k8s.V1Status, stderr: string): string { + const statusMessage = + status.message !== undefined && status.message !== "" + ? status.message + : undefined; + const stderrMessage = stderr === "" ? "unknown error" : stderr; + return statusMessage ?? stderrMessage; +} + +export async function execPodCommandImpl( + kc: k8s.KubeConfig, + podName: string, + namespace: string, + containerName: string, + command: string[], + timeout: number = 60000, +): Promise<{ stdout: string; stderr: string }> { + try { + const exec = new k8s.Exec(kc); + const capture = createOutputCaptureStreams(); + + await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error(`Command execution timed out after ${timeout}ms`)); + }, timeout); + + void exec.exec( + namespace, + podName, + containerName, + command, + capture.stdoutStream, + capture.stderrStream, + null, + false, + (status: k8s.V1Status) => { + clearTimeout(timeoutId); + if (status.status === "Success") { + resolve(); + } else { + reject( + new Error( + `Command execution failed: ${buildExecFailureMessage(status, capture.capture.stderr)}`, + ), + ); + } + }, + ); + }); + + return { stdout: capture.capture.stdout, stderr: capture.capture.stderr }; + } catch (error) { + throw new Error( + `Failed to execute command in pod ${podName}: ${getKubeApiErrorMessage(error)}`, + { cause: error }, + ); + } +} diff --git a/e2e-tests/playwright/utils/kube-client-helpers.ts b/e2e-tests/playwright/utils/kube-client-helpers.ts new file mode 100644 index 0000000000..2599c74c22 --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client-helpers.ts @@ -0,0 +1,164 @@ +import * as k8s from "@kubernetes/client-node"; +import { getErrorMessage, hasErrorResponse, hasStatusCode } from "./errors"; + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Structured result from checkPodFailureStates() containing both + * a human-readable message and the failing container name (if applicable). + */ +export interface PodFailureResult { + /** Human-readable description of the failure */ + message: string; + /** The name of the failing container, if the failure is container-scoped */ + containerName?: string; +} + +export const APP_CONFIG_NAMES = [ + "app-config-rhdh", + "app-config", + "backstage-app-config", + "rhdh-app-config", +] as const; + +export const DEFAULT_BACKSTAGE_LABEL_SELECTOR = + "app.kubernetes.io/component=backstage,app.kubernetes.io/instance=rhdh,app.kubernetes.io/name=backstage"; + +export function getErrorStatusCode(error: unknown): number | undefined { + if (hasErrorResponse(error) && error.response?.statusCode !== undefined) { + return error.response.statusCode; + } + if (hasStatusCode(error)) { + return error.statusCode; + } + return undefined; +} + +export function getErrorBodyMessage(error: unknown): string | undefined { + if ( + hasErrorResponse(error) && + typeof error.body?.message === "string" && + error.body.message !== "" + ) { + return error.body.message; + } + return undefined; +} + +export function formatKubeErrorLog(error: unknown): string { + return getErrorBodyMessage(error) ?? getKubeApiErrorMessage(error); +} + +export function getEventSortTimestamp(event: k8s.CoreV1Event): number { + if (event.firstTimestamp !== undefined) { + return typeof event.firstTimestamp === "string" + ? new Date(event.firstTimestamp).getTime() + : event.firstTimestamp.getTime(); + } + if (event.eventTime !== undefined) { + return typeof event.eventTime === "string" + ? new Date(event.eventTime).getTime() + : event.eventTime.getTime(); + } + return 0; +} + +export function formatEventTimestamp(event: k8s.CoreV1Event): string { + if (event.firstTimestamp !== undefined) { + return typeof event.firstTimestamp === "string" + ? new Date(event.firstTimestamp).toISOString() + : event.firstTimestamp.toISOString(); + } + if (event.eventTime !== undefined) { + return typeof event.eventTime === "string" + ? new Date(event.eventTime).toISOString() + : event.eventTime.toISOString(); + } + return "unknown"; +} + +export function formatContainerStartedAt( + startedAt: Date | string | undefined, +): string { + if (startedAt === undefined || startedAt === "") { + return "unknown"; + } + return typeof startedAt === "string" + ? new Date(startedAt).toISOString() + : startedAt.toISOString(); +} + +/** + * Safely extracts error information from Kubernetes API errors without leaking sensitive data. + */ +export function getKubeApiErrorMessage(error: unknown): string { + if (hasErrorResponse(error)) { + const body: unknown = error.body; + if (isRecord(body) && typeof body.message === "string") { + const parts = [body.message]; + if (typeof body.reason === "string") { + parts.push(`reason: ${body.reason}`); + } + if (typeof body.code === "number") { + parts.push(`code: ${String(body.code)}`); + } + return parts.join(", "); + } + + const response: unknown = error.response; + if (isRecord(response) && typeof response.statusCode === "number") { + const statusMessage = + typeof response.statusMessage === "string" + ? response.statusMessage + : "Unknown error"; + return `HTTP ${String(response.statusCode)}: ${statusMessage}`; + } + } + + if (hasStatusCode(error)) { + return `HTTP ${String(error.statusCode)}`; + } + + if (error instanceof Error) { + return error.message; + } + + const message = getErrorMessage(error); + return message === "" ? "Unknown Kubernetes API error" : message; +} + +/** + * Returns the RHDH deployment name based on the install method. + */ +export function getRhdhDeploymentName(): string { + const releaseName = + process.env.RELEASE_NAME !== undefined && process.env.RELEASE_NAME !== "" + ? process.env.RELEASE_NAME + : "rhdh"; + const job = process.env.JOB_NAME ?? ""; + if (job.includes("operator")) { + return `backstage-${releaseName}`; + } + return `${releaseName}-developer-hub`; +} + +export function rejectAsError( + reject: (reason: Error) => void, + err: unknown, +): void { + reject(err instanceof Error ? err : new Error(getErrorMessage(err))); +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, ms); + }); +} + +export function podNameOrUnknown(name: string | undefined): string { + return name !== undefined && name !== "" ? name : "unknown"; +} diff --git a/e2e-tests/playwright/utils/kube-client-pod-failure.ts b/e2e-tests/playwright/utils/kube-client-pod-failure.ts new file mode 100644 index 0000000000..70e39615c9 --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client-pod-failure.ts @@ -0,0 +1,233 @@ +import * as k8s from "@kubernetes/client-node"; +import { + getKubeApiErrorMessage, + PodFailureResult, + podNameOrUnknown, +} from "./kube-client-helpers"; + +const POD_READY_ERROR_REASONS = [ + "Unhealthy", + "ReadinessGatesNotReady", + "PodHasNoResources", +] as const; + +const CONTAINER_FAILURE_STATES = [ + "CrashLoopBackOff", + "ImagePullBackOff", + "ErrImagePull", + "InvalidImageName", + "CreateContainerConfigError", + "CreateContainerError", + "ErrImageNeverPull", + "RegistryUnavailable", +] as const; + +function isTransientPvcSchedulingMessage(message: string): boolean { + return ( + message.includes("ephemeral volume") || + message.includes("persistentvolumeclaim") + ); +} + +function checkFailedPodPhase(pod: k8s.V1Pod): PodFailureResult | null { + const podName = podNameOrUnknown(pod.metadata?.name); + if (pod.status?.phase !== "Failed") { + return null; + } + + const reason = pod.status.reason ?? "Unknown"; + const message = pod.status.message ?? ""; + return { + message: `Pod ${podName} is in Failed phase: ${reason} - ${message}`, + }; +} + +function checkPodScheduledCondition( + pod: k8s.V1Pod, + condition: k8s.V1PodCondition, +): PodFailureResult | null { + const podName = podNameOrUnknown(pod.metadata?.name); + const msg = condition.message ?? ""; + if (isTransientPvcSchedulingMessage(msg)) { + console.log( + `Pod ${podName} waiting for PVC creation (transient): ${condition.reason} - ${msg}`, + ); + return null; + } + + return { + message: `Pod ${podName} cannot be scheduled: ${condition.reason} - ${msg}`, + }; +} + +function checkPodReadyCondition( + pod: k8s.V1Pod, + condition: k8s.V1PodCondition, +): PodFailureResult | null { + const reason = condition.reason; + if ( + reason === undefined || + reason === "" || + reason === "ContainersNotReady" + ) { + return null; + } + + if (!(POD_READY_ERROR_REASONS as readonly string[]).includes(reason)) { + return null; + } + + const podName = podNameOrUnknown(pod.metadata?.name); + return { + message: `Pod ${podName} is not ready: ${reason} - ${condition.message}`, + }; +} + +function checkPodConditions(pod: k8s.V1Pod): PodFailureResult | null { + const conditions = pod.status?.conditions ?? []; + for (const condition of conditions) { + if (condition.type === "PodScheduled" && condition.status === "False") { + const result = checkPodScheduledCondition(pod, condition); + if (result !== null) { + return result; + } + return null; + } + + if (condition.type === "Ready" && condition.status === "False") { + const result = checkPodReadyCondition(pod, condition); + if (result !== null) { + return result; + } + } + } + return null; +} + +function checkWaitingContainerState( + pod: k8s.V1Pod, + containerStatus: k8s.V1ContainerStatus, + waiting: k8s.V1ContainerStateWaiting, +): PodFailureResult | null { + const podName = podNameOrUnknown(pod.metadata?.name); + const containerName = containerStatus.name; + const reason = waiting.reason ?? ""; + + if (!(CONTAINER_FAILURE_STATES as readonly string[]).includes(reason)) { + const message = waiting.message ?? ""; + return { + message: `Pod ${podName} container ${containerName} is in ${reason} state: ${message}`, + containerName, + }; + } + + if ( + reason === "ContainerCreating" && + waiting.message !== undefined && + waiting.message !== "" + ) { + console.log( + `Pod ${podName} container ${containerName} is being created: ${waiting.message}`, + ); + } + + return null; +} + +function checkTerminatedContainerState( + pod: k8s.V1Pod, + containerStatus: k8s.V1ContainerStatus, + terminated: k8s.V1ContainerStateTerminated, +): PodFailureResult | null { + if (terminated.exitCode === 0) { + return null; + } + + const podName = podNameOrUnknown(pod.metadata?.name); + const containerName = containerStatus.name; + const reason = terminated.reason ?? "Error"; + const message = terminated.message ?? ""; + return { + message: `Pod ${podName} container ${containerName} terminated with exit code ${terminated.exitCode}: ${reason} - ${message}`, + containerName, + }; +} + +function checkContainerStatuses(pod: k8s.V1Pod): PodFailureResult | null { + const containerStatuses = [ + ...(pod.status?.containerStatuses ?? []), + ...(pod.status?.initContainerStatuses ?? []), + ]; + + for (const containerStatus of containerStatuses) { + const waiting = containerStatus.state?.waiting; + if (waiting !== undefined) { + const waitingResult = checkWaitingContainerState( + pod, + containerStatus, + waiting, + ); + if (waitingResult !== null) { + return waitingResult; + } + } + + const terminated = containerStatus.state?.terminated; + if (terminated !== undefined) { + const terminatedResult = checkTerminatedContainerState( + pod, + containerStatus, + terminated, + ); + if (terminatedResult !== null) { + return terminatedResult; + } + } + } + + return null; +} + +function checkSinglePodFailure(pod: k8s.V1Pod): PodFailureResult | null { + return ( + checkFailedPodPhase(pod) ?? + checkPodConditions(pod) ?? + checkContainerStatuses(pod) + ); +} + +export async function checkPodFailureStatesImpl( + coreV1Api: k8s.CoreV1Api, + namespace: string, + labelSelector: string, +): Promise { + try { + const response = await coreV1Api.listNamespacedPod( + namespace, + undefined, + undefined, + undefined, + undefined, + labelSelector, + ); + + const pods = response.body.items; + if (pods.length === 0) { + return null; + } + + for (const pod of pods) { + const failure = checkSinglePodFailure(pod); + if (failure !== null) { + return failure; + } + } + + return null; + } catch (error) { + console.error( + `Error checking pod failure states: ${getKubeApiErrorMessage(error)}`, + ); + return null; + } +} diff --git a/e2e-tests/playwright/utils/kube-client.ts b/e2e-tests/playwright/utils/kube-client.ts index adbed047e3..dc613030a8 100644 --- a/e2e-tests/playwright/utils/kube-client.ts +++ b/e2e-tests/playwright/utils/kube-client.ts @@ -1,124 +1,39 @@ -import * as stream from "stream"; - import * as k8s from "@kubernetes/client-node"; import { V1ConfigMap } from "@kubernetes/client-node"; -import * as yaml from "js-yaml"; - -import { getErrorMessage, hasErrorResponse, hasStatusCode } from "./errors"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * Structured result from checkPodFailureStates() containing both - * a human-readable message and the failing container name (if applicable). - */ -interface PodFailureResult { - /** Human-readable description of the failure */ - message: string; - /** The name of the failing container, if the failure is container-scoped */ - containerName?: string; -} - -function getErrorStatusCode(error: unknown): number | undefined { - if (hasErrorResponse(error) && error.response?.statusCode !== undefined) { - return error.response.statusCode; - } - if (hasStatusCode(error)) { - return error.statusCode; - } - return undefined; -} - -function getEventSortTimestamp(event: k8s.CoreV1Event): number { - if (event.firstTimestamp) { - return typeof event.firstTimestamp === "string" - ? new Date(event.firstTimestamp).getTime() - : event.firstTimestamp.getTime(); - } - if (event.eventTime) { - return typeof event.eventTime === "string" - ? new Date(event.eventTime).getTime() - : event.eventTime.getTime(); - } - return 0; -} - -function formatEventTimestamp(event: k8s.CoreV1Event): string { - if (event.firstTimestamp) { - return typeof event.firstTimestamp === "string" - ? new Date(event.firstTimestamp).toISOString() - : event.firstTimestamp.toISOString(); - } - if (event.eventTime) { - return typeof event.eventTime === "string" - ? new Date(event.eventTime).toISOString() - : event.eventTime.toISOString(); - } - return "unknown"; -} - -function formatContainerStartedAt(startedAt: Date | string | undefined): string { - if (!startedAt) { - return "unknown"; - } - return typeof startedAt === "string" - ? new Date(startedAt).toISOString() - : startedAt.toISOString(); -} - -/** - * Safely extracts error information from Kubernetes API errors without leaking sensitive data. - * The @kubernetes/client-node HttpError contains the full HTTP request/response which includes - * the Authorization header with the bearer token. This function extracts only safe information. - */ -function getKubeApiErrorMessage(error: unknown): string { - if (hasErrorResponse(error)) { - const body: unknown = error.body; - if (isRecord(body) && typeof body.message === "string") { - const parts = [body.message]; - if (typeof body.reason === "string") { - parts.push(`reason: ${body.reason}`); - } - if (typeof body.code === "number") { - parts.push(`code: ${String(body.code)}`); - } - return parts.join(", "); - } - - const response: unknown = error.response; - if (isRecord(response) && typeof response.statusCode === "number") { - const statusMessage = - typeof response.statusMessage === "string" ? response.statusMessage : "Unknown error"; - return `HTTP ${String(response.statusCode)}: ${statusMessage}`; - } - } - - if (hasStatusCode(error)) { - return `HTTP ${String(error.statusCode)}`; - } - - if (error instanceof Error) { - return error.message; - } - - return getErrorMessage(error) || "Unknown Kubernetes API error"; -} - -/** - * Returns the RHDH deployment name based on the install method. - * Operator deployments use "backstage-" naming, - * Helm deployments use "-developer-hub" naming. - */ -export function getRhdhDeploymentName(): string { - const releaseName = process.env.RELEASE_NAME || "rhdh"; - const job = process.env.JOB_NAME || ""; - if (job.includes("operator")) { - return `backstage-${releaseName}`; - } - return `${releaseName}-developer-hub`; -} +import { hasStatusCode } from "./errors"; +import { + findAppConfigMapName, + updateConfigMapTitleImpl, +} from "./kube-client-configmap"; +import { + logDeploymentEventsImpl, + logPodEventsImpl, +} from "./kube-client-diagnostics-events"; +import { + logPodConditionsForDeploymentImpl, + logPodContainerLogsImpl, + logPodConditionsImpl, +} from "./kube-client-diagnostics-pods"; +import { logReplicaSetStatusImpl } from "./kube-client-diagnostics-replicasets"; +import { restartDeploymentImpl } from "./kube-client-deployment-restart"; +import { + getDeploymentPodSelectorImpl, + scaleDeploymentImpl, +} from "./kube-client-deployment-scale"; +import { waitForDeploymentReadyImpl } from "./kube-client-deployment-wait"; +import { execPodCommandImpl } from "./kube-client-exec"; +import { + formatKubeErrorLog, + getErrorStatusCode, + getKubeApiErrorMessage, + getRhdhDeploymentName, + PodFailureResult, + rejectAsError, +} from "./kube-client-helpers"; +import { checkPodFailureStatesImpl } from "./kube-client-pod-failure"; + +export { getRhdhDeploymentName }; +export type { PodFailureResult }; export class KubeClient { coreV1Api: k8s.CoreV1Api; @@ -157,19 +72,24 @@ export class KubeClient { this.coreV1Api = this.kc.makeApiClient(k8s.CoreV1Api); this.customObjectsApi = this.kc.makeApiClient(k8s.CustomObjectsApi); } catch (e) { - console.log(`Error initializing KubeClient: ${getKubeApiErrorMessage(e)}`); + console.log( + `Error initializing KubeClient: ${getKubeApiErrorMessage(e)}`, + ); throw e; } } async getConfigMap(configmapName: string, namespace: string) { try { - console.log(`Getting configmap ${configmapName} from namespace ${namespace}`); - return await this.coreV1Api.readNamespacedConfigMap(configmapName, namespace); - } catch (e) { console.log( - hasErrorResponse(e) && e.body?.message ? e.body.message : getKubeApiErrorMessage(e), + `Getting configmap ${configmapName} from namespace ${namespace}`, ); + return await this.coreV1Api.readNamespacedConfigMap( + configmapName, + namespace, + ); + } catch (e) { + console.log(formatKubeErrorLog(e)); throw e; } } @@ -179,117 +99,43 @@ export class KubeClient { console.log(`Listing configmaps in namespace ${namespace}`); return await this.coreV1Api.listNamespacedConfigMap(namespace); } catch (e) { - console.error( - hasErrorResponse(e) && e.body?.message ? e.body.message : getKubeApiErrorMessage(e), - ); + console.error(formatKubeErrorLog(e)); throw e; } } - // Define possible ConfigMap base names as a constant - private readonly appConfigNames = [ - "app-config-rhdh", - "app-config", - "backstage-app-config", - "rhdh-app-config", - ]; - - async findAppConfigMap(namespace: string): Promise { - try { - const configMapsResponse = await this.listConfigMaps(namespace); - const configMaps = configMapsResponse.body.items; - - console.log(`Found ${configMaps.length} ConfigMaps in namespace ${namespace}`); - configMaps.forEach((cm) => { - console.log(`ConfigMap: ${cm.metadata?.name}`); - }); - - for (const name of this.appConfigNames) { - const found = configMaps.find((cm) => cm.metadata?.name === name); - if (found) { - console.log(`Found app config ConfigMap: ${name}`); - return name; - } - } - - // If none of the expected names found, look for ConfigMaps containing app-config data - for (const cm of configMaps) { - if ( - cm.data && - Object.keys(cm.data).some((key) => key.includes("app-config") && key.endsWith(".yaml")) - ) { - console.log(`Found ConfigMap with app-config data: ${cm.metadata?.name}`); - return cm.metadata?.name || ""; - } - } - - throw new Error(`No suitable app-config ConfigMap found in namespace ${namespace}`); - } catch (error) { - console.error(`Error finding app config ConfigMap: ${getKubeApiErrorMessage(error)}`); - throw error; - } + findAppConfigMap(namespace: string): Promise { + return findAppConfigMapName( + this.coreV1Api, + (ns) => this.listConfigMaps(ns), + namespace, + ); } async getNamespaceByName(name: string): Promise { try { return (await this.coreV1Api.readNamespace(name)).body; } catch (e) { - console.log(`Error getting namespace ${name}: ${getKubeApiErrorMessage(e)}`); + console.log( + `Error getting namespace ${name}: ${getKubeApiErrorMessage(e)}`, + ); throw e; } } - async scaleDeployment( + scaleDeployment( deploymentName: string, namespace: string, replicas: number, maxRetries: number = 3, ) { - const patch = { spec: { replicas: replicas } }; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - await this.appsApi.patchNamespacedDeploymentScale( - deploymentName, - namespace, - patch, - undefined, - undefined, - undefined, - undefined, - undefined, - { - headers: { - "Content-Type": "application/strategic-merge-patch+json", - }, - }, - ); - console.log(`Deployment ${deploymentName} scaled to ${replicas} replicas.`); - return; - } catch (error) { - const statusCode = getErrorStatusCode(error); - const isNotFound = statusCode === 404; - const isRetryable = isNotFound || statusCode === 503 || statusCode === 429; - - if (isRetryable && attempt < maxRetries) { - const delay = attempt * 2000; // 2s, 4s, 6s, 8s... - console.log( - `Deployment ${deploymentName} not ready (${statusCode}). Retry ${attempt}/${maxRetries} after ${delay}ms...`, - ); - await new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, delay); - }); - } else { - console.error( - `Failed to scale deployment ${deploymentName} after ${attempt} attempts:`, - getKubeApiErrorMessage(error), - ); - throw error; - } - } - } + return scaleDeploymentImpl( + this.appsApi, + deploymentName, + namespace, + replicas, + maxRetries, + ); } async getSecret(secretName: string, namespace: string) { @@ -297,14 +143,16 @@ export class KubeClient { console.log(`Getting secret ${secretName} from namespace ${namespace}`); return await this.coreV1Api.readNamespacedSecret(secretName, namespace); } catch (e) { - console.log( - hasErrorResponse(e) && e.body?.message ? e.body.message : getKubeApiErrorMessage(e), - ); + console.log(formatKubeErrorLog(e)); throw e; } } - async updateConfigMap(configmapName: string, namespace: string, patch: object) { + async updateConfigMap( + configmapName: string, + namespace: string, + patch: object, + ) { try { console.log("updateConfigMap called"); console.log("Namespace: ", namespace); @@ -312,7 +160,9 @@ export class KubeClient { const options = { headers: { "Content-type": k8s.PatchUtils.PATCH_FORMAT_JSON_PATCH }, }; - console.log(`Updating configmap ${configmapName} in namespace ${namespace}`); + console.log( + `Updating configmap ${configmapName} in namespace ${namespace}`, + ); await this.coreV1Api.patchNamespacedConfigMap( configmapName, namespace, @@ -330,101 +180,19 @@ export class KubeClient { } } - async updateConfigMapTitle(configMapName: string, namespace: string, newTitle: string) { - try { - // If the provided configMapName doesn't exist, try to find the correct one dynamically - let actualConfigMapName = configMapName; - try { - await this.getConfigMap(configMapName, namespace); - console.log(`Using provided ConfigMap name: ${configMapName}`); - } catch (error) { - if (hasErrorResponse(error) && error.response?.statusCode === 404) { - console.log(`ConfigMap ${configMapName} not found, searching for alternatives...`); - actualConfigMapName = await this.findAppConfigMap(namespace); - } else { - throw error; - } - } - - const configMapResponse = await this.getConfigMap(actualConfigMapName, namespace); - const configMap = configMapResponse.body; - - console.log(`Using ConfigMap: ${actualConfigMapName}`); - console.log(`Available data keys: ${Object.keys(configMap.data || {}).join(", ")}`); - - // Find the correct data key dynamically - let dataKey: string | undefined; - const dataKeys = Object.keys(configMap.data || {}); - - // Generate key patterns from the possible names + the actual ConfigMap name - const keyPatterns = [ - `${actualConfigMapName}.yaml`, - ...this.appConfigNames.map((name) => `${name}.yaml`), - ]; - - for (const pattern of keyPatterns) { - if (dataKeys.includes(pattern)) { - dataKey = pattern; - break; - } - } - - // If none of the patterns match, look for any .yaml file containing app-config - if (!dataKey) { - dataKey = dataKeys.find((key) => key.endsWith(".yaml") && key.includes("app-config")); - } - - // Last resort: use any .yaml file - if (!dataKey) { - dataKey = dataKeys.find((key) => key.endsWith(".yaml")); - } - - if (!dataKey) { - throw new Error( - `No suitable YAML data key found in ConfigMap '${actualConfigMapName}'. Available keys: ${dataKeys.join(", ")}`, - ); - } - - console.log(`Using data key: ${dataKey}`); - if (!configMap.data) { - throw new Error(`ConfigMap '${actualConfigMapName}' has no data section`); - } - const appConfigYaml = configMap.data[dataKey]; - - if (!appConfigYaml) { - throw new Error(`Data key '${dataKey}' is empty in ConfigMap '${actualConfigMapName}'`); - } - - const parsedConfig: unknown = yaml.load(appConfigYaml); - if (!isRecord(parsedConfig) || !isRecord(parsedConfig.app)) { - throw new Error( - `Invalid app-config structure in ConfigMap '${actualConfigMapName}'. Expected 'app' section not found.`, - ); - } - - const appSection = parsedConfig.app; - const currentTitle = typeof appSection.title === "string" ? appSection.title : undefined; - console.log(`Current title: ${currentTitle ?? "(none)"}`); - appSection.title = newTitle; - console.log(`New title: ${newTitle}`); - - configMap.data[dataKey] = yaml.dump(parsedConfig); - - if (configMap.metadata) { - delete configMap.metadata.creationTimestamp; - delete configMap.metadata.resourceVersion; - } - - await this.coreV1Api.replaceNamespacedConfigMap(actualConfigMapName, namespace, configMap); - console.log( - `ConfigMap '${actualConfigMapName}' updated successfully with new title: '${newTitle}'`, - ); - } catch (error) { - console.error(`Error updating ConfigMap: ${getKubeApiErrorMessage(error)}`); - throw new Error(`Failed to update ConfigMap: ${getKubeApiErrorMessage(error)}`, { - cause: error, - }); - } + updateConfigMapTitle( + configMapName: string, + namespace: string, + newTitle: string, + ) { + return updateConfigMapTitleImpl( + this.coreV1Api, + (name, ns) => this.getConfigMap(name, ns), + (ns) => this.findAppConfigMap(ns), + configMapName, + namespace, + newTitle, + ); } async updateSecret(secretName: string, namespace: string, patch: object) { @@ -455,10 +223,12 @@ export class KubeClient { async createCongifmap(namespace: string, body: V1ConfigMap) { try { const configMapName = body.metadata?.name; - if (!configMapName) { + if (configMapName === undefined || configMapName === "") { throw new Error("ConfigMap metadata.name is required"); } - console.log(`Creating configmap ${configMapName} in namespace ${namespace}`); + console.log( + `Creating configmap ${configMapName} in namespace ${namespace}`, + ); return await this.coreV1Api.createNamespacedConfigMap(namespace, body); } catch (err) { console.log(getKubeApiErrorMessage(err)); @@ -484,11 +254,10 @@ export class KubeClient { }, (err: unknown) => { if (hasStatusCode(err) && err.statusCode === 404) { - // Namespace was already deleted or does not exist console.log(`Namespace '${namespace}' is already deleted.`); resolve(); } else { - reject(err); + rejectAsError(reject, err); } }, ); @@ -511,7 +280,9 @@ export class KubeClient { try { await this.deleteNamespaceAndWait(namespace); } catch (err) { - console.log(`Error deleting namespace ${namespace}: ${getKubeApiErrorMessage(err)}`); + console.log( + `Error deleting namespace ${namespace}: ${getKubeApiErrorMessage(err)}`, + ); throw err; } } @@ -542,28 +313,30 @@ export class KubeClient { } } - /** - * Create or update a Kubernetes secret (upsert pattern). - * Tries to update the secret first; if it doesn't exist, creates it. - */ - async createOrUpdateSecret(secret: k8s.V1Secret, namespace: string): Promise { + async createOrUpdateSecret( + secret: k8s.V1Secret, + namespace: string, + ): Promise { const secretName = secret.metadata?.name; - if (!secretName) { + if (secretName === undefined || secretName === "") { throw new Error("Secret metadata.name is required"); } try { - const existing = await this.coreV1Api.readNamespacedSecret(secretName, namespace); + const existing = await this.coreV1Api.readNamespacedSecret( + secretName, + namespace, + ); const body = existing.body; - // Merge new keys into existing data to preserve keys not in the update - // (e.g., RHDH_RUNTIME_URL when updating only DB credentials) body.data = { ...body.data, ...secret.data }; await this.coreV1Api.replaceNamespacedSecret(secretName, namespace, body); console.log(`Secret ${secretName} updated in namespace ${namespace}`); } catch (err: unknown) { const statusCode = getErrorStatusCode(err); if (statusCode === 404) { - console.log(`Secret ${secretName} not found, creating in namespace ${namespace}`); + console.log( + `Secret ${secretName} not found, creating in namespace ${namespace}`, + ); await this.createSecret(secret, namespace); console.log(`Secret ${secretName} created in namespace ${namespace}`); } else { @@ -572,641 +345,113 @@ export class KubeClient { } } - /** - * Check if pods are in a failure state (CrashLoopBackOff, ImagePullBackOff, etc.) - * Returns a failure reason if found, null otherwise - */ - async checkPodFailureStates( + checkPodFailureStates( namespace: string, labelSelector: string, ): Promise { - try { - const response = await this.coreV1Api.listNamespacedPod( - namespace, - undefined, - undefined, - undefined, - undefined, - labelSelector, - ); - - const pods = response.body.items; - if (pods.length === 0) { - return null; // No pods yet, not a failure - } - - for (const pod of pods) { - const podName = pod.metadata?.name || "unknown"; - const phase = pod.status?.phase; - - // Check for Failed phase - if (phase === "Failed") { - const reason = pod.status?.reason || "Unknown"; - const message = pod.status?.message || ""; - return { - message: `Pod ${podName} is in Failed phase: ${reason} - ${message}`, - }; - } - - // Check pod conditions for issues - const conditions = pod.status?.conditions || []; - for (const condition of conditions) { - if (condition.type === "PodScheduled" && condition.status === "False") { - const msg = condition.message || ""; - const isTransientPvc = - msg.includes("ephemeral volume") || msg.includes("persistentvolumeclaim"); - if (isTransientPvc) { - console.log( - `Pod ${podName} waiting for PVC creation (transient): ${condition.reason} - ${msg}`, - ); - return null; - } - return { - message: `Pod ${podName} cannot be scheduled: ${condition.reason} - ${msg}`, - }; - } - if ( - condition.type === "Ready" && - condition.status === "False" && - condition.reason && - condition.reason !== "ContainersNotReady" - ) { - // Only report if it's a specific error reason, not just "not ready yet" - const errorReasons = ["Unhealthy", "ReadinessGatesNotReady", "PodHasNoResources"]; - if (errorReasons.includes(condition.reason)) { - return { - message: `Pod ${podName} is not ready: ${condition.reason} - ${condition.message}`, - }; - } - } - } - - // Check container statuses for failure states - const containerStatuses = [ - ...(pod.status?.containerStatuses || []), - ...(pod.status?.initContainerStatuses || []), - ]; - - for (const containerStatus of containerStatuses) { - const containerName = containerStatus.name; - const waiting = containerStatus.state?.waiting; - - if (waiting) { - const reason = waiting.reason || ""; - // Check for common failure states - const failureStates = [ - "CrashLoopBackOff", - "ImagePullBackOff", - "ErrImagePull", - "InvalidImageName", - "CreateContainerConfigError", - "CreateContainerError", - "ErrImageNeverPull", - "RegistryUnavailable", - ]; - - if (failureStates.includes(reason)) { - const message = waiting.message || ""; - return { - message: `Pod ${podName} container ${containerName} is in ${reason} state: ${message}`, - containerName, - }; - } - - // Check for other waiting states that might indicate issues - if (reason === "ContainerCreating" && waiting.message) { - // Log but don't fail - this might be normal startup - console.log( - `Pod ${podName} container ${containerName} is being created: ${waiting.message}`, - ); - } - } - - // Check for containers that have terminated with errors - const terminated = containerStatus.state?.terminated; - if (terminated && terminated.exitCode !== 0) { - const reason = terminated.reason || "Error"; - const message = terminated.message || ""; - // Return error if container exited with non-zero code - return { - message: `Pod ${podName} container ${containerName} terminated with exit code ${terminated.exitCode}: ${reason} - ${message}`, - containerName, - }; - } - } - } - - return null; // No failure states detected - } catch (error) { - console.error(`Error checking pod failure states: ${getKubeApiErrorMessage(error)}`); - return null; // Don't fail the check if we can't retrieve pod info - } + return checkPodFailureStatesImpl(this.coreV1Api, namespace, labelSelector); } - async waitForDeploymentReady( + waitForDeploymentReady( deploymentName: string, namespace: string, expectedReplicas: number, - timeout: number = 300000, // 5 minutes - checkInterval: number = 10000, // 10 seconds - labelSelector?: string, // Optional label selector for pods + timeout: number = 300000, + checkInterval: number = 10000, + labelSelector?: string, ) { - const endTime = Date.now() + timeout; - - const podSelector = await this.getDeploymentPodSelector(deploymentName, namespace); - const finalLabelSelector = labelSelector ?? podSelector; - - while (Date.now() < endTime) { - try { - const response = await this.appsApi.readNamespacedDeployment(deploymentName, namespace); - const availableReplicas = response.body.status?.availableReplicas || 0; - const readyReplicas = response.body.status?.readyReplicas || 0; - const updatedReplicas = response.body.status?.updatedReplicas || 0; - const replicas = response.body.status?.replicas || 0; - const conditions = response.body.status?.conditions || []; - - console.log(`Available replicas: ${availableReplicas}`); - console.log(`Ready replicas: ${readyReplicas}`); - console.log(`Updated replicas: ${updatedReplicas}`); - console.log(`Desired replicas: ${replicas}`); - console.log("Deployment conditions:", JSON.stringify(conditions, null, 2)); - - // Check for pod failure states when expecting replicas > 0 - if (expectedReplicas > 0 && podSelector) { - const podFailure = await this.checkPodFailureStates(namespace, podSelector); - if (podFailure) { - console.error( - `Pod failure detected: ${podFailure.message}. Logging events and pod logs...`, - ); - await this.logDeploymentEvents(deploymentName, namespace); - await this.logReplicaSetStatus(deploymentName, namespace); - await this.logPodEvents(namespace, finalLabelSelector); - await this.logPodConditions(namespace, finalLabelSelector); - await this.logPodContainerLogs(namespace, finalLabelSelector, podFailure.containerName); - throw new Error(`Deployment ${deploymentName} failed to start: ${podFailure.message}`); - } - } - - // Log pod conditions using the deployment's pod selector - await this.logPodConditions(namespace, podSelector); - - // Check if the expected replicas match - if (availableReplicas === expectedReplicas) { - console.log(`Deployment ${deploymentName} is ready with ${availableReplicas} replicas.`); - return; - } - - // Only log progress if it's taking a while (after first check) - if (Date.now() > endTime - timeout + checkInterval * 2) { - console.log( - `Waiting for ${deploymentName} to become ready (${readyReplicas}/${expectedReplicas} ready)...`, - ); - } - } catch (error) { - console.error(`Error checking deployment status: ${getKubeApiErrorMessage(error)}`); - // If we threw an error about pod failure, re-throw it - if (error instanceof Error && error.message.includes("failed to start")) { - throw error; - } - } - - await new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, checkInterval); - }); - } - - // On timeout, collect final diagnostics - console.error(`Timeout waiting for deployment ${deploymentName}. Collecting diagnostics...`); - await this.logDeploymentEvents(deploymentName, namespace); - await this.logReplicaSetStatus(deploymentName, namespace); - await this.logPodEvents(namespace, finalLabelSelector); - await this.logPodConditions(namespace, finalLabelSelector); - throw new Error( - `Deployment ${deploymentName} did not become ready in time (timeout: ${timeout / 1000}s).`, + return waitForDeploymentReadyImpl( + this.appsApi, + (name, ns) => this.getDeploymentPodSelector(name, ns), + (ns, selector) => this.checkPodFailureStates(ns, selector), + (ns, selector) => this.logPodConditions(ns, selector), + { + logDeploymentEvents: (name, ns) => this.logDeploymentEvents(name, ns), + logReplicaSetStatus: (name, ns) => this.logReplicaSetStatus(name, ns), + logPodEvents: (ns, selector) => this.logPodEvents(ns, selector), + logPodConditions: (ns, selector) => this.logPodConditions(ns, selector), + logPodContainerLogs: (ns, selector, containerName) => + this.logPodContainerLogs(ns, selector, containerName), + }, + deploymentName, + namespace, + expectedReplicas, + timeout, + checkInterval, + labelSelector, ); } - async restartDeployment(deploymentName: string, namespace: string) { - try { - console.log(`Starting deployment restart for ${deploymentName} in namespace ${namespace}`); - - // Scale down deployment to 0 replicas - console.log(`Scaling down deployment ${deploymentName} to 0 replicas.`); - console.log(`Deployment: ${deploymentName}, Namespace: ${namespace}`); - await this.logPodConditionsForDeployment(deploymentName, namespace); - await this.scaleDeployment(deploymentName, namespace, 0); - await this.waitForDeploymentReady(deploymentName, namespace, 0, 300000); // 5 minutes for scale down - - // Wait a bit for pods to be fully terminated - console.log("Waiting for pods to be fully terminated..."); - await new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, 10000); - }); // 10 seconds - - // Scale up deployment to 1 replica - console.log(`Scaling up deployment ${deploymentName} to 1 replica.`); - await this.scaleDeployment(deploymentName, namespace, 1); - - await this.waitForDeploymentReady(deploymentName, namespace, 1, 600000); // 10 minutes for scale up - - console.log(`Restart of deployment ${deploymentName} completed successfully.`); - } catch (error) { - console.error( - `Error during deployment restart: Deployment '${deploymentName}' in namespace '${namespace}': ${getKubeApiErrorMessage(error)}`, - ); - await this.logPodConditionsForDeployment(deploymentName, namespace); - await this.logDeploymentEvents(deploymentName, namespace); - throw new Error( - `Failed to restart deployment '${deploymentName}' in namespace '${namespace}': ${getKubeApiErrorMessage(error)}`, - { cause: error }, - ); - } + restartDeployment(deploymentName: string, namespace: string) { + return restartDeploymentImpl( + (name, ns, replicas) => this.scaleDeployment(name, ns, replicas), + (name, ns, replicas, t) => + this.waitForDeploymentReady(name, ns, replicas, t), + (name, ns) => this.logPodConditionsForDeployment(name, ns), + (name, ns) => this.logDeploymentEvents(name, ns), + deploymentName, + namespace, + ); } - /** - * Resolves the pod label selector from a deployment's spec.selector.matchLabels. - */ - private async getDeploymentPodSelector( + private getDeploymentPodSelector( deploymentName: string, namespace: string, ): Promise { - const response = await this.appsApi.readNamespacedDeployment(deploymentName, namespace); - const matchLabels = response.body.spec?.selector?.matchLabels || {}; - const entries = Object.entries(matchLabels); - if (entries.length === 0) { - throw new Error( - `Deployment '${deploymentName}' in namespace '${namespace}' has no matchLabels in selector`, - ); - } - return entries.map(([k, v]) => `${k}=${v}`).join(","); + return getDeploymentPodSelectorImpl( + this.appsApi, + deploymentName, + namespace, + ); } - /** - * Logs pod conditions for pods belonging to a specific deployment. - * Resolves the pod selector from the deployment's matchLabels. - */ - async logPodConditionsForDeployment(deploymentName: string, namespace: string) { - try { - const selector = await this.getDeploymentPodSelector(deploymentName, namespace); - await this.logPodConditions(namespace, selector); - } catch (error) { - console.warn( - `Could not resolve pod selector for deployment '${deploymentName}': ${getKubeApiErrorMessage(error)}`, - ); - } + logPodConditionsForDeployment(deploymentName: string, namespace: string) { + return logPodConditionsForDeploymentImpl( + (ns, selector) => this.logPodConditions(ns, selector), + (name, ns) => this.getDeploymentPodSelector(name, ns), + deploymentName, + namespace, + ); } - async logPodConditions(namespace: string, labelSelector: string) { - try { - const response = await this.coreV1Api.listNamespacedPod( - namespace, - undefined, - undefined, - undefined, - undefined, - labelSelector, - ); - - if (response.body.items.length === 0) { - console.warn(`No pods found for selector: ${labelSelector}`); - } - - for (const pod of response.body.items) { - const podName = pod.metadata?.name || "unknown"; - const phase = pod.status?.phase; - console.log(`Pod: ${podName} (Phase: ${phase})`); - console.log("Conditions:", JSON.stringify(pod.status?.conditions, null, 2)); - - // Log container statuses - const containerStatuses = [ - ...(pod.status?.containerStatuses || []), - ...(pod.status?.initContainerStatuses || []), - ]; - - if (containerStatuses.length > 0) { - console.log("Container Statuses:"); - for (const containerStatus of containerStatuses) { - const containerName = containerStatus.name; - const waiting = containerStatus.state?.waiting; - const running = containerStatus.state?.running; - const terminated = containerStatus.state?.terminated; - - if (waiting) { - console.log(` ${containerName}: Waiting - ${waiting.reason}: ${waiting.message}`); - } else if (running) { - console.log( - ` ${containerName}: Running (started: ${formatContainerStartedAt(running.startedAt)})`, - ); - } else if (terminated) { - console.log( - ` ${containerName}: Terminated - Exit Code: ${terminated.exitCode}, Reason: ${terminated.reason}`, - ); - if (terminated.message) { - console.log(` Message: ${terminated.message}`); - } - } - } - } - } - } catch (error) { - console.error( - `Error while retrieving pod conditions for selector '${labelSelector}': ${getKubeApiErrorMessage(error)}`, - ); - } + logPodConditions(namespace: string, labelSelector: string) { + return logPodConditionsImpl(this.coreV1Api, namespace, labelSelector); } - async logPodContainerLogs(namespace: string, labelSelector?: string, containerName?: string) { - const selector = - labelSelector || - "app.kubernetes.io/component=backstage,app.kubernetes.io/instance=rhdh,app.kubernetes.io/name=backstage"; - - try { - const podsResponse = await this.coreV1Api.listNamespacedPod( - namespace, - undefined, - undefined, - undefined, - undefined, - selector, - ); - - if (podsResponse.body.items.length === 0) { - console.log("No pods found to retrieve logs from."); - return; - } - - for (const pod of podsResponse.body.items.slice(0, 2)) { - const podName = pod.metadata?.name; - if (!podName) continue; - - // If container name specified, only get logs from that container - // Otherwise, get logs from all containers (including init containers) - const containers = containerName - ? [{ name: containerName }] - : [...(pod.spec?.initContainers || []), ...(pod.spec?.containers || [])]; - - for (const container of containers) { - const cn = container.name; - try { - console.log(`\n=== Pod ${podName} - Container ${cn} Logs (last 100 lines) ===`); - const logs = await this.coreV1Api.readNamespacedPodLog( - podName, - namespace, - cn, - false, // follow - undefined, // limitBytes - undefined, // pretty - undefined, // previous - undefined, // sinceSeconds - 100, // tailLines - ); - if (logs.body) { - const logLines = logs.body.split("\n"); - logLines.forEach((line) => { - if (line.trim()) console.log(line); - }); - } else { - console.log("(No logs available)"); - } - } catch (logError) { - const errorMsg = getKubeApiErrorMessage(logError); - // Log error but don't try to get previous container logs (API doesn't support it easily) - console.warn(`Could not retrieve logs for pod ${podName} container ${cn}: ${errorMsg}`); - } - } - } - } catch (error) { - console.error(`Error retrieving pod logs: ${getKubeApiErrorMessage(error)}`); - } + logPodContainerLogs( + namespace: string, + labelSelector?: string, + containerName?: string, + ) { + return logPodContainerLogsImpl( + this.coreV1Api, + namespace, + labelSelector, + containerName, + ); } - async logPodEvents(namespace: string, labelSelector?: string) { - const selector = - labelSelector || - "app.kubernetes.io/component=backstage,app.kubernetes.io/instance=rhdh,app.kubernetes.io/name=backstage"; - - try { - // Get all pods (including recently deleted ones if we can) - const podsResponse = await this.coreV1Api.listNamespacedPod( - namespace, - undefined, - undefined, - undefined, - undefined, - selector, - ); - - // Also try to get pods without selector to catch any pods that might exist - const allPodsResponse = await this.coreV1Api.listNamespacedPod(namespace); - - // Get all events in the namespace - const eventsResponse = await this.coreV1Api.listNamespacedEvent(namespace); - - // Get pod names from both responses - const podNames = new Set(); - podsResponse.body.items.forEach((pod) => { - if (pod.metadata?.name) podNames.add(pod.metadata.name); - }); - allPodsResponse.body.items.forEach((pod) => { - if (pod.metadata?.name && pod.metadata.name.includes("backstage-developer-hub")) { - podNames.add(pod.metadata.name); - } - }); - - // Filter events related to pods (check by name pattern too) - const podEvents = [...eventsResponse.body.items] - .filter((event) => { - const involvedObject = event.involvedObject; - if (involvedObject?.kind !== "Pod") return false; - const podName = involvedObject.name; - // Match if it's in our pod list OR if it matches our deployment pattern - return ( - (podName !== undefined && podNames.has(podName)) || - (podName !== undefined && podName.includes("backstage-developer-hub")) - ); - }) - // oxlint-disable-next-line unicorn/no-array-sort -- es2022 lib has no Array#toSorted - .sort( - (a: k8s.CoreV1Event, b: k8s.CoreV1Event) => - getEventSortTimestamp(b) - getEventSortTimestamp(a), - ) - .slice(0, 30); // Limit to last 30 events - - if (podEvents.length > 0) { - console.log(`Recent pod events (last ${podEvents.length}):`); - for (const event of podEvents) { - const podName = event.involvedObject?.name || "unknown"; - const timestamp = formatEventTimestamp(event); - console.log( - ` [${timestamp}] Pod ${podName}: [${event.type}] ${event.reason}: ${event.message}`, - ); - } - } else { - console.log("No recent pod events found"); - } - - // Also try to get logs from any existing pods (even if they're failing) - if (podsResponse.body.items.length > 0) { - console.log("\nAttempting to get logs from existing pods:"); - for (const pod of podsResponse.body.items.slice(0, 3)) { - const podName = pod.metadata?.name; - if (!podName) continue; - - try { - // Try to get logs (last 50 lines) - const logs = await this.coreV1Api.readNamespacedPodLog( - podName, - namespace, - undefined, // container name - false, // follow - undefined, // limitBytes - undefined, // pretty - undefined, // previous - undefined, // sinceSeconds - 50, // tailLines - ); - if (logs.body) { - const logLines = logs.body.split("\n").slice(-20); // Last 20 lines - console.log(`\n Pod ${podName} logs (last 20 lines):`); - logLines.forEach((line) => { - if (line.trim()) console.log(` ${line}`); - }); - } - } catch (logError) { - // Pod might be deleted or not ready for logs yet - console.log( - ` Could not get logs from ${podName}: ${getKubeApiErrorMessage(logError)}`, - ); - } - } - } - } catch (error) { - console.error( - `Error retrieving pod events for selector '${selector}': ${getKubeApiErrorMessage(error)}`, - ); - } + logPodEvents(namespace: string, labelSelector?: string) { + return logPodEventsImpl(this.coreV1Api, namespace, labelSelector); } - async logDeploymentEvents(deploymentName: string, namespace: string) { - try { - const eventsResponse = await this.coreV1Api.listNamespacedEvent( - namespace, - undefined, - undefined, - undefined, - `involvedObject.name=${deploymentName}`, - ); - - console.log( - `Events for deployment ${deploymentName}: ${JSON.stringify( - eventsResponse.body.items.map((event) => ({ - message: event.message, - reason: event.reason, - type: event.type, - })), - null, - 2, - )}`, - ); - } catch (error) { - console.error( - `Error retrieving events for deployment ${deploymentName}: ${getKubeApiErrorMessage(error)}`, - ); - } + logDeploymentEvents(deploymentName: string, namespace: string) { + return logDeploymentEventsImpl(this.coreV1Api, deploymentName, namespace); } - async logReplicaSetStatus(deploymentName: string, namespace: string) { - try { - // Get the deployment to find associated ReplicaSets - const deployment = await this.appsApi.readNamespacedDeployment(deploymentName, namespace); - - // List ReplicaSets with the deployment's labels - const labelSelector = deployment.body.spec?.selector?.matchLabels; - if (!labelSelector) { - console.warn(`Deployment ${deploymentName} has no label selector`); - return; - } - - const selectorString = Object.entries(labelSelector) - .map(([key, value]) => `${key}=${value}`) - .join(","); - - const rsResponse = await this.appsApi.listNamespacedReplicaSet( - namespace, - undefined, - undefined, - undefined, - undefined, - selectorString, - ); - - console.log( - `Found ${rsResponse.body.items.length} ReplicaSet(s) for deployment ${deploymentName}:`, - ); - - // Sort by creation timestamp (newest first) - // oxlint-disable-next-line unicorn/no-array-sort -- es2022 lib has no Array#toSorted - const sortedReplicaSets = [...rsResponse.body.items].sort( - (a: k8s.V1ReplicaSet, b: k8s.V1ReplicaSet) => { - const aTime = a.metadata?.creationTimestamp?.getTime() ?? 0; - const bTime = b.metadata?.creationTimestamp?.getTime() ?? 0; - return bTime - aTime; - }, - ); - - for (const rs of sortedReplicaSets) { - const rsName = rs.metadata?.name || "unknown"; - const readyReplicas = rs.status?.readyReplicas || 0; - const availableReplicas = rs.status?.availableReplicas || 0; - const replicas = rs.status?.replicas || 0; - const fullyLabeledReplicas = rs.status?.fullyLabeledReplicas || 0; - const conditions = rs.status?.conditions || []; - - console.log(` ReplicaSet: ${rsName}`); - console.log( - ` Ready: ${readyReplicas}, Available: ${availableReplicas}, Desired: ${replicas}, Fully Labeled: ${fullyLabeledReplicas}`, - ); - if (conditions.length > 0) { - console.log(` Conditions: ${JSON.stringify(conditions, null, 2)}`); - } - - // Get events for this ReplicaSet - try { - const rsEvents = await this.coreV1Api.listNamespacedEvent( - namespace, - undefined, - undefined, - undefined, - `involvedObject.name=${rsName}`, - ); - - if (rsEvents.body.items.length > 0) { - console.log(` Events for ReplicaSet ${rsName}:`); - rsEvents.body.items.slice(0, 10).forEach((event) => { - // Limit to last 10 events - console.log(` [${event.type}] ${event.reason}: ${event.message}`); - }); - } else { - console.log(` No events found for ReplicaSet ${rsName}`); - } - } catch (error) { - console.warn( - ` Could not retrieve events for ReplicaSet ${rsName}: ${getKubeApiErrorMessage(error)}`, - ); - } - } - } catch (error) { - console.error( - `Error retrieving ReplicaSet status for deployment ${deploymentName}: ${getKubeApiErrorMessage(error)}`, - ); - } + logReplicaSetStatus(deploymentName: string, namespace: string) { + return logReplicaSetStatusImpl( + this.coreV1Api, + this.appsApi, + deploymentName, + namespace, + ); } - async getServiceByLabel(namespace: string, labelSelector: string): Promise { + async getServiceByLabel( + namespace: string, + labelSelector: string, + ): Promise { try { const response = await this.coreV1Api.listNamespacedService( namespace, @@ -1225,67 +470,20 @@ export class KubeClient { } } - async execPodCommand( + execPodCommand( podName: string, namespace: string, containerName: string, command: string[], - timeout: number = 60000, // 1 minute + timeout: number = 60000, ): Promise<{ stdout: string; stderr: string }> { - try { - const exec = new k8s.Exec(this.kc); - let stdout = ""; - let stderr = ""; - - await new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - reject(new Error(`Command execution timed out after ${timeout}ms`)); - }, timeout); - - // Create writable streams to capture output - const stdoutStream = new stream.Writable({ - write(chunk: Buffer, encoding: string, callback: () => void) { - stdout += chunk.toString(); - callback(); - }, - }); - const stderrStream = new stream.Writable({ - write(chunk: Buffer, encoding: string, callback: () => void) { - stderr += chunk.toString(); - callback(); - }, - }); - - void exec.exec( - namespace, - podName, - containerName, - command, - stdoutStream, - stderrStream, - process.stdin || undefined, - false, // tty - (status: k8s.V1Status) => { - clearTimeout(timeoutId); - if (status.status === "Success") { - resolve(); - } else { - reject( - new Error( - `Command execution failed: ${status.message || stderr || "unknown error"}`, - ), - ); - } - }, - ); - }); - - return { stdout, stderr }; - } catch (error) { - throw new Error( - `Failed to execute command in pod ${podName}: ${getKubeApiErrorMessage(error)}`, - { cause: error }, - ); - } + return execPodCommandImpl( + this.kc, + podName, + namespace, + containerName, + command, + timeout, + ); } } diff --git a/e2e-tests/playwright/utils/postgres-config.ts b/e2e-tests/playwright/utils/postgres-config.ts index eb5b8d8fbf..78785c3c4a 100644 --- a/e2e-tests/playwright/utils/postgres-config.ts +++ b/e2e-tests/playwright/utils/postgres-config.ts @@ -11,9 +11,7 @@ */ import { readFileSync, existsSync } from "fs"; - import { Client } from "pg"; - import { KubeClient } from "./kube-client"; /** @@ -21,7 +19,7 @@ import { KubeClient } from "./kube-client"; * Environment variables from Vault often have literal \n instead of newlines. */ function unescapeNewlines(value: string): string { - return value.replaceAll(/\\n/g, "\n"); + return value.replaceAll("\\n", "\n"); } /** @@ -29,8 +27,10 @@ function unescapeNewlines(value: string): string { * @param filePath - Path to the certificate file * @returns Certificate content with escaped newlines converted, or null if file doesn't exist */ -export function readCertificateFile(filePath: string | undefined): string | null { - if (!filePath) { +export function readCertificateFile( + filePath: string | undefined, +): string | null { + if (filePath === undefined || filePath === "") { return null; } if (!existsSync(filePath)) { @@ -76,18 +76,22 @@ export async function configurePostgresCredentials( ): Promise { const data: Record = { POSTGRES_HOST: Buffer.from(credentials.host).toString("base64"), - POSTGRES_PORT: Buffer.from(credentials.port || "5432").toString("base64"), - PGSSLMODE: Buffer.from(credentials.sslMode || "require").toString("base64"), - NODE_EXTRA_CA_CERTS: Buffer.from("/opt/app-root/src/postgres-crt.pem").toString("base64"), + POSTGRES_PORT: Buffer.from(credentials.port ?? "5432").toString("base64"), + PGSSLMODE: Buffer.from(credentials.sslMode ?? "require").toString("base64"), + NODE_EXTRA_CA_CERTS: Buffer.from( + "/opt/app-root/src/postgres-crt.pem", + ).toString("base64"), }; - if (credentials.user) { + if (credentials.user !== "") { data.POSTGRES_USER = Buffer.from(credentials.user).toString("base64"); } - if (credentials.password) { - data.POSTGRES_PASSWORD = Buffer.from(credentials.password).toString("base64"); + if (credentials.password !== "") { + data.POSTGRES_PASSWORD = Buffer.from(credentials.password).toString( + "base64", + ); } - if (credentials.database) { + if (credentials.database !== undefined && credentials.database !== "") { data.POSTGRES_DB = Buffer.from(credentials.database).toString("base64"); } @@ -100,16 +104,89 @@ export async function configurePostgresCredentials( await kubeClient.createOrUpdateSecret(secret, namespace); } +const SYSTEM_DATABASES = [ + "postgres", + "template0", + "template1", + "rdsadmin", + "azure_maintenance", + "azure_sys", +]; + +function buildSslConfig( + certificatePath: string | undefined, +): { ca: string } | boolean { + if (certificatePath === undefined || certificatePath === "") { + return true; + } + const certContent = readCertificateFile(certificatePath); + if (certContent === null) { + return true; + } + return { ca: certContent }; +} + +function isRetryableDropError(errorMsg: string): boolean { + return ( + errorMsg.includes("being accessed by other users") || + errorMsg.includes("in use") || + errorMsg.includes("timeout") + ); +} + +async function dropDatabaseWithRetry( + client: Client, + db: string, + maxRetries: number, +): Promise { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await client.query(`DROP DATABASE IF EXISTS "${db}" WITH (FORCE)`); + return true; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + const canRetry = isRetryableDropError(errorMsg) && attempt < maxRetries; + if (!canRetry) { + console.warn(`Warning: Failed to drop database ${db}:`, errorMsg); + return false; + } + const delay = attempt * 1000; + console.log( + `Retry ${attempt}/${maxRetries} for database ${db} after ${delay}ms (${errorMsg})`, + ); + await new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, delay); + }); + } + } + return false; +} + +async function dropUserDatabases( + client: Client, + databases: string[], +): Promise<{ succeeded: string[]; failed: string[] }> { + const succeeded: string[] = []; + const failed: string[] = []; + const maxRetries = 3; + + for (const db of databases) { + const success = await dropDatabaseWithRetry(client, db, maxRetries); + if (success) { + succeeded.push(db); + } else { + failed.push(db); + } + } + + return { succeeded, failed }; +} + /** * Clear all non-system databases from a PostgreSQL instance. * Used to clean up after external database tests. - * - * @param credentials - Database connection credentials - * @param credentials.host - PostgreSQL host - * @param credentials.port - PostgreSQL port (default: "5432") - * @param credentials.user - PostgreSQL user - * @param credentials.password - PostgreSQL password - * @param certificatePath - Optional path to TLS certificate file */ export async function clearDatabase(credentials: { host: string; @@ -120,34 +197,13 @@ export async function clearDatabase(credentials: { }): Promise { console.log("Starting database cleanup process..."); - // System databases that should never be dropped (includes cloud provider managed databases) - const systemDatabases = [ - "postgres", - "template0", - "template1", - // AWS RDS system databases - "rdsadmin", - // Azure Database for PostgreSQL system databases - "azure_maintenance", - "azure_sys", - ]; - - // Read certificate if path is provided - let ssl: { ca: string } | boolean = true; - if (credentials.certificatePath) { - const certContent = readCertificateFile(credentials.certificatePath); - if (certContent) { - ssl = { ca: certContent }; - } - } - const client = new Client({ host: credentials.host, - port: parseInt(credentials.port || "5432"), + port: Math.trunc(Number(credentials.port ?? "5432")), user: credentials.user, password: credentials.password, database: "postgres", - ssl, + ssl: buildSslConfig(credentials.certificatePath), connectionTimeoutMillis: 30 * 1000, query_timeout: 120 * 1000, }); @@ -155,14 +211,13 @@ export async function clearDatabase(credentials: { try { await client.connect(); - // Get list of databases const result = await client.query<{ datname: string }>( "SELECT datname FROM pg_database WHERE datistemplate = false", ); const databases = result.rows .map((row) => row.datname) - .filter((db) => !systemDatabases.includes(db)); + .filter((db) => !SYSTEM_DATABASES.includes(db)); if (databases.length === 0) { console.log("No databases found to drop"); @@ -171,52 +226,11 @@ export async function clearDatabase(credentials: { console.log(`Found databases to drop: ${databases.join(", ")}`); - const succeeded: string[] = []; - const failed: string[] = []; - - // Execute drops sequentially - for (const db of databases) { - let success = false; - const maxRetries = 3; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - // WITH (FORCE) atomically terminates connections and drops the database - await client.query(`DROP DATABASE IF EXISTS "${db}" WITH (FORCE)`); - success = true; - break; - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - const isRetryable = - errorMsg.includes("being accessed by other users") || - errorMsg.includes("in use") || - errorMsg.includes("timeout"); - - if (isRetryable && attempt < maxRetries) { - const delay = attempt * 1000; // 1s, 2s, 3s - console.log( - `Retry ${attempt}/${maxRetries} for database ${db} after ${delay}ms (${errorMsg})`, - ); - await new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, delay); - }); - } else { - console.warn(`Warning: Failed to drop database ${db}:`, errorMsg); - break; - } - } - } + const { succeeded, failed } = await dropUserDatabases(client, databases); - if (success) { - succeeded.push(db); - } else { - failed.push(db); - } - } - - console.log(`Database cleanup completed: ${succeeded.length} dropped, ${failed.length} failed`); + console.log( + `Database cleanup completed: ${succeeded.length} dropped, ${failed.length} failed`, + ); if (succeeded.length > 0) { console.log(`Successfully dropped: ${succeeded.join(", ")}`); } @@ -224,7 +238,10 @@ export async function clearDatabase(credentials: { console.log(`Failed to drop: ${failed.join(", ")}`); } } catch (error) { - console.error("Failed to connect to database or retrieve database list:", error); + console.error( + "Failed to connect to database or retrieve database list:", + error, + ); throw error; } finally { await client.end(); diff --git a/e2e-tests/playwright/utils/ui-helper.ts b/e2e-tests/playwright/utils/ui-helper.ts index 972d421da0..ac3175238e 100644 --- a/e2e-tests/playwright/utils/ui-helper.ts +++ b/e2e-tests/playwright/utils/ui-helper.ts @@ -1,836 +1 @@ -import { expect, Locator, Page } from "@playwright/test"; - -import { getTranslations, getCurrentLanguage } from "../e2e/localization/locale"; -import { UI_HELPER_ELEMENTS } from "../support/page-objects/global-obj"; -import { SEARCH_OBJECTS_COMPONENTS } from "../support/page-objects/page-obj"; -import { getErrorMessage } from "./errors"; - -const t = getTranslations(); -const lang = getCurrentLanguage(); -export class UIhelper { - private page: Page; - - constructor(page: Page) { - this.page = page; - } - - private getGlobalHeader(): Locator { - return this.page.getByRole("navigation").filter({ - has: this.page.getByTestId("KeyboardArrowDownOutlinedIcon"), - }); - } - - async verifyComponentInCatalog(kind: string, expectedRows: string[]) { - await this.openSidebar("Catalog"); - await this.selectMuiBox("Kind", kind); - await this.verifyRowsInTable(expectedRows); - } - - getSideBarMenuItem(menuItem: string): Locator { - return this.page.getByTestId("login-button").getByText(menuItem); - } - - async fillTextInputByLabel(label: string, text: string) { - 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: string) { - await this.page.fill(SEARCH_OBJECTS_COMPONENTS.placeholderSearch, searchText); - } - - async searchInputAriaLabel(searchText: string) { - await this.page.fill(SEARCH_OBJECTS_COMPONENTS.ariaLabelSearch, searchText); - } - - async pressTab() { - await this.page.keyboard.press("Tab"); - } - - async checkCheckbox(text: string) { - const locator = this.page.getByRole("checkbox", { - name: text, - }); - await locator.check(); - } - - async uncheckCheckbox(text: string) { - const locator = this.page.getByRole("checkbox", { - name: text, - }); - await locator.uncheck(); - } - - async clickButton( - label: string | RegExp, - options: { exact?: boolean; force?: boolean } = { - exact: true, - force: false, - }, - ) { - const button = this.page.getByRole("button", { name: label, exact: options.exact }).first(); - - await expect(button).toBeVisible(); - - if (options?.force) { - // oxlint-disable-next-line playwright/no-force-option -- MUI overlay blocks native click in CI - await button.click({ force: true }); - } else { - await button.click(); - } - return button; - } - - async clickBtnByTitleIfNotPressed(title: string) { - const button = this.page.locator(`button[title="${title}"]`); - const isPressed = await button.getAttribute("aria-pressed"); - - if (isPressed === "false") { - await button.scrollIntoViewIfNeeded(); - await expect(button).toBeVisible(); - await button.click(); - } - } - - async clickByDataTestId(dataTestId: string) { - const element = this.page.getByTestId(dataTestId); - await element.waitFor({ state: "visible" }); - await element.dispatchEvent("click"); - } - - /** - * Clicks on a div element by its title attribute. - * - * @param title - The title attribute value of the div to click on. - */ - async clickDivByTitle(title: string) { - const divElement = this.page.locator(`div[title="${title}"]`); - await divElement.waitFor({ state: "visible" }); - await divElement.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: string | RegExp, - options: { - exact?: boolean; - timeout?: number; - force?: boolean; - } = { - 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) { - // oxlint-disable-next-line playwright/no-force-option -- MUI overlay blocks native click in CI - await buttonElement.click({ force: true }); - } else { - await buttonElement.click(); - } - } - - async clickButtonByLabel(label: string | RegExp) { - await this.page.getByRole("button", { name: label }).first().click(); - } - - /** - * Conditionally clicks on "Mark all read" if visible, then clicks on "Mark All". - * This method handles the two-step process of marking all notifications as read. - */ - async markAllNotificationsAsReadIfVisible() { - try { - // Check if "Mark all read" div is visible - const markAllReadDiv = this.page.getByTitle("Mark all read"); - const isVisible = await markAllReadDiv.isVisible(); - - if (isVisible) { - // Click on "Mark all read" div first - await markAllReadDiv.click(); - - // Then click on "Mark All" button - await this.clickButtonByText("Mark All", { - timeout: 5000, - }); - } - } catch (error) { - console.log( - "Mark all read functionality not available or already processed: ", - getErrorMessage(error), - ); - } - } - - /** - * Clicks on an element by title only if it's visible. - * - * @param title - The title attribute value of the element to click on. - * @param elementType - The type of element (default: 'div'). - * @returns Promise - Returns true if element was clicked, false if not visible. - */ - async clickByTitleIfVisible(title: string, elementType: string = "div"): Promise { - try { - const element = this.page.locator(`${elementType}[title="${title}"]`); - const isVisible = await element.isVisible(); - - if (isVisible) { - await element.click(); - return true; - } - return false; - } catch (error) { - console.log( - `Element with title "${title}" not found or not clickable: `, - getErrorMessage(error), - ); - return false; - } - } - - async verifyDivHasText(divText: string | RegExp) { - await expect(this.page.getByText(divText)).toBeVisible(); - } - - async clickLink(options: string | { href: string } | { ariaLabel: string }) { - let linkLocator: Locator; - - if (typeof options === "string") { - linkLocator = this.page.getByRole("link", { name: 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.getGlobalHeader(); - await expect(header).toBeVisible(); - await header.getByTestId("KeyboardArrowDownOutlinedIcon").click(); - } - - async goToPageUrl(url: string, heading?: string) { - await this.page.goto(url); - await expect(this.page).toHaveURL(url); - if (heading) { - await this.verifyHeading(heading); - } - } - - async goToMyProfilePage() { - await expect(this.getGlobalHeader()).toBeVisible(); - await this.openProfileDropdown(); - await this.clickLink( - // TODO: RHDHBUGS-2552 - Strings not getting translated - // "My profile", - "My profile", - ); - } - - async goToSettingsPage() { - await expect(this.getGlobalHeader()).toBeVisible(); - await this.openProfileDropdown(); - const settingsItem = this.page.getByRole("menuitem", { - name: t["plugin.global-header"][lang]["profile.settings"], - }); - await expect(settingsItem).toBeVisible(); - await settingsItem.click(); - } - - async goToSelfServicePage() { - await this.clickLink({ - ariaLabel: t["rhdh"][lang]["menuItem.selfService"], - }); - await this.verifyHeading(t["scaffolder"][lang]["templateListPage.title"]); - } - - async verifyLink( - arg: string | { label: string }, - options: { - exact?: boolean; - notVisible?: boolean; - } = { - exact: true, - notVisible: false, - }, - ) { - let linkLocator: Locator; - let notVisibleCheck: boolean; - - if (typeof arg !== "object") { - linkLocator = this.page.getByRole("link", { name: 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(); - } - } - - private async isElementVisible( - locator: string, - timeout = 10000, - force = false, - ): Promise { - 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: string): Promise { - const locator = `BUTTON[title="${text}"]`; - return this.isElementVisible(locator); - } - - async isBtnVisible(text: string): Promise { - const locator = `button:has-text("${text}")`; - return this.isElementVisible(locator); - } - - async isTextVisible(text: string, timeout = 10000): Promise { - const locator = `:has-text("${text}")`; - return this.isElementVisible(locator, timeout); - } - - async verifyTextVisible(text: string, exact = false, timeout = 10000): Promise { - const locator = this.page.getByText(text, { exact }); - await expect(locator).toBeVisible({ timeout }); - } - - async verifyLinkVisible(text: string, timeout = 10000): Promise { - const locator = this.page.locator(`a:has-text("${text}")`); - await expect(locator).toBeVisible({ timeout }); - } - - async waitForSideBarVisible() { - await this.page.waitForSelector("nav a", { timeout: 10_000 }); - } - - async openSidebar(navBarText: string) { - 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: string) { - await this.openSidebar(t["rhdh"][lang]["menuItem.catalog"]); - await this.selectMuiBox(t["catalog-react"][lang]["entityKindPicker.title"], kind); - await expect(async () => { - await this.clickByDataTestId("user-picker-all"); - await this.verifyHeading(new RegExp(`all ${kind}`, "i")); - }).toPass({ - intervals: [3_000], - timeout: 20_000, - }); - } - - async openSidebarButton(navBarButtonLabel: string) { - const navLink = this.page.locator(`nav button[aria-label="${navBarButtonLabel}"]`); - await navLink.waitFor({ state: "visible" }); - await navLink.click(); - } - - async selectMuiBox(label: string, value: string, notVisible?: boolean) { - // Wait for any overlaying dialogs to close before interacting - await this.page - .getByRole("dialog") - .waitFor({ state: "detached", timeout: 3000 }) - .catch(() => {}); // Ignore if no dialog exists - - // Use semantic selector with fallback to CSS selector - const combobox = this.page - .getByRole("combobox", { name: label }) - .or(this.page.locator(`div[aria-label="${label}"]`)) - .first(); - - await expect(combobox).toBeVisible(); - await combobox.click(); - - // Wait for and click option using semantic selector - const option = this.page.getByRole("option", { name: value }); - - if (notVisible) { - await expect(option).toBeHidden(); - } else { - await expect(option).toBeVisible(); - await option.click(); - } - } - - async verifyRowsInTable(rowTexts: (string | RegExp)[], exact: boolean = true) { - for (const rowText of rowTexts) { - await this.verifyTextInLocator(`tr>td`, rowText, exact); - } - } - - async waitForTextDisappear(text: string) { - await this.page.waitForSelector(`text=${text}`, { state: "detached" }); - } - - async verifyText(text: string | RegExp, exact: boolean = true, timeout: number = 5000) { - await this.verifyTextInLocator("", text, exact, timeout); - } - - private async verifyTextInLocator( - locator: string, - text: string | RegExp, - exact: boolean, - timeout: number = 5000, - ) { - const elementLocator = locator - ? this.page.locator(locator).getByText(text, { exact }).first() - : this.page.getByText(text, { exact }).first(); - - await elementLocator.waitFor({ state: "visible", timeout }); - await elementLocator.waitFor({ state: "attached" }); - - try { - await elementLocator.scrollIntoViewIfNeeded(); - } catch (error) { - console.warn(`Warning: Could not scroll element into view. Error: ${getErrorMessage(error)}`); - } - await expect(elementLocator).toBeVisible(); - } - - async verifyTextInSelector(selector: string, expectedText: string) { - 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 verifyPartialTextInSelector(selector: string, partialText: string) { - try { - const elements = this.page.locator(selector); - const count = await elements.count(); - - for (let i = 0; i < count; i++) { - const textContent = await elements.nth(i).textContent(); - if (textContent && textContent.includes(partialText)) { - console.log(`Found partial text: ${partialText} in element: ${textContent}`); - return; - } - } - - throw new Error( - `Verification failed: Partial text "${partialText}" not found in any elements matching selector "${selector}".`, - ); - } catch (error) { - console.error(getErrorMessage(error)); - throw error; - } - } - - async verifyColumnHeading(rowTexts: string[] | RegExp[], exact: boolean = true) { - for (const rowText of rowTexts) { - const rowLocator = this.page - .getByRole("columnheader") - .getByText(rowText, { exact: exact }) - .first(); - await rowLocator.waitFor({ state: "visible" }); - await rowLocator.scrollIntoViewIfNeeded(); - await expect(rowLocator).toBeVisible(); - } - } - - async verifyHeading(heading: string | RegExp, timeout: number = 20000) { - const headingLocator = this.page.getByRole("heading").filter({ hasText: heading }).first(); - - await headingLocator.waitFor({ state: "visible", timeout: timeout }); - await expect(headingLocator).toBeVisible(); - } - - async verifyParagraph(paragraph: string) { - const headingLocator = this.page.getByText(paragraph).first(); - await headingLocator.waitFor({ state: "visible", timeout: 20000 }); - await expect(headingLocator).toBeVisible(); - } - - async waitForTitle(text: string, level: number = 1) { - await this.page.waitForSelector(`h${level}:has-text("${text}")`); - } - - async clickTab(tabName: string) { - const tabLocator = this.page.getByRole("tab", { name: tabName }); - await tabLocator.waitFor({ state: "visible" }); - await tabLocator.click(); - } - - async verifyCellsInTable(texts: (string | RegExp)[]) { - 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(); - } - } - } - - getButtonSelector(label: string): string { - return `${UI_HELPER_ELEMENTS.MuiButtonLabel}:has-text("${label}")`; - } - - getLoginBtnSelector(): string { - return 'MuiListItem-root li.MuiListItem-root button.MuiButton-root:has(span.MuiButton-label:text("Log in"))'; - } - - async waitForLoginBtnDisappear() { - await this.page.waitForSelector(this.getLoginBtnSelector(), { - state: "detached", - }); - } - - async verifyButtonURL( - label: string | RegExp, - url: string | RegExp, - options: { locator?: string | Locator; exact?: boolean } = { - locator: "", - exact: true, - }, - ) { - // To verify the button URL if it is in a specific locator - // Now supports both CSS selector strings and Locator objects - const baseLocator = - !options.locator || options.locator === "" - ? this.page - : typeof options.locator === "string" - ? this.page.locator(options.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: string, cellTexts: string[] | RegExp[]) { - const row = this.page.locator(UI_HELPER_ELEMENTS.rowByText(uniqueRowText)); - await row.waitFor(); - for (const cellText of cellTexts) { - await expect(row.getByRole("cell").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: string, - linkText: string | RegExp, - exact: boolean = true, - ) { - const row = this.page.locator(UI_HELPER_ELEMENTS.rowByText(uniqueRowText)); - await row.waitFor(); - await row.getByRole("link").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: string, textOrLabel: string | RegExp) { - 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: string, linkText: string, exact = true) { - const link = this.page - .locator(UI_HELPER_ELEMENTS.MuiCard(cardHeading)) - .getByRole("link") - .getByText(linkText, { exact: exact }) - .first(); - await link.scrollIntoViewIfNeeded(); - await expect(link).toBeVisible(); - } - - async clickBtnInCard(cardText: string, btnText: string, 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: string, text: string | RegExp, 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: string[]) { - // 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); - } - - // Function to convert hexadecimal to RGB or return RGB if it's already in RGB - toRgb(color: string): string { - if (color.startsWith("rgb")) { - return color; - } - - const bigint = parseInt(color.slice(1), 16); - const r = (bigint >> 16) & 255; - const g = (bigint >> 8) & 255; - const b = bigint & 255; - return `rgb(${r}, ${g}, ${b})`; - } - - async checkCssColor(page: Page, selector: string, expectedColor: string) { - const elements = page.locator(selector); - const count = await elements.count(); - const expectedRgbColor = this.toRgb(expectedColor); - - for (let i = 0; i < count; i++) { - const color = await elements.nth(i).evaluate((el) => window.getComputedStyle(el).color); - expect(color).toBe(expectedRgbColor); - } - } - - async verifyTableIsEmpty() { - const rowSelector = `table tbody tr:not(:has(td[colspan]))`; - const rowCount = await this.page.locator(rowSelector).count(); - expect(rowCount).toEqual(0); - } - - async waitForCardWithHeader(cardHeading: string) { - await this.page.waitForSelector(UI_HELPER_ELEMENTS.MuiCard(cardHeading)); - } - - async verifyAlertErrorMessage(message: string | RegExp) { - const alert = this.page.getByRole("alert"); - await alert.waitFor(); - await expect(alert).toHaveText(message); - } - - async clickById(id: string) { - const locator = this.page.locator(`#${id}`); - await locator.waitFor({ state: "attached" }); - await locator.click(); - } - - async clickSpanByText(text: string) { - await this.verifyText(text); - await this.page.click(`span:has-text("${text}")`); - } - - async verifyLocationRefreshButtonIsEnabled(locationName: string) { - await expect(async () => { - await this.page.goto("/"); - await this.openSidebar("Catalog"); - await this.selectMuiBox("Kind", "Location"); - await this.verifyHeading("All locations"); - await this.verifyCellsInTable([locationName]); - await this.clickLink(locationName); - await this.verifyHeading(locationName); - }).toPass({ - intervals: [1_000, 2_000, 5_000], - timeout: 20 * 1000, - }); - - const refreshButton = this.page.getByRole("button", { - name: "Schedule entity refresh", - }); - await expect(refreshButton).toHaveCount(1); - - await refreshButton.click(); - await this.verifyAlertErrorMessage("Refresh scheduled"); - - const moreButton = this.page.getByRole("button", { name: "more" }).first(); - await moreButton.waitFor({ state: "visible", timeout: 4000 }); - await moreButton.waitFor({ state: "attached", timeout: 4000 }); - await moreButton.click(); - - const unregisterItem = this.page - .getByRole("menuitem") - .filter({ hasText: "Unregister entity" }) - .first(); - await unregisterItem.waitFor({ state: "visible", timeout: 4000 }); - await unregisterItem.waitFor({ state: "attached", timeout: 4000 }); - await expect(unregisterItem).toBeEnabled(); - } - - async clickUnregisterButtonForDisplayedEntity( - buttonName: "Delete Entity" | "Unregister Location" = "Delete Entity", - ) { - const moreButton = this.page.getByRole("button", { name: "more" }).first(); - await moreButton.waitFor({ state: "visible" }); - await moreButton.waitFor({ state: "attached" }); - await moreButton.click(); - - const unregisterItem = this.page - .getByRole("menuitem") - .filter({ hasText: "Unregister entity" }) - .first(); - await unregisterItem.waitFor({ state: "visible" }); - await unregisterItem.click(); - - const deleteButton = this.page.getByRole("button", { - name: buttonName, - }); - await deleteButton.waitFor({ state: "visible" }); - await deleteButton.waitFor({ state: "attached" }); - await deleteButton.click(); - } - - /** - * Verifies the values of the Enabled and Preinstalled columns for a specific row. - * - * @param text - Text to locate the specific row (based on the Name column). - * @param expectedEnabled - Expected value for the Enabled column ("Yes" or "No"). - * @param expectedPreinstalled - Expected value for the Preinstalled column ("Yes" or "No"). - */ - async verifyPluginRow(text: string, expectedEnabled: string, expectedPreinstalled: string) { - // Locate the row based on the text in the Name column - const rowSelector = `tr:has(td:text-is("${text}"))`; - const row = this.page.locator(rowSelector); - - // Locate the "Enabled" (3rd column) and "Preinstalled" (4th column) cells by their index - const enabledColumn = row.getByRole("cell").nth(2); // Index 2 for "Enabled" - const preinstalledColumn = row.getByRole("cell").nth(3); // Index 3 for "Preinstalled" - - await expect(enabledColumn).toHaveText(expectedEnabled); - await expect(preinstalledColumn).toHaveText(expectedPreinstalled); - } - - async verifyTextInTooltip(text: string | RegExp) { - const tooltip = this.page.getByRole("tooltip").getByText(text); - await expect(tooltip).toBeVisible(); - } - - // Keep in sync when adding new locales - private static readonly quickstartHideLabel = { - en: "Hide", - de: "Ausblenden", - es: "Ocultar", - fr: "Cacher", - it: "Nascondi", - ja: "非表示", - }; - - private getQuickstartHideButton() { - const label = UIhelper.quickstartHideLabel[lang] ?? UIhelper.quickstartHideLabel["en"]; - return this.page.getByRole("button", { name: label }); - } - - /** - * Hides the Quick Start panel if it is currently visible. - * This is useful in test setup to ensure a clean state without the Quick Start overlay. - */ - async hideQuickstartIfVisible(): Promise { - const quickstartHideButton = this.getQuickstartHideButton(); - if (await quickstartHideButton.isVisible()) { - await quickstartHideButton.click(); - await quickstartHideButton.waitFor({ state: "hidden", timeout: 5000 }); - } - } - - async openQuickstartIfHidden(): Promise { - const quickstartHideButton = this.page.getByRole("button", { - name: "Hide", - }); - - const progressBars = this.page.getByTestId("progress"); - await expect(progressBars).toHaveCount(0); - - if (!(await quickstartHideButton.isVisible())) { - await this.clickButtonByLabel("Help"); - await this.clickByDataTestId("quickstart-button"); - } - await expect(quickstartHideButton).toBeVisible(); - } -} +export { UIhelper } from "./ui-helper/class"; diff --git a/e2e-tests/playwright/utils/ui-helper/class.ts b/e2e-tests/playwright/utils/ui-helper/class.ts new file mode 100644 index 0000000000..3743aa44d4 --- /dev/null +++ b/e2e-tests/playwright/utils/ui-helper/class.ts @@ -0,0 +1,357 @@ +import { Locator, Page } from "@playwright/test"; +import { SEARCH_OBJECTS_COMPONENTS } from "../../support/page-objects/page-obj"; +import * as interaction from "./interaction"; +import * as misc from "./misc"; +import * as navigation from "./navigation"; +import * as table from "./table"; +import * as verification from "./verification"; +import * as visibility from "./visibility"; + +export class UIhelper { + private page: Page; + + constructor(page: Page) { + this.page = page; + } + + verifyComponentInCatalog(kind: string, expectedRows: string[]) { + return misc.verifyComponentInCatalog(this.page, kind, expectedRows); + } + + getSideBarMenuItem(menuItem: string): Locator { + return this.page.getByTestId("login-button").getByText(menuItem); + } + + fillTextInputByLabel(label: string, text: string) { + return interaction.fillTextInputByLabel(this.page, label, text); + } + + searchInputPlaceholder(searchText: string) { + return this.page.fill( + SEARCH_OBJECTS_COMPONENTS.placeholderSearch, + searchText, + ); + } + + searchInputAriaLabel(searchText: string) { + return this.page.fill( + SEARCH_OBJECTS_COMPONENTS.ariaLabelSearch, + searchText, + ); + } + + pressTab() { + return interaction.pressTab(this.page); + } + + checkCheckbox(text: string) { + return interaction.checkCheckbox(this.page, text); + } + + uncheckCheckbox(text: string) { + return interaction.uncheckCheckbox(this.page, text); + } + + clickButton( + label: string | RegExp, + options?: { exact?: boolean; force?: boolean }, + ) { + return interaction.clickButton(this.page, label, options); + } + + clickBtnByTitleIfNotPressed(title: string) { + return interaction.clickBtnByTitleIfNotPressed(this.page, title); + } + + clickByDataTestId(dataTestId: string) { + return interaction.clickByDataTestId(this.page, dataTestId); + } + + clickDivByTitle(title: string) { + return interaction.clickDivByTitle(this.page, title); + } + + clickButtonByText( + buttonText: string | RegExp, + options?: { + exact?: boolean; + timeout?: number; + force?: boolean; + }, + ) { + return interaction.clickButtonByText(this.page, buttonText, options); + } + + clickButtonByLabel(label: string | RegExp) { + return interaction.clickButtonByLabel(this.page, label); + } + + markAllNotificationsAsReadIfVisible() { + return navigation.markAllNotificationsAsReadIfVisible(this.page); + } + + clickByTitleIfVisible(title: string, elementType: string = "div") { + return interaction.clickByTitleIfVisible(this.page, title, elementType); + } + + verifyDivHasText(divText: string | RegExp) { + return verification.verifyDivHasText(this.page, divText); + } + + clickLink(options: string | { href: string } | { ariaLabel: string }) { + return interaction.clickLink(this.page, options); + } + + openProfileDropdown() { + return navigation.openProfileDropdown(this.page); + } + + goToPageUrl(url: string, heading?: string) { + return navigation.goToPageUrl(this.page, url, heading); + } + + goToMyProfilePage() { + return navigation.goToMyProfilePage(this.page); + } + + goToSettingsPage() { + return navigation.goToSettingsPage(this.page); + } + + goToSelfServicePage() { + return navigation.goToSelfServicePage(this.page); + } + + verifyLink( + arg: string | { label: string }, + options?: { exact?: boolean; notVisible?: boolean }, + ) { + return verification.verifyLink(this.page, arg, options); + } + + isBtnVisibleByTitle(text: string) { + return visibility.isBtnVisibleByTitle(this.page, text); + } + + isBtnVisible(text: string) { + return visibility.isBtnVisible(this.page, text); + } + + isTextVisible(text: string, timeout = 10000) { + return visibility.isTextVisible(this.page, text, timeout); + } + + verifyTextVisible(text: string, exact = false, timeout = 10000) { + return verification.verifyTextVisible(this.page, text, exact, timeout); + } + + verifyLinkVisible(text: string, timeout = 10000) { + return verification.verifyLinkVisible(this.page, text, timeout); + } + + waitForSideBarVisible() { + return navigation.waitForSideBarVisible(this.page); + } + + openSidebar(navBarText: string) { + return navigation.openSidebar(this.page, navBarText); + } + + openCatalogSidebar(kind: string) { + return navigation.openCatalogSidebar(this.page, kind); + } + + openSidebarButton(navBarButtonLabel: string) { + return navigation.openSidebarButton(this.page, navBarButtonLabel); + } + + selectMuiBox(label: string, value: string, notVisible?: boolean) { + return navigation.selectMuiBox(this.page, label, value, notVisible); + } + + verifyRowsInTable(rowTexts: (string | RegExp)[], exact: boolean = true) { + return verification.verifyRowsInTable(this.page, rowTexts, exact); + } + + waitForTextDisappear(text: string) { + return verification.waitForTextDisappear(this.page, text); + } + + verifyText( + text: string | RegExp, + exact: boolean = true, + timeout: number = 5000, + ) { + return verification.verifyText(this.page, text, exact, timeout); + } + + verifyTextInSelector(selector: string, expectedText: string) { + return verification.verifyTextInSelector(this.page, selector, expectedText); + } + + verifyPartialTextInSelector(selector: string, partialText: string) { + return verification.verifyPartialTextInSelector( + this.page, + selector, + partialText, + ); + } + + verifyColumnHeading(rowTexts: string[] | RegExp[], exact: boolean = true) { + return verification.verifyColumnHeading(this.page, rowTexts, exact); + } + + verifyHeading(heading: string | RegExp, timeout: number = 20000) { + return verification.verifyHeading(this.page, heading, timeout); + } + + verifyParagraph(paragraph: string) { + return verification.verifyParagraph(this.page, paragraph); + } + + waitForTitle(text: string, level: number = 1) { + return verification.waitForTitle(this.page, text, level); + } + + clickTab(tabName: string) { + return interaction.clickTab(this.page, tabName); + } + + verifyCellsInTable(texts: (string | RegExp)[]) { + return table.verifyCellsInTable(this.page, texts); + } + + getButtonSelector(label: string): string { + return `button:has-text("${label}")`; + } + + getLoginBtnSelector(): string { + return 'button:has-text("Log in")'; + } + + waitForLoginBtnDisappear() { + return table.waitForLoginBtnDisappear(this.page); + } + + verifyButtonURL( + label: string | RegExp, + url: string | RegExp, + options?: { locator?: string | Locator; exact?: boolean }, + ) { + return table.verifyButtonURL(this.page, label, url, options); + } + + verifyRowInTableByUniqueText( + uniqueRowText: string, + cellTexts: string[] | RegExp[], + ) { + return table.verifyRowInTableByUniqueText( + this.page, + uniqueRowText, + cellTexts, + ); + } + + clickOnLinkInTableByUniqueText( + uniqueRowText: string, + linkText: string | RegExp, + exact: boolean = true, + ) { + return table.clickOnLinkInTableByUniqueText( + this.page, + uniqueRowText, + linkText, + exact, + ); + } + + clickOnButtonInTableByUniqueText( + uniqueRowText: string, + textOrLabel: string | RegExp, + ) { + return table.clickOnButtonInTableByUniqueText( + this.page, + uniqueRowText, + textOrLabel, + ); + } + + verifyLinkinCard(cardHeading: string, linkText: string, exact = true) { + return misc.verifyLinkinCard(this.page, cardHeading, linkText, exact); + } + + clickBtnInCard(cardText: string, btnText: string, exact = true) { + return interaction.clickBtnInCard(this.page, cardText, btnText, exact); + } + + verifyTextinCard(cardHeading: string, text: string | RegExp, exact = true) { + return misc.verifyTextinCard(this.page, cardHeading, text, exact); + } + + verifyTableHeadingAndRows(texts: string[]) { + return table.verifyTableHeadingAndRows(this.page, texts); + } + + toRgb(color: string): string { + return misc.toRgb(color); + } + + checkCssColor(page: Page, selector: string, expectedColor: string) { + return misc.checkCssColor(page, selector, expectedColor); + } + + verifyTableIsEmpty() { + return table.verifyTableIsEmpty(this.page); + } + + waitForCardWithHeader(cardHeading: string) { + return misc.waitForCardWithHeader(this.page, cardHeading); + } + + verifyAlertErrorMessage(message: string | RegExp) { + return verification.verifyAlertErrorMessage(this.page, message); + } + + clickById(id: string) { + return interaction.clickById(this.page, id); + } + + clickSpanByText(text: string) { + return verification.clickSpanByText(this.page, text); + } + + verifyLocationRefreshButtonIsEnabled(locationName: string) { + return misc.verifyLocationRefreshButtonIsEnabled(this.page, locationName); + } + + clickUnregisterButtonForDisplayedEntity( + buttonName: "Delete Entity" | "Unregister Location" = "Delete Entity", + ) { + return misc.clickUnregisterButtonForDisplayedEntity(this.page, buttonName); + } + + verifyPluginRow( + text: string, + expectedEnabled: string, + expectedPreinstalled: string, + ) { + return table.verifyPluginRow( + this.page, + text, + expectedEnabled, + expectedPreinstalled, + ); + } + + verifyTextInTooltip(text: string | RegExp) { + return verification.verifyTextInTooltip(this.page, text); + } + + hideQuickstartIfVisible() { + return misc.hideQuickstartIfVisible(this.page); + } + + openQuickstartIfHidden() { + return misc.openQuickstartIfHidden(this.page); + } +} diff --git a/e2e-tests/playwright/utils/ui-helper/defaults.ts b/e2e-tests/playwright/utils/ui-helper/defaults.ts new file mode 100644 index 0000000000..b81f7fb468 --- /dev/null +++ b/e2e-tests/playwright/utils/ui-helper/defaults.ts @@ -0,0 +1,25 @@ +import { Locator } from "@playwright/test"; + +export const DEFAULT_CLICK_BUTTON_OPTIONS = { + exact: true, + force: false, +} as const; + +export const DEFAULT_CLICK_BUTTON_BY_TEXT_OPTIONS = { + exact: true, + timeout: 10000, + force: false, +} as const; + +export const DEFAULT_VERIFY_LINK_OPTIONS = { + exact: true, + notVisible: false, +} as const; + +export const DEFAULT_VERIFY_BUTTON_URL_OPTIONS: { + locator: string | Locator; + exact: boolean; +} = { + locator: "", + exact: true, +}; diff --git a/e2e-tests/playwright/utils/ui-helper/interaction.ts b/e2e-tests/playwright/utils/ui-helper/interaction.ts new file mode 100644 index 0000000000..e2a7d10da6 --- /dev/null +++ b/e2e-tests/playwright/utils/ui-helper/interaction.ts @@ -0,0 +1,184 @@ +import { expect, Locator, Page } from "@playwright/test"; +import { getCardByText } from "../../support/page-objects/ui-locators"; +import { getErrorMessage } from "../errors"; +import { + DEFAULT_CLICK_BUTTON_BY_TEXT_OPTIONS, + DEFAULT_CLICK_BUTTON_OPTIONS, +} from "./defaults"; + +export function getGlobalHeader(page: Page): Locator { + return page.getByRole("navigation").filter({ + has: page.getByTestId("KeyboardArrowDownOutlinedIcon"), + }); +} + +export async function clickButton( + page: Page, + label: string | RegExp, + options?: { exact?: boolean; force?: boolean }, +) { + const { exact, force } = { ...DEFAULT_CLICK_BUTTON_OPTIONS, ...options }; + const button = page.getByRole("button", { name: label, exact }).first(); + + await expect(button).toBeVisible(); + + if (force) { + // oxlint-disable-next-line playwright/no-force-option -- MUI overlay blocks native click in CI + await button.click({ force: true }); + } else { + await button.click(); + } + return button; +} + +export async function clickBtnByTitleIfNotPressed(page: Page, title: string) { + const button = page.locator(`button[title="${title}"]`); + const isPressed = await button.getAttribute("aria-pressed"); + + if (isPressed === "false") { + await button.scrollIntoViewIfNeeded(); + await expect(button).toBeVisible(); + await button.click(); + } +} + +export async function clickByDataTestId(page: Page, dataTestId: string) { + const element = page.getByTestId(dataTestId); + await element.waitFor({ state: "visible" }); + await element.dispatchEvent("click"); +} + +export async function clickDivByTitle(page: Page, title: string) { + const divElement = page.locator(`div[title="${title}"]`); + await divElement.waitFor({ state: "visible" }); + await divElement.click(); +} + +export async function clickButtonByText( + page: Page, + buttonText: string | RegExp, + options?: { + exact?: boolean; + timeout?: number; + force?: boolean; + }, +) { + const { exact, timeout, force } = { + ...DEFAULT_CLICK_BUTTON_BY_TEXT_OPTIONS, + ...options, + }; + const buttonElement = page + .getByRole("button") + .getByText(buttonText, { exact }); + + await buttonElement.waitFor({ + state: "visible", + timeout, + }); + + if (force) { + // oxlint-disable-next-line playwright/no-force-option -- MUI overlay blocks native click in CI + await buttonElement.click({ force: true }); + } else { + await buttonElement.click(); + } +} + +export async function clickButtonByLabel(page: Page, label: string | RegExp) { + await page.getByRole("button", { name: label }).first().click(); +} + +export async function fillTextInputByLabel( + page: Page, + label: string, + text: string, +) { + await page.getByLabel(label).fill(text); +} + +export async function checkCheckbox(page: Page, text: string) { + const locator = page.getByRole("checkbox", { + name: text, + }); + await locator.check(); +} + +export async function uncheckCheckbox(page: Page, text: string) { + const locator = page.getByRole("checkbox", { + name: text, + }); + await locator.uncheck(); +} + +export async function pressTab(page: Page) { + await page.keyboard.press("Tab"); +} + +export async function clickByTitleIfVisible( + page: Page, + title: string, + elementType: string = "div", +): Promise { + try { + const element = page.locator(`${elementType}[title="${title}"]`); + const isVisible = await element.isVisible(); + + if (isVisible) { + await element.click(); + return true; + } + return false; + } catch (error) { + console.log( + `Element with title "${title}" not found or not clickable: `, + getErrorMessage(error), + ); + return false; + } +} + +export async function clickLink( + page: Page, + options: string | { href: string } | { ariaLabel: string }, +) { + let linkLocator: Locator; + + if (typeof options === "string") { + linkLocator = page.getByRole("link", { name: options }).first(); + } else if ("href" in options) { + linkLocator = page.locator(`a[href="${options.href}"]`).first(); + } else { + linkLocator = page + .locator(`div[aria-label='${options.ariaLabel}'] a`) + .first(); + } + + await linkLocator.waitFor({ state: "visible" }); + await linkLocator.click(); +} + +export async function clickTab(page: Page, tabName: string) { + const tabLocator = page.getByRole("tab", { name: tabName }); + await tabLocator.waitFor({ state: "visible" }); + await tabLocator.click(); +} + +export async function clickById(page: Page, id: string) { + const locator = page.locator(`#${id}`); + await locator.waitFor({ state: "attached" }); + await locator.click(); +} + +export async function clickBtnInCard( + page: Page, + cardText: string, + btnText: string, + exact = true, +) { + const cardLocator = getCardByText(page, cardText).first(); + await cardLocator.scrollIntoViewIfNeeded(); + await cardLocator + .getByRole("button", { name: btnText, exact }) + .first() + .click(); +} diff --git a/e2e-tests/playwright/utils/ui-helper/misc.ts b/e2e-tests/playwright/utils/ui-helper/misc.ts new file mode 100644 index 0000000000..11a51eb0b7 --- /dev/null +++ b/e2e-tests/playwright/utils/ui-helper/misc.ts @@ -0,0 +1,189 @@ +import { expect, Page } from "@playwright/test"; +import { getCardByHeading } from "../../support/page-objects/ui-locators"; +import { getCurrentLanguage } from "../../e2e/localization/locale"; +import { + clickButtonByLabel, + clickByDataTestId, + clickLink, +} from "./interaction"; +import { openSidebar, selectMuiBox } from "./navigation"; +import { + verifyAlertErrorMessage, + verifyHeading, + verifyRowsInTable, +} from "./verification"; +import { verifyCellsInTable } from "./table"; + +export async function verifyLinkinCard( + page: Page, + cardHeading: string, + linkText: string, + exact = true, +) { + const link = getCardByHeading(page, cardHeading) + .getByRole("link") + .getByText(linkText, { exact }) + .first(); + await link.scrollIntoViewIfNeeded(); + await expect(link).toBeVisible(); +} + +export async function verifyTextinCard( + page: Page, + cardHeading: string, + text: string | RegExp, + exact = true, +) { + const locator = getCardByHeading(page, cardHeading) + .getByText(text, { exact }) + .first(); + await locator.scrollIntoViewIfNeeded(); + await expect(locator).toBeVisible(); +} + +export async function waitForCardWithHeader(page: Page, cardHeading: string) { + await getCardByHeading(page, cardHeading).waitFor({ + state: "visible", + }); +} + +export function toRgb(color: string): string { + if (color.startsWith("rgb")) { + return color; + } + + const bigint = parseInt(color.slice(1), 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + return `rgb(${r}, ${g}, ${b})`; +} + +export async function checkCssColor( + page: Page, + selector: string, + expectedColor: string, +) { + const elements = page.locator(selector); + const count = await elements.count(); + const expectedRgbColor = toRgb(expectedColor); + + for (let i = 0; i < count; i++) { + const color = await elements + .nth(i) + .evaluate((el) => window.getComputedStyle(el).color); + expect(color).toBe(expectedRgbColor); + } +} + +const lang = getCurrentLanguage(); + +const quickstartHideLabel = { + en: "Hide", + de: "Ausblenden", + es: "Ocultar", + fr: "Cacher", + it: "Nascondi", + ja: "非表示", +} as const; + +function getQuickstartHideButton(page: Page) { + const label = quickstartHideLabel[lang] ?? quickstartHideLabel.en; + return page.getByRole("button", { name: label }); +} + +export async function hideQuickstartIfVisible(page: Page): Promise { + const quickstartHideButton = getQuickstartHideButton(page); + if (await quickstartHideButton.isVisible()) { + await quickstartHideButton.click(); + await quickstartHideButton.waitFor({ state: "hidden", timeout: 5000 }); + } +} + +export async function openQuickstartIfHidden(page: Page): Promise { + const quickstartHideButton = page.getByRole("button", { + name: "Hide", + }); + + const progressBars = page.getByTestId("progress"); + await expect(progressBars).toHaveCount(0); + + if (!(await quickstartHideButton.isVisible())) { + await clickButtonByLabel(page, "Help"); + await clickByDataTestId(page, "quickstart-button"); + } + await expect(quickstartHideButton).toBeVisible(); +} + +export async function verifyLocationRefreshButtonIsEnabled( + page: Page, + locationName: string, +) { + await expect(async () => { + await page.goto("/"); + await openSidebar(page, "Catalog"); + await selectMuiBox(page, "Kind", "Location"); + await verifyHeading(page, "All locations"); + await verifyCellsInTable(page, [locationName]); + await clickLink(page, locationName); + await verifyHeading(page, locationName); + }).toPass({ + intervals: [1_000, 2_000, 5_000], + timeout: 20 * 1000, + }); + + const refreshButton = page.getByRole("button", { + name: "Schedule entity refresh", + }); + await expect(refreshButton).toHaveCount(1); + + await refreshButton.click(); + await verifyAlertErrorMessage(page, "Refresh scheduled"); + + const moreButton = page.getByRole("button", { name: "more" }).first(); + await moreButton.waitFor({ state: "visible", timeout: 4000 }); + await moreButton.waitFor({ state: "attached", timeout: 4000 }); + await moreButton.click(); + + const unregisterItem = page + .getByRole("menuitem") + .filter({ hasText: "Unregister entity" }) + .first(); + await unregisterItem.waitFor({ state: "visible", timeout: 4000 }); + await unregisterItem.waitFor({ state: "attached", timeout: 4000 }); + await expect(unregisterItem).toBeEnabled(); +} + +export async function clickUnregisterButtonForDisplayedEntity( + page: Page, + buttonName: "Delete Entity" | "Unregister Location" = "Delete Entity", +) { + const moreButton = page.getByRole("button", { name: "more" }).first(); + await moreButton.waitFor({ state: "visible" }); + await moreButton.waitFor({ state: "attached" }); + await moreButton.click(); + + const unregisterItem = page + .getByRole("menuitem") + .filter({ hasText: "Unregister entity" }) + .first(); + await unregisterItem.waitFor({ state: "visible" }); + await unregisterItem.click(); + + const deleteButton = page.getByRole("button", { + name: buttonName, + }); + await deleteButton.waitFor({ state: "visible" }); + await deleteButton.waitFor({ state: "attached" }); + await deleteButton.click(); +} + +export async function verifyComponentInCatalog( + page: Page, + kind: string, + expectedRows: string[], +) { + await openSidebar(page, "Catalog"); + await selectMuiBox(page, "Kind", kind); + await verifyRowsInTable(page, expectedRows); +} diff --git a/e2e-tests/playwright/utils/ui-helper/navigation.ts b/e2e-tests/playwright/utils/ui-helper/navigation.ts new file mode 100644 index 0000000000..65396f8c4c --- /dev/null +++ b/e2e-tests/playwright/utils/ui-helper/navigation.ts @@ -0,0 +1,135 @@ +import { expect, Page } from "@playwright/test"; +import { + getTranslations, + getCurrentLanguage, +} from "../../e2e/localization/locale"; +import { getErrorMessage } from "../errors"; +import { + clickButtonByText, + clickByDataTestId, + clickLink, + getGlobalHeader, +} from "./interaction"; +import { verifyHeading } from "./verification"; + +const t = getTranslations(); +const lang = getCurrentLanguage(); + +export async function openProfileDropdown(page: Page) { + const header = getGlobalHeader(page); + await expect(header).toBeVisible(); + await header.getByTestId("KeyboardArrowDownOutlinedIcon").click(); +} + +export async function goToPageUrl(page: Page, url: string, heading?: string) { + await page.goto(url); + await expect(page).toHaveURL(url); + if (heading !== undefined && heading !== "") { + await verifyHeading(page, heading); + } +} + +export async function goToMyProfilePage(page: Page) { + await expect(getGlobalHeader(page)).toBeVisible(); + await openProfileDropdown(page); + // RHDHBUGS-2552: profile label not translated yet; keep English until fixed + await clickLink(page, "My profile"); +} + +export async function goToSettingsPage(page: Page) { + await expect(getGlobalHeader(page)).toBeVisible(); + await openProfileDropdown(page); + const settingsItem = page.getByRole("menuitem", { + name: t["plugin.global-header"][lang]["profile.settings"], + }); + await expect(settingsItem).toBeVisible(); + await settingsItem.click(); +} + +export async function goToSelfServicePage(page: Page) { + await clickLink(page, { + ariaLabel: t["rhdh"][lang]["menuItem.selfService"], + }); + await verifyHeading(page, t["scaffolder"][lang]["templateListPage.title"]); +} + +export async function waitForSideBarVisible(page: Page) { + await page.waitForSelector("nav a", { timeout: 10_000 }); +} + +export async function openSidebar(page: Page, navBarText: string) { + const navLink = page.locator(`nav a:has-text("${navBarText}")`).first(); + await navLink.waitFor({ state: "visible", timeout: 15_000 }); + await navLink.dispatchEvent("click"); +} + +export async function openCatalogSidebar(page: Page, kind: string) { + await openSidebar(page, t["rhdh"][lang]["menuItem.catalog"]); + await selectMuiBox( + page, + t["catalog-react"][lang]["entityKindPicker.title"], + kind, + ); + await expect(async () => { + await clickByDataTestId(page, "user-picker-all"); + await verifyHeading(page, new RegExp(`all ${kind}`, "iu")); + }).toPass({ + intervals: [3_000], + timeout: 20_000, + }); +} + +export async function openSidebarButton(page: Page, navBarButtonLabel: string) { + const navLink = page.locator(`nav button[aria-label="${navBarButtonLabel}"]`); + await navLink.waitFor({ state: "visible" }); + await navLink.click(); +} + +export async function selectMuiBox( + page: Page, + label: string, + value: string, + notVisible?: boolean, +) { + // Wait for any overlaying dialogs to close before interacting + await page + .getByRole("dialog") + .waitFor({ state: "detached", timeout: 3000 }) + .catch(() => {}); + + const combobox = page + .getByRole("combobox", { name: label }) + .or(page.locator(`div[aria-label="${label}"]`)) + .first(); + + await expect(combobox).toBeVisible(); + await combobox.click(); + + const option = page.getByRole("option", { name: value }); + + if (notVisible === true) { + await expect(option).toBeHidden(); + } else { + await expect(option).toBeVisible(); + await option.click(); + } +} + +export async function markAllNotificationsAsReadIfVisible(page: Page) { + try { + const markAllReadDiv = page.getByTitle("Mark all read"); + const isVisible = await markAllReadDiv.isVisible(); + + if (isVisible) { + await markAllReadDiv.click(); + await clickButtonByText(page, "Mark All", { + timeout: 5000, + }); + } + } catch (error) { + console.log( + "Mark all read functionality not available or already processed: ", + getErrorMessage(error), + ); + } +} diff --git a/e2e-tests/playwright/utils/ui-helper/table.ts b/e2e-tests/playwright/utils/ui-helper/table.ts new file mode 100644 index 0000000000..9bd64d4ea6 --- /dev/null +++ b/e2e-tests/playwright/utils/ui-helper/table.ts @@ -0,0 +1,134 @@ +import { expect, Locator, Page } from "@playwright/test"; +import { + getTableCell, + getTableRow, +} from "../../support/page-objects/ui-locators"; +import { DEFAULT_VERIFY_BUTTON_URL_OPTIONS } from "./defaults"; + +export async function verifyCellsInTable( + page: Page, + texts: (string | RegExp)[], +) { + for (const text of texts) { + const cellLocator = getTableCell(page, text); + const count = await cellLocator.count(); + + if (count === 0) { + throw new Error( + `Expected at least one cell with text matching ${String(text)}, but none were found.`, + ); + } + + for (let i = 0; i < count; i++) { + await expect(cellLocator.nth(i)).toBeVisible(); + } + } +} + +export async function verifyButtonURL( + page: Page, + label: string | RegExp, + url: string | RegExp, + options?: { locator?: string | Locator; exact?: boolean }, +) { + const { locator, exact } = { + ...DEFAULT_VERIFY_BUTTON_URL_OPTIONS, + ...options, + }; + const baseLocator = + locator === undefined || locator === "" + ? page + : typeof locator === "string" + ? page.locator(locator) + : locator; + + const buttonUrl = await baseLocator + .getByRole("button", { name: label, exact }) + .first() + .getAttribute("href"); + + expect(buttonUrl).toContain(url); +} + +export async function verifyRowInTableByUniqueText( + page: Page, + uniqueRowText: string, + cellTexts: string[] | RegExp[], +) { + const row = getTableRow(page, uniqueRowText); + await row.waitFor(); + for (const cellText of cellTexts) { + await expect( + row.getByRole("cell").filter({ hasText: cellText }).first(), + ).toBeVisible(); + } +} + +export async function clickOnLinkInTableByUniqueText( + page: Page, + uniqueRowText: string, + linkText: string | RegExp, + exact: boolean = true, +) { + const row = getTableRow(page, uniqueRowText); + await row.waitFor(); + await row.getByRole("link").getByText(linkText, { exact }).first().click(); +} + +export async function clickOnButtonInTableByUniqueText( + page: Page, + uniqueRowText: string, + textOrLabel: string | RegExp, +) { + const row = getTableRow(page, uniqueRowText); + await row.waitFor(); + await row + .locator( + `button:has-text("${String(textOrLabel)}"), button[aria-label="${String(textOrLabel)}"]`, + ) + .first() + .click(); +} + +export async function verifyTableHeadingAndRows(page: Page, texts: string[]) { + await page.waitForSelector("table tbody tr", { state: "visible" }); + for (const column of texts) { + const columnSelector = `table th:has-text("${column}")`; + const columnCount = await page.locator(columnSelector).count(); + expect(columnCount).toBeGreaterThan(0); + } + + const rowSelector = `table tbody tr:not(:has(td[colspan]))`; + const rowCount = await page.locator(rowSelector).count(); + expect(rowCount).toBeGreaterThan(0); +} + +export async function verifyTableIsEmpty(page: Page) { + const rowSelector = `table tbody tr:not(:has(td[colspan]))`; + const rowCount = await page.locator(rowSelector).count(); + expect(rowCount).toEqual(0); +} + +export async function verifyPluginRow( + page: Page, + text: string, + expectedEnabled: string, + expectedPreinstalled: string, +) { + const rowSelector = `tr:has(td:text-is("${text}"))`; + const row = page.locator(rowSelector); + + // Index 2 for "Enabled" + const enabledColumn = row.getByRole("cell").nth(2); + // Index 3 for "Preinstalled" + const preinstalledColumn = row.getByRole("cell").nth(3); + + await expect(enabledColumn).toHaveText(expectedEnabled); + await expect(preinstalledColumn).toHaveText(expectedPreinstalled); +} + +export async function waitForLoginBtnDisappear(page: Page) { + await page + .getByRole("button", { name: "Log in" }) + .waitFor({ state: "detached" }); +} diff --git a/e2e-tests/playwright/utils/ui-helper/verification.ts b/e2e-tests/playwright/utils/ui-helper/verification.ts new file mode 100644 index 0000000000..cf3612908a --- /dev/null +++ b/e2e-tests/playwright/utils/ui-helper/verification.ts @@ -0,0 +1,224 @@ +import { expect, Locator, Page } from "@playwright/test"; +import { getErrorMessage } from "../errors"; +import { DEFAULT_VERIFY_LINK_OPTIONS } from "./defaults"; + +export async function verifyDivHasText(page: Page, divText: string | RegExp) { + await expect(page.getByText(divText)).toBeVisible(); +} + +export async function verifyLink( + page: Page, + arg: string | { label: string }, + options?: { + exact?: boolean; + notVisible?: boolean; + }, +) { + const { exact, notVisible } = { ...DEFAULT_VERIFY_LINK_OPTIONS, ...options }; + let linkLocator: Locator; + let notVisibleCheck: boolean; + + if (typeof arg === "object") { + linkLocator = page.locator(`div[aria-label="${arg.label}"] a`); + notVisibleCheck = false; + } else { + linkLocator = page.getByRole("link", { name: arg, exact }).first(); + notVisibleCheck = notVisible; + } + + if (notVisibleCheck) { + await expect(linkLocator).toBeHidden(); + } else { + await expect(linkLocator).toBeVisible(); + } +} + +export async function verifyTextVisible( + page: Page, + text: string, + exact = false, + timeout = 10000, +): Promise { + const locator = page.getByText(text, { exact }); + await expect(locator).toBeVisible({ timeout }); +} + +export async function verifyLinkVisible( + page: Page, + text: string, + timeout = 10000, +): Promise { + const locator = page.locator(`a:has-text("${text}")`); + await expect(locator).toBeVisible({ timeout }); +} + +export async function verifyText( + page: Page, + text: string | RegExp, + exact: boolean = true, + timeout: number = 5000, +) { + await verifyTextInLocator(page, "", text, exact, timeout); +} + +export async function verifyRowsInTable( + page: Page, + rowTexts: (string | RegExp)[], + exact: boolean = true, +) { + for (const rowText of rowTexts) { + await verifyTextInLocator(page, `tr>td`, rowText, exact); + } +} + +export async function waitForTextDisappear(page: Page, text: string) { + await page.waitForSelector(`text=${text}`, { state: "detached" }); +} + +async function verifyTextInLocator( + page: Page, + locator: string, + text: string | RegExp, + exact: boolean, + timeout: number = 5000, +) { + const elementLocator = locator + ? page.locator(locator).getByText(text, { exact }).first() + : page.getByText(text, { exact }).first(); + + await elementLocator.waitFor({ state: "visible", timeout }); + await elementLocator.waitFor({ state: "attached" }); + + try { + await elementLocator.scrollIntoViewIfNeeded(); + } catch (error) { + console.warn( + `Warning: Could not scroll element into view. Error: ${getErrorMessage(error)}`, + ); + } + await expect(elementLocator).toBeVisible(); +} + +export async function verifyTextInSelector( + page: Page, + selector: string, + expectedText: string, +) { + const elementLocator = 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 page.locator(selector).allTextContents(); + console.error( + `Verification failed for text: Expected "${expectedText}". Selector content: ${allTextContent.join(", ")}`, + ); + throw error; + } +} + +export async function verifyPartialTextInSelector( + page: Page, + selector: string, + partialText: string, +) { + try { + const elements = page.locator(selector); + const count = await elements.count(); + + for (let i = 0; i < count; i++) { + const textContent = await elements.nth(i).textContent(); + if (textContent !== null && textContent.includes(partialText)) { + console.log( + `Found partial text: ${partialText} in element: ${textContent}`, + ); + return; + } + } + + throw new Error( + `Verification failed: Partial text "${partialText}" not found in any elements matching selector "${selector}".`, + ); + } catch (error) { + console.error(getErrorMessage(error)); + throw error; + } +} + +export async function verifyColumnHeading( + page: Page, + rowTexts: string[] | RegExp[], + exact: boolean = true, +) { + for (const rowText of rowTexts) { + const rowLocator = page + .getByRole("columnheader") + .getByText(rowText, { exact }) + .first(); + await rowLocator.waitFor({ state: "visible" }); + await rowLocator.scrollIntoViewIfNeeded(); + await expect(rowLocator).toBeVisible(); + } +} + +export async function verifyHeading( + page: Page, + heading: string | RegExp, + timeout: number = 20000, +) { + const headingLocator = page + .getByRole("heading") + .filter({ hasText: heading }) + .first(); + + await headingLocator.waitFor({ state: "visible", timeout }); + await expect(headingLocator).toBeVisible(); +} + +export async function verifyParagraph(page: Page, paragraph: string) { + const headingLocator = page.getByText(paragraph).first(); + await headingLocator.waitFor({ state: "visible", timeout: 20000 }); + await expect(headingLocator).toBeVisible(); +} + +export async function waitForTitle( + page: Page, + text: string, + level: number = 1, +) { + await page.waitForSelector(`h${level}:has-text("${text}")`); +} + +export async function verifyAlertErrorMessage( + page: Page, + message: string | RegExp, +) { + const alert = page.getByRole("alert"); + await alert.waitFor(); + await expect(alert).toHaveText(message); +} + +export async function verifyTextInTooltip(page: Page, text: string | RegExp) { + const tooltip = page.getByRole("tooltip").getByText(text); + await expect(tooltip).toBeVisible(); +} + +export async function clickSpanByText(page: Page, text: string) { + await verifyText(page, text); + await page.click(`span:has-text("${text}")`); +} diff --git a/e2e-tests/playwright/utils/ui-helper/visibility.ts b/e2e-tests/playwright/utils/ui-helper/visibility.ts new file mode 100644 index 0000000000..42da432666 --- /dev/null +++ b/e2e-tests/playwright/utils/ui-helper/visibility.ts @@ -0,0 +1,42 @@ +import { Page } from "@playwright/test"; + +async function isElementVisible( + page: Page, + locator: string, + timeout = 10000, + force = false, +): Promise { + try { + await page.waitForSelector(locator, { + state: "visible", + timeout, + }); + const button = page.locator(locator).first(); + return await button.isVisible(); + } catch (error) { + if (force) throw error; + return false; + } +} + +export function isBtnVisibleByTitle( + page: Page, + text: string, +): Promise { + const locator = `BUTTON[title="${text}"]`; + return isElementVisible(page, locator); +} + +export function isBtnVisible(page: Page, text: string): Promise { + const locator = `button:has-text("${text}")`; + return isElementVisible(page, locator); +} + +export function isTextVisible( + page: Page, + text: string, + timeout = 10000, +): Promise { + const locator = `:has-text("${text}")`; + return isElementVisible(page, locator, timeout); +} From 19f4a3e8c83f54b3989410afea5498b9c364ecd1 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Tue, 23 Jun 2026 13:17:13 -0500 Subject: [PATCH 2/8] refactor(e2e): replace oxlint file exceptions with path patterns Use directory globs instead of named files and document the rationale for each override block. Co-authored-by: Cursor --- e2e-tests/oxlint.config.ts | 56 ++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/e2e-tests/oxlint.config.ts b/e2e-tests/oxlint.config.ts index 8d0cd4ab7b..665a31ef70 100644 --- a/e2e-tests/oxlint.config.ts +++ b/e2e-tests/oxlint.config.ts @@ -64,6 +64,9 @@ export default defineConfig({ }, overrides: [ { + // Auth-provider specs deploy RHDH in beforeAll and use async Playwright hooks. + // strict-void-return and no-misused-promises produce false positives on those + // describe/beforeAll callbacks without improving test safety. files: ["playwright/e2e/auth-providers/**/*.spec.ts"], rules: { "typescript/strict-void-return": "off", @@ -71,6 +74,8 @@ export default defineConfig({ }, }, { + // Spec files orchestrate multi-step E2E flows; length limits target production + // code readability, not test scenarios that must stay in one file for clarity. files: ["**/*.spec.ts", "**/*.test.ts"], rules: { "eslint/max-lines": "off", @@ -78,24 +83,14 @@ export default defineConfig({ }, }, { + // Shared infrastructure (utils, support, data, e2e helpers) is split into + // modules but still contains cohesive orchestration (kube waits, deployment + // setup, log parsing). Complexity limits would force artificial fragmentation. files: [ - "playwright/utils/kube-client.ts", - "playwright/utils/kube-client-*.ts", - "playwright/utils/common.ts", - "playwright/utils/common-auth-popup.ts", - "playwright/utils/ui-helper.ts", - "playwright/utils/ui-helper/**/*.ts", - "playwright/utils/api-helper.ts", - "playwright/utils/postgres-config.ts", - "playwright/utils/authentication-providers/rhdh-deployment.ts", - "playwright/utils/authentication-providers/rhdh-deployment-*.ts", - "playwright/utils/authentication-providers/msgraph-helper.ts", - "playwright/utils/authentication-providers/msgraph-helper-nsg.ts", - "playwright/e2e/audit-log/log-utils.ts", - "playwright/e2e/plugin-division-mode-schema/schema-mode-setup.ts", - "playwright/e2e/plugin-division-mode-schema/schema-mode-db.ts", - "playwright/support/selectors/semantic-selectors*.ts", - "playwright/data/rbac-constants.ts", + "playwright/utils/**/*.ts", + "playwright/support/**/*.ts", + "playwright/data/**/*.ts", + "playwright/e2e/**/*.ts", ], rules: { "eslint/max-lines": "off", @@ -104,24 +99,25 @@ export default defineConfig({ }, }, { - files: ["playwright/e2e/localization/locale.ts"], - rules: { - "import/max-dependencies": "off", - }, - }, - { - files: ["playwright/utils/kube-client.ts"], - rules: { - "import/max-dependencies": "off", - }, - }, - { - files: ["playwright/utils/authentication-providers/rhdh-deployment.ts"], + // Facade modules aggregate many submodules by design (e.g. KubeClient re-exports, + // rhdh-deployment orchestration, locale translation maps). A flat import count + // does not reflect coupling when each import is a focused submodule. + files: [ + "playwright/utils/**/*.ts", + "playwright/e2e/localization/**/*.ts", + ], rules: { "import/max-dependencies": "off", }, }, { + // valid-title / valid-describe-callback: existing suite uses legacy naming + // patterns that do not match the plugin's strict conventions. + // no-wait-for-selector: replaced with expect() and locator.waitFor() per + // hardening guidelines; rule would flag intentional migration patterns. + // expect-expect + assertFunctionNames: POM verify* helpers and loginAsGuest + // perform assertions on behalf of the spec; register them so specs are not + // forced to duplicate expect() calls after every helper invocation. files: ["**/*.spec.ts", "**/*.test.ts", "playwright/**/*.ts"], rules: { // Playwright requires object destructuring for hook/test callbacks that take From f585c82f55539ee62a7d37b694570de3e75d4036 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 24 Jun 2026 11:38:50 -0500 Subject: [PATCH 3/8] fix(e2e): apply Playwright assertion fixes in oxlint pedantic PR Use plain await for deployment restart, expect().toBeVisible() for the About card link, and table-scoped verifyText for catalog Name checks. Register restartDeployment as an assert helper for expect-expect. Co-authored-by: Cursor --- e2e-tests/oxlint.config.ts | 1 + .../verify-tls-config-with-external-azure-db.spec.ts | 6 ++---- .../verify-tls-config-with-external-rds.spec.ts | 6 ++---- .../scaffolder-relation-processor.spec.ts | 5 +---- e2e-tests/playwright/support/pages/backstage-showcase.ts | 7 +------ 5 files changed, 7 insertions(+), 18 deletions(-) diff --git a/e2e-tests/oxlint.config.ts b/e2e-tests/oxlint.config.ts index 665a31ef70..b98754096d 100644 --- a/e2e-tests/oxlint.config.ts +++ b/e2e-tests/oxlint.config.ts @@ -158,6 +158,7 @@ export default defineConfig({ "verifyTextInSelector", "verifyPartialTextInSelector", "loginAsGuest", + "restartDeployment", "waitForTitle", ], }, diff --git a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts index 70328ea23d..6d44ebb479 100644 --- a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts +++ b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@support/coverage/test"; +import { test } from "@support/coverage/test"; import { UIhelper } from "../../utils/ui-helper"; import { Common } from "../../utils/common"; import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; @@ -92,9 +92,7 @@ test.describe("Verify TLS configuration with Azure Database for PostgreSQL healt user: azureUser, password: azurePassword, }); - await expect( - kubeClient.restartDeployment(deploymentName, namespace), - ).resolves.toBeUndefined(); + await kubeClient.restartDeployment(deploymentName, namespace); }); test("Verify successful DB connection", async ({ page }) => { diff --git a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts index 32d13de0ee..f63981a7e2 100644 --- a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts +++ b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@support/coverage/test"; +import { test } from "@support/coverage/test"; import { UIhelper } from "../../utils/ui-helper"; import { Common } from "../../utils/common"; import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; @@ -88,9 +88,7 @@ test.describe("Verify TLS configuration with RDS PostgreSQL health check", () => user: rdsUser, password: rdsPassword, }); - await expect( - kubeClient.restartDeployment(deploymentName, namespace), - ).resolves.toBeUndefined(); + await kubeClient.restartDeployment(deploymentName, namespace); }); test("Verify successful DB connection", async ({ page }) => { diff --git a/e2e-tests/playwright/e2e/plugins/scaffolder-relation-processor/scaffolder-relation-processor.spec.ts b/e2e-tests/playwright/e2e/plugins/scaffolder-relation-processor/scaffolder-relation-processor.spec.ts index c5f08711e5..2c50235e8c 100644 --- a/e2e-tests/playwright/e2e/plugins/scaffolder-relation-processor/scaffolder-relation-processor.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/scaffolder-relation-processor/scaffolder-relation-processor.spec.ts @@ -51,10 +51,7 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { test("Register the template for scaffolder relation processor", async () => { await uiHelper.openSidebar("Catalog"); - // Wait for the Catalog page table to fully load before proceeding - await expect(page.getByText("Name", { exact: true }).first()).toBeVisible({ - timeout: 20000, - }); + await uiHelper.verifyText("Name"); await uiHelper.clickButton("Self-service"); await uiHelper.verifyHeading("Self-service"); diff --git a/e2e-tests/playwright/support/pages/backstage-showcase.ts b/e2e-tests/playwright/support/pages/backstage-showcase.ts index c4357eb2f8..2ccb588348 100644 --- a/e2e-tests/playwright/support/pages/backstage-showcase.ts +++ b/e2e-tests/playwright/support/pages/backstage-showcase.ts @@ -58,12 +58,7 @@ export class BackstageShowcase { async verifyAboutCardIsDisplayed() { const url = "https://github.com/redhat-developer/rhdh/tree/main/catalog-entities/components/"; - const isLinkVisible = await this.page - .locator(`a[href="${url}"]`) - .isVisible(); - if (!isLinkVisible) { - throw new Error("About card is not displayed"); - } + await expect(this.page.locator(`a[href="${url}"]`)).toBeVisible(); } async verifyPRRows( From 647a1669fb23ac5ebaa1c46442bb9d0680f5d3aa Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 24 Jun 2026 12:21:53 -0500 Subject: [PATCH 4/8] chore(e2e): reformat after pedantic rebase onto Oxfmt defaults Co-authored-by: Cursor --- e2e-tests/oxlint.config.ts | 5 +- .../playwright/data/rbac-constants-roles.ts | 5 +- .../playwright/e2e/audit-log/log-utils.ts | 54 ++--- .../e2e/auth-providers/github.spec.ts | 73 ++----- .../e2e/auth-providers/gitlab.spec.ts | 93 +++------ .../e2e/auth-providers/ldap.spec.ts | 154 ++++----------- .../e2e/auth-providers/oidc.spec.ts | 184 ++++-------------- ...-tls-config-with-external-azure-db.spec.ts | 15 +- ...erify-tls-config-with-external-rds.spec.ts | 7 +- .../schema-mode-db.ts | 56 ++---- .../schema-mode-setup.ts | 99 +++------- .../verify-schema-mode.spec.ts | 39 ++-- .../annotator.spec.ts | 77 ++------ .../scaffolder-relation-processor.spec.ts | 45 ++--- .../plugins/user-settings-info-card.spec.ts | 65 +++---- .../support/api/github-structures.ts | 10 +- e2e-tests/playwright/support/api/github.ts | 14 +- e2e-tests/playwright/support/api/rbac-api.ts | 18 +- .../support/page-objects/page-obj.ts | 40 ++-- .../support/page-objects/ui-locators.ts | 6 +- .../support/pages/backstage-showcase.ts | 21 +- .../support/pages/catalog-import.ts | 34 +--- .../playwright/support/pages/home-page.ts | 25 +-- e2e-tests/playwright/support/pages/rbac.ts | 23 +-- .../semantic-selectors-accessibility.ts | 6 +- .../selectors/semantic-selectors-structure.ts | 20 +- .../semantic-selectors-table-helpers.ts | 11 +- .../support/selectors/semantic-selectors.ts | 5 +- .../support/selectors/wait-strategies.ts | 4 +- .../playwright/utils/api-helper-catalog.ts | 9 +- .../playwright/utils/api-helper-github.ts | 50 ++--- .../playwright/utils/api-helper-guards.ts | 30 +-- e2e-tests/playwright/utils/api-helper.ts | 68 ++----- .../authentication-providers/gitlab-helper.ts | 40 ++-- .../msgraph-helper-nsg.ts | 46 +---- .../msgraph-helper.ts | 138 +++---------- .../rhdh-deployment-auth.ts | 92 ++++----- .../rhdh-deployment-catalog.ts | 17 +- .../rhdh-deployment-k8s.ts | 120 +++--------- .../rhdh-deployment-logs.ts | 21 +- .../rhdh-deployment-types.ts | 13 +- .../rhdh-deployment-wait.ts | 74 ++----- .../rhdh-deployment.ts | 75 ++----- .../playwright/utils/common-auth-popup.ts | 27 +-- e2e-tests/playwright/utils/common-browser.ts | 23 +-- e2e-tests/playwright/utils/common.ts | 56 ++---- .../playwright/utils/kube-client-configmap.ts | 70 ++----- .../utils/kube-client-deployment-restart.ts | 48 +---- .../utils/kube-client-deployment-scale.ts | 26 +-- .../utils/kube-client-deployment-wait.ts | 72 ++----- .../utils/kube-client-diagnostics-events.ts | 26 +-- .../utils/kube-client-diagnostics-pods.ts | 36 +--- .../kube-client-diagnostics-replicasets.ts | 15 +- .../playwright/utils/kube-client-exec.ts | 6 +- .../playwright/utils/kube-client-helpers.ts | 14 +- .../utils/kube-client-pod-failure.ts | 50 +---- e2e-tests/playwright/utils/kube-client.ts | 137 +++---------- e2e-tests/playwright/utils/postgres-config.ts | 27 +-- e2e-tests/playwright/utils/ui-helper/class.ts | 75 ++----- .../playwright/utils/ui-helper/interaction.ts | 32 +-- e2e-tests/playwright/utils/ui-helper/misc.ts | 40 +--- .../playwright/utils/ui-helper/navigation.ts | 26 +-- e2e-tests/playwright/utils/ui-helper/table.ts | 19 +- .../utils/ui-helper/verification.ts | 56 ++---- .../playwright/utils/ui-helper/visibility.ts | 11 +- 65 files changed, 735 insertions(+), 2158 deletions(-) diff --git a/e2e-tests/oxlint.config.ts b/e2e-tests/oxlint.config.ts index b98754096d..648437e905 100644 --- a/e2e-tests/oxlint.config.ts +++ b/e2e-tests/oxlint.config.ts @@ -102,10 +102,7 @@ export default defineConfig({ // Facade modules aggregate many submodules by design (e.g. KubeClient re-exports, // rhdh-deployment orchestration, locale translation maps). A flat import count // does not reflect coupling when each import is a focused submodule. - files: [ - "playwright/utils/**/*.ts", - "playwright/e2e/localization/**/*.ts", - ], + files: ["playwright/utils/**/*.ts", "playwright/e2e/localization/**/*.ts"], rules: { "import/max-dependencies": "off", }, diff --git a/e2e-tests/playwright/data/rbac-constants-roles.ts b/e2e-tests/playwright/data/rbac-constants-roles.ts index 9c430af008..af68819ab1 100644 --- a/e2e-tests/playwright/data/rbac-constants-roles.ts +++ b/e2e-tests/playwright/data/rbac-constants-roles.ts @@ -26,10 +26,7 @@ export const EXPECTED_ROLES: Role[] = [ name: "role:default/qe_rbac_admin", }, { - memberReferences: [ - "group:default/rhdh-qe-parent-team", - "group:default/rhdh-qe-child-team", - ], + memberReferences: ["group:default/rhdh-qe-parent-team", "group:default/rhdh-qe-child-team"], name: "role:default/transitive-owner", }, { diff --git a/e2e-tests/playwright/e2e/audit-log/log-utils.ts b/e2e-tests/playwright/e2e/audit-log/log-utils.ts index 8bacdbab22..bec084c7a0 100644 --- a/e2e-tests/playwright/e2e/audit-log/log-utils.ts +++ b/e2e-tests/playwright/e2e/audit-log/log-utils.ts @@ -1,13 +1,10 @@ -import { expect } from "@playwright/test"; import { execFile, exec } from "child_process"; + import { type JsonObject } from "@backstage/types"; -import { - Log, - type LogRequest, - type EventStatus, - type EventSeverityLevel, -} from "./logs"; +import { expect } from "@playwright/test"; + import { getBackstageDeploySelector } from "../../utils/helper"; +import { Log, type LogRequest, type EventStatus, type EventSeverityLevel } from "./logs"; function formatError(error: unknown): string { if (error instanceof Error) { @@ -138,10 +135,7 @@ export const LogUtils = { console.log(`Command executed successfully on attempt ${attempt + 1}`); return output; } catch (error) { - console.error( - `Error executing command on attempt ${attempt + 1}:`, - error, - ); + console.error(`Error executing command on attempt ${attempt + 1}:`, error); attempt++; } } @@ -181,10 +175,9 @@ export const LogUtils = { return await LogUtils.executeCommand("oc", args); } catch (error) { console.error("Error listing pods:", error); - throw new Error( - `Failed to list pods in namespace "${namespace}": ${formatError(error)}`, - { cause: error }, - ); + throw new Error(`Failed to list pods in namespace "${namespace}": ${formatError(error)}`, { + cause: error, + }); } }, @@ -226,14 +219,10 @@ export const LogUtils = { let attempt = 0; while (attempt <= maxRetries) { try { - console.log( - `Attempt ${attempt + 1}/${maxRetries + 1}: Fetching logs with grep...`, - ); + console.log(`Attempt ${attempt + 1}/${maxRetries + 1}: Fetching logs with grep...`); const output = await LogUtils.executeShellCommand(grepCommand); - const logLines = output - .split("\n") - .filter((line) => line.trim() !== ""); + const logLines = output.split("\n").filter((line) => line.trim() !== ""); if (logLines.length > 0) { console.log("Matching log line found:", logLines[0]); return logLines[0]; @@ -243,10 +232,7 @@ export const LogUtils = { `No matching logs found for filter ${JSON.stringify(filterWords)} on attempt ${attempt + 1}. Retrying...`, ); } catch (error) { - console.error( - `Error fetching logs on attempt ${attempt + 1}:`, - formatError(error), - ); + console.error(`Error fetching logs on attempt ${attempt + 1}:`, formatError(error)); } attempt++; @@ -271,9 +257,7 @@ export const LogUtils = { const server = process.env.K8S_CLUSTER_URL ?? ""; if (token === "" || server === "") { - throw new Error( - "Environment variables K8S_CLUSTER_TOKEN and K8S_CLUSTER_URL must be set.", - ); + throw new Error("Environment variables K8S_CLUSTER_TOKEN and K8S_CLUSTER_URL must be set."); } const command = "oc"; @@ -316,22 +300,16 @@ export const LogUtils = { filterWordsAll.push(request.url); } try { - const actualLog = await LogUtils.getPodLogsWithGrep( - filterWordsAll, - namespace, - ); + const actualLog = await LogUtils.getPodLogsWithGrep(filterWordsAll, namespace); let parsedLog: Log; try { parsedLog = parseLogFromJson(actualLog); } catch (parseError) { console.error("Failed to parse log JSON. Log content:", actualLog); - throw new Error( - `Invalid JSON received for log: ${formatError(parseError)}`, - { - cause: parseError, - }, - ); + throw new Error(`Invalid JSON received for log: ${formatError(parseError)}`, { + cause: parseError, + }); } const expectedLog: Partial = { diff --git a/e2e-tests/playwright/e2e/auth-providers/github.spec.ts b/e2e-tests/playwright/e2e/auth-providers/github.spec.ts index 5022cebdeb..f917bb5024 100644 --- a/e2e-tests/playwright/e2e/auth-providers/github.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/github.spec.ts @@ -1,8 +1,9 @@ import { test, expect, Page, BrowserContext } from "@support/coverage/test"; + import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; import { Common, setupBrowser } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; import { NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE } from "../../utils/constants"; +import { UIhelper } from "../../utils/ui-helper"; let page: Page; let context: BrowserContext; @@ -133,9 +134,7 @@ test.describe("Configure Github Provider", async () => { test.beforeEach(() => { test.info().setTimeout(600 * 1000); - console.log( - `Running test case ${test.info().title} - Attempt #${test.info().retry}`, - ); + console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); }); test("Login with Github default resolver", async () => { @@ -178,10 +177,7 @@ test.describe("Configure Github Provider", async () => { test("Login with Github emailMatchingUserEntityProfileEmail resolver", async () => { //A common sign-in resolver that looks up the user using the local part of their email address as the entity name. - await deployment.setGithubResolver( - "emailMatchingUserEntityProfileEmail", - false, - ); + await deployment.setGithubResolver("emailMatchingUserEntityProfileEmail", false); await deployment.updateAllConfigs(); await deployment.restartLocalDeployment(); await deployment.waitForConfigReconciled(); @@ -197,18 +193,13 @@ test.describe("Configure Github Provider", async () => { ); expect(login).toBe("Login successful"); - await uiHelper.verifyAlertErrorMessage( - NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE, - ); + await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); await context.clearCookies(); }); test("Login with Github emailLocalPartMatchingUserEntityName resolver", async () => { //A common sign-in resolver that looks up the user using the local part of their email address as the entity name. - await deployment.setGithubResolver( - "emailLocalPartMatchingUserEntityName", - false, - ); + await deployment.setGithubResolver("emailLocalPartMatchingUserEntityName", false); await deployment.updateAllConfigs(); await deployment.restartLocalDeployment(); await deployment.waitForConfigReconciled(); @@ -227,17 +218,12 @@ test.describe("Configure Github Provider", async () => { expect(login).toBe("Login successful"); - await uiHelper.verifyAlertErrorMessage( - NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE, - ); + await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); await context.clearCookies(); }); test(`Set Github sessionDuration and confirm in auth cookie duration has been set`, async () => { - deployment.setAppConfigProperty( - "auth.providers.github.production.sessionDuration", - "3days", - ); + deployment.setAppConfigProperty("auth.providers.github.production.sessionDuration", "3days"); await deployment.updateAllConfigs(); await deployment.restartLocalDeployment(); await deployment.waitForConfigReconciled(); @@ -256,9 +242,7 @@ test.describe("Configure Github Provider", async () => { await page.reload(); const cookies = await context.cookies(); - const authCookie = cookies.find( - (cookie) => cookie.name === "github-refresh-token", - ); + const authCookie = cookies.find((cookie) => cookie.name === "github-refresh-token"); expect(authCookie).toBeDefined(); // expected duration of 3 days in ms @@ -281,35 +265,18 @@ test.describe("Configure Github Provider", async () => { test.setTimeout(300 * 1000); await expect - .poll( - () => - deployment.checkUserIsIngestedInCatalog([ - "RHDH QE User 1", - "RHDH QE Admin", - ]), - { timeout: 120_000 }, - ) + .poll(() => deployment.checkUserIsIngestedInCatalog(["RHDH QE User 1", "RHDH QE Admin"]), { + timeout: 120_000, + }) .toBe(true); expect( - await deployment.checkGroupIsIngestedInCatalog([ - "test_admins", - "test_all", - "test_users", - ]), - ).toBe(true); - expect( - await deployment.checkUserIsInGroup("rhdhqeauthadmin", "test_admins"), - ).toBe(true); - expect( - await deployment.checkUserIsInGroup("rhdhqeauth1", "test_users"), + await deployment.checkGroupIsIngestedInCatalog(["test_admins", "test_all", "test_users"]), ).toBe(true); + expect(await deployment.checkUserIsInGroup("rhdhqeauthadmin", "test_admins")).toBe(true); + expect(await deployment.checkUserIsInGroup("rhdhqeauth1", "test_users")).toBe(true); - expect( - await deployment.checkGroupIsChildOfGroup("test_users", "test_all"), - ).toBe(true); - expect( - await deployment.checkGroupIsChildOfGroup("test_admins", "test_all"), - ).toBe(true); + expect(await deployment.checkGroupIsChildOfGroup("test_users", "test_all")).toBe(true); + expect(await deployment.checkGroupIsChildOfGroup("test_admins", "test_all")).toBe(true); expect( await deployment.checkUserHasAnnotation( @@ -319,11 +286,7 @@ test.describe("Configure Github Provider", async () => { ), ).toBe(true); expect( - await deployment.checkUserHasAnnotation( - "rhdhqeauth1", - "MY_CUSTOM_ANNOTATION", - "rhdhqeauth1", - ), + await deployment.checkUserHasAnnotation("rhdhqeauth1", "MY_CUSTOM_ANNOTATION", "rhdhqeauth1"), ).toBe(true); }); diff --git a/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts b/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts index 6bd60f8587..3925d504eb 100644 --- a/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts @@ -1,8 +1,9 @@ import { test, expect, Page, BrowserContext } from "@support/coverage/test"; + +import { GitLabHelper } from "../../utils/authentication-providers/gitlab-helper"; import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; import { Common, setupBrowser } from "../../utils/common"; import { UIhelper } from "../../utils/ui-helper"; -import { GitLabHelper } from "../../utils/authentication-providers/gitlab-helper"; let page: Page; let context: BrowserContext; @@ -82,9 +83,7 @@ test.describe("Configure GitLab Provider", async () => { true, ); oauthAppId = oauthApp.id; - console.log( - `[TEST] GitLab OAuth application created - ID: ${oauthApp.application_id}`, - ); + console.log(`[TEST] GitLab OAuth application created - ID: ${oauthApp.application_id}`); // clean old namespaces await deployment.deleteNamespaceIfExists(); @@ -115,14 +114,8 @@ test.describe("Configure GitLab Provider", async () => { "AUTH_PROVIDERS_GITLAB_PARENT_ORG", process.env.AUTH_PROVIDERS_GITLAB_PARENT_ORG!, ); - await deployment.addSecretData( - "AUTH_PROVIDERS_GITLAB_CLIENT_ID", - oauthApp.application_id, - ); - await deployment.addSecretData( - "AUTH_PROVIDERS_GITLAB_CLIENT_SECRET", - oauthApp.secret, - ); + await deployment.addSecretData("AUTH_PROVIDERS_GITLAB_CLIENT_ID", oauthApp.application_id); + await deployment.addSecretData("AUTH_PROVIDERS_GITLAB_CLIENT_SECRET", oauthApp.secret); await deployment.addSecretData( "AUTH_PROVIDERS_GITLAB_TOKEN", process.env.AUTH_PROVIDERS_GITLAB_TOKEN!, @@ -146,16 +139,11 @@ test.describe("Configure GitLab Provider", async () => { test.beforeEach(() => { test.info().setTimeout(60 * 1000); - console.log( - `Running test case ${test.info().title} - Attempt #${test.info().retry}`, - ); + console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); }); test("Login with GitLab default resolver", async () => { - const login = await common.gitlabLogin( - "user1", - process.env.DEFAULT_USER_PASSWORD!, - ); + const login = await common.gitlabLogin("user1", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -167,13 +155,7 @@ test.describe("Configure GitLab Provider", async () => { test(`Ingestion of GitLab users and groups: verify the user entities and groups are created with the correct relationships`, async () => { await expect .poll( - () => - deployment.checkUserIsIngestedInCatalog([ - "user1", - "user2", - "user3", - "Administrator", - ]), + () => deployment.checkUserIsIngestedInCatalog(["user1", "user2", "user3", "Administrator"]), { timeout: 120_000 }, ) .toBe(true); @@ -194,55 +176,27 @@ test.describe("Configure GitLab Provider", async () => { expect(await deployment.checkUserIsInGroup("root", "group1")).toBe(true); - expect(await deployment.checkUserIsInGroup("user1", "group1-nested")).toBe( - true, - ); - expect(await deployment.checkUserIsInGroup("user2", "group1-nested")).toBe( - true, - ); - expect(await deployment.checkUserIsInGroup("root", "group1-nested")).toBe( - true, - ); + expect(await deployment.checkUserIsInGroup("user1", "group1-nested")).toBe(true); + expect(await deployment.checkUserIsInGroup("user2", "group1-nested")).toBe(true); + expect(await deployment.checkUserIsInGroup("root", "group1-nested")).toBe(true); - expect( - await deployment.checkUserIsInGroup("user3", "group1-nested-nested_2"), - ).toBe(true); - expect( - await deployment.checkUserIsInGroup("root", "group1-nested-nested_2"), - ).toBe(true); + expect(await deployment.checkUserIsInGroup("user3", "group1-nested-nested_2")).toBe(true); + expect(await deployment.checkUserIsInGroup("root", "group1-nested-nested_2")).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("group1", "my-org")).toBe( - true, - ); - expect(await deployment.checkGroupIsParentOfGroup("my-org", "group1")).toBe( - true, - ); + expect(await deployment.checkGroupIsChildOfGroup("group1", "my-org")).toBe(true); + expect(await deployment.checkGroupIsParentOfGroup("my-org", "group1")).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("all", "my-org")).toBe( - true, - ); - expect(await deployment.checkGroupIsParentOfGroup("my-org", "all")).toBe( - true, - ); + expect(await deployment.checkGroupIsChildOfGroup("all", "my-org")).toBe(true); + expect(await deployment.checkGroupIsParentOfGroup("my-org", "all")).toBe(true); - expect( - await deployment.checkGroupIsChildOfGroup("group1-nested", "group1"), - ).toBe(true); - expect( - await deployment.checkGroupIsParentOfGroup("group1", "group1-nested"), - ).toBe(true); + expect(await deployment.checkGroupIsChildOfGroup("group1-nested", "group1")).toBe(true); + expect(await deployment.checkGroupIsParentOfGroup("group1", "group1-nested")).toBe(true); expect( - await deployment.checkGroupIsChildOfGroup( - "group1-nested-nested_2", - "group1-nested", - ), + await deployment.checkGroupIsChildOfGroup("group1-nested-nested_2", "group1-nested"), ).toBe(true); expect( - await deployment.checkGroupIsParentOfGroup( - "group1-nested", - "group1-nested-nested_2", - ), + await deployment.checkGroupIsParentOfGroup("group1-nested", "group1-nested-nested_2"), ).toBe(true); }); @@ -255,10 +209,7 @@ test.describe("Configure GitLab Provider", async () => { await gitlabHelper.deleteOAuthApplication(oauthAppId); console.log("[TEST] GitLab OAuth application deleted successfully"); } catch (error) { - console.error( - "[TEST] Failed to delete GitLab OAuth application:", - error, - ); + console.error("[TEST] Failed to delete GitLab OAuth application:", error); } } diff --git a/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts b/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts index 68e5ebec02..c5c0a42b61 100644 --- a/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts @@ -1,8 +1,9 @@ import { test, expect, Page, BrowserContext } from "@support/coverage/test"; + +import { MSClient } from "../../utils/authentication-providers/msgraph-helper"; import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; import { Common, setupBrowser } from "../../utils/common"; import { UIhelper } from "../../utils/ui-helper"; -import { MSClient } from "../../utils/authentication-providers/msgraph-helper"; let page: Page; let browserContext: BrowserContext; @@ -94,60 +95,21 @@ test.describe("Configure LDAP Provider", () => { await deployment.addSecretData("BASE_BACKEND_URL", backstageBackendUrl); } - await deployment.addSecretData( - "DEFAULT_USER_PASSWORD", - process.env.DEFAULT_USER_PASSWORD!, - ); - await deployment.addSecretData( - "RHBK_LDAP_REALM", - process.env.RHBK_LDAP_REALM!, - ); - await deployment.addSecretData( - "RHBK_LDAP_CLIENT_ID", - process.env.RHBK_LDAP_CLIENT_ID!, - ); - await deployment.addSecretData( - "RHBK_LDAP_CLIENT_SECRET", - process.env.RHBK_LDAP_CLIENT_SECRET!, - ); - await deployment.addSecretData( - "LDAP_BIND_DN", - process.env.RHBK_LDAP_USER_BIND!, - ); - await deployment.addSecretData( - "LDAP_BIND_SECRET", - process.env.RHBK_LDAP_USER_PASSWORD!, - ); - await deployment.addSecretData( - "LDAP_TARGET_URL", - process.env.RHBK_LDAP_TARGET!, - ); - await deployment.addSecretData( - "DEFAULT_USER_PASSWORD", - process.env.DEFAULT_USER_PASSWORD!, - ); - await deployment.addSecretData( - "DEFAULT_USER_PASSWORD_2", - process.env.DEFAULT_USER_PASSWORD_2!, - ); - await deployment.addSecretData( - "LDAP_GROUPS_DN", - "OU=Groups,OU=RHDH Local,DC=rhdh,DC=test", - ); - await deployment.addSecretData( - "LDAP_USERS_DN", - "OU=Users,OU=RHDH Local,DC=rhdh,DC=test", - ); + await deployment.addSecretData("DEFAULT_USER_PASSWORD", process.env.DEFAULT_USER_PASSWORD!); + await deployment.addSecretData("RHBK_LDAP_REALM", process.env.RHBK_LDAP_REALM!); + await deployment.addSecretData("RHBK_LDAP_CLIENT_ID", process.env.RHBK_LDAP_CLIENT_ID!); + await deployment.addSecretData("RHBK_LDAP_CLIENT_SECRET", process.env.RHBK_LDAP_CLIENT_SECRET!); + await deployment.addSecretData("LDAP_BIND_DN", process.env.RHBK_LDAP_USER_BIND!); + await deployment.addSecretData("LDAP_BIND_SECRET", process.env.RHBK_LDAP_USER_PASSWORD!); + await deployment.addSecretData("LDAP_TARGET_URL", process.env.RHBK_LDAP_TARGET!); + await deployment.addSecretData("DEFAULT_USER_PASSWORD", process.env.DEFAULT_USER_PASSWORD!); + await deployment.addSecretData("DEFAULT_USER_PASSWORD_2", process.env.DEFAULT_USER_PASSWORD_2!); + await deployment.addSecretData("LDAP_GROUPS_DN", "OU=Groups,OU=RHDH Local,DC=rhdh,DC=test"); + await deployment.addSecretData("LDAP_USERS_DN", "OU=Users,OU=RHDH Local,DC=rhdh,DC=test"); await deployment.addSecretData("RHBK_BASE_URL", process.env.RHBK_BASE_URL!); await deployment.addSecretData("RHBK_REALM", process.env.RHBK_REALM!); - await deployment.addSecretData( - "RHBK_CLIENT_ID", - process.env.RHBK_CLIENT_ID!, - ); - await deployment.addSecretData( - "RHBK_CLIENT_SECRET", - process.env.RHBK_CLIENT_SECRET!, - ); + await deployment.addSecretData("RHBK_CLIENT_ID", process.env.RHBK_CLIENT_ID!); + await deployment.addSecretData("RHBK_CLIENT_SECRET", process.env.RHBK_CLIENT_SECRET!); await deployment.addSecretData( "AUTH_PROVIDERS_GH_ORG_CLIENT_ID", @@ -158,14 +120,8 @@ test.describe("Configure LDAP Provider", () => { process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET!, ); - await deployment.addSecretData( - "PINGFEDERATE_BASE_URL", - process.env.PINGFEDERATE_BASE_URL!, - ); - await deployment.addSecretData( - "PINGFEDERATE_CLIENT_ID", - process.env.PINGFEDERATE_CLIENT_ID!, - ); + await deployment.addSecretData("PINGFEDERATE_BASE_URL", process.env.PINGFEDERATE_BASE_URL!); + await deployment.addSecretData("PINGFEDERATE_CLIENT_ID", process.env.PINGFEDERATE_CLIENT_ID!); await deployment.addSecretData( "PINGFEDERATE_CLIENT_SECRET", process.env.PINGFEDERATE_CLIENT_SECRET!, @@ -195,9 +151,7 @@ test.describe("Configure LDAP Provider", () => { "AllowE2EJobs", ); console.log(`[TEST] NSG access configured successfully`); - console.log( - `[TEST] Rule created: ${nsgConfig.ruleName} for IP: ${nsgConfig.publicIp}`, - ); + console.log(`[TEST] Rule created: ${nsgConfig.ruleName} for IP: ${nsgConfig.publicIp}`); // Store cleanup function for afterAll nsgCleanup = nsgConfig.cleanup; @@ -216,9 +170,7 @@ test.describe("Configure LDAP Provider", () => { test.beforeEach(() => { test.info().setTimeout(600 * 1000); - console.log( - `Running test case ${test.info().title} - Attempt #${test.info().retry}`, - ); + console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); }); test("Login with LDAP oidcLdapUuidMatchingAnnotation resolver", async () => { @@ -235,12 +187,7 @@ test.describe("Configure LDAP Provider", () => { test(`Ingestion of LDAP users and groups: verify the user entities and groups are created with the correct relationships`, async () => { expect( - await deployment.checkUserIsIngestedInCatalog([ - "User 1", - "User 2", - "User 3", - "RHDH Admin", - ]), + await deployment.checkUserIsIngestedInCatalog(["User 1", "User 2", "User 3", "RHDH Admin"]), ).toBe(true); expect( @@ -253,34 +200,16 @@ test.describe("Configure LDAP Provider", () => { "SubAdmins", ]), ).toBe(true); - expect(await deployment.checkUserIsInGroup("rhdh-admin", "Admins")).toBe( - true, - ); - expect(await deployment.checkUserIsInGroup("user1", "All_Users")).toBe( + expect(await deployment.checkUserIsInGroup("rhdh-admin", "Admins")).toBe(true); + expect(await deployment.checkUserIsInGroup("user1", "All_Users")).toBe(true); + expect(await deployment.checkUserIsInGroup("user2", "All_Users")).toBe(true); + + expect(await deployment.checkGroupIsChildOfGroup("testsubgroup", "testgroup")).toBe(true); + expect(await deployment.checkGroupIsChildOfGroup("testsubsubgroup", "testsubgroup")).toBe(true); + expect(await deployment.checkGroupIsParentOfGroup("testgroup", "testsubgroup")).toBe(true); + expect(await deployment.checkGroupIsParentOfGroup("testsubgroup", "testsubsubgroup")).toBe( true, ); - expect(await deployment.checkUserIsInGroup("user2", "All_Users")).toBe( - true, - ); - - expect( - await deployment.checkGroupIsChildOfGroup("testsubgroup", "testgroup"), - ).toBe(true); - expect( - await deployment.checkGroupIsChildOfGroup( - "testsubsubgroup", - "testsubgroup", - ), - ).toBe(true); - expect( - await deployment.checkGroupIsParentOfGroup("testgroup", "testsubgroup"), - ).toBe(true); - expect( - await deployment.checkGroupIsParentOfGroup( - "testsubgroup", - "testsubsubgroup", - ), - ).toBe(true); }); test("Login with PingFederate OIDC (with LDAP catalog)", async () => { @@ -295,10 +224,7 @@ test.describe("Configure LDAP Provider", () => { // Wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.pingFederateLogin( - "user1", - process.env.RHBK_LDAP_USER_PASSWORD!, - ); + const login = await common.pingFederateLogin("user1", process.env.RHBK_LDAP_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -309,16 +235,13 @@ test.describe("Configure LDAP Provider", () => { test("Login with PingFederate OIDC (with LDAP catalog) with sub as ldap_uuid", async () => { await deployment.enablePingFederateOIDCLogin(); - deployment.setAppConfigProperty( - "auth.providers.oidc.production.signIn.resolvers", - [ - { - resolver: "oidcLdapUuidMatchingAnnotation", - // match sub claim as required by OIDC spec - ldapUuidKey: "sub", - }, - ], - ); + deployment.setAppConfigProperty("auth.providers.oidc.production.signIn.resolvers", [ + { + resolver: "oidcLdapUuidMatchingAnnotation", + // match sub claim as required by OIDC spec + ldapUuidKey: "sub", + }, + ]); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); @@ -328,10 +251,7 @@ test.describe("Configure LDAP Provider", () => { // Wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.pingFederateLogin( - "user1", - process.env.RHBK_LDAP_USER_PASSWORD!, - ); + const login = await common.pingFederateLogin("user1", process.env.RHBK_LDAP_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); diff --git a/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts b/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts index 63d6eaa68a..1b359e4e54 100644 --- a/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts @@ -1,9 +1,10 @@ import { test, expect, Page, BrowserContext } from "@support/coverage/test"; + +import { KeycloakHelper } from "../../utils/authentication-providers/keycloak-helper"; import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; import { Common, setupBrowser } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; -import { KeycloakHelper } from "../../utils/authentication-providers/keycloak-helper"; import { NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE } from "../../utils/constants"; +import { UIhelper } from "../../utils/ui-helper"; let page: Page; let context: BrowserContext; @@ -99,24 +100,12 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { await deployment.addSecretData("BASE_URL", backstageUrl); await deployment.addSecretData("BASE_BACKEND_URL", backstageBackendUrl); } - await deployment.addSecretData( - "DEFAULT_USER_PASSWORD", - process.env.DEFAULT_USER_PASSWORD!, - ); - await deployment.addSecretData( - "DEFAULT_USER_PASSWORD_2", - process.env.DEFAULT_USER_PASSWORD_2!, - ); + await deployment.addSecretData("DEFAULT_USER_PASSWORD", process.env.DEFAULT_USER_PASSWORD!); + await deployment.addSecretData("DEFAULT_USER_PASSWORD_2", process.env.DEFAULT_USER_PASSWORD_2!); await deployment.addSecretData("RHBK_BASE_URL", process.env.RHBK_BASE_URL!); await deployment.addSecretData("RHBK_REALM", process.env.RHBK_REALM!); - await deployment.addSecretData( - "RHBK_CLIENT_ID", - process.env.RHBK_CLIENT_ID!, - ); - await deployment.addSecretData( - "RHBK_CLIENT_SECRET", - process.env.RHBK_CLIENT_SECRET!, - ); + await deployment.addSecretData("RHBK_CLIENT_ID", process.env.RHBK_CLIENT_ID!); + await deployment.addSecretData("RHBK_CLIENT_SECRET", process.env.RHBK_CLIENT_SECRET!); await deployment.addSecretData( "AUTH_PROVIDERS_GH_ORG_CLIENT_ID", @@ -146,16 +135,11 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { test.beforeEach(() => { test.info().setTimeout(600 * 1000); - console.log( - `Running test case ${test.info().title} - Attempt #${test.info().retry}`, - ); + console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); }); test("Login with OIDC default resolver", async () => { - const login = await common.keycloakLogin( - "zeus", - process.env.DEFAULT_USER_PASSWORD!, - ); + const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -173,10 +157,7 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { test("Login with OIDC oidcSubClaimMatchingKeycloakUserId resolver", async () => { await deployment.enableOIDCLoginWithIngestion(); - await deployment.setOIDCResolver( - "oidcSubClaimMatchingKeycloakUserId", - false, - ); + await deployment.setOIDCResolver("oidcSubClaimMatchingKeycloakUserId", false); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); @@ -185,10 +166,7 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { // wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.keycloakLogin( - "zeus", - process.env.DEFAULT_USER_PASSWORD!, - ); + const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -197,10 +175,7 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }); test("Login with OIDC emailMatchingUserEntityProfileEmail resolver", async () => { - await deployment.setOIDCResolver( - "emailMatchingUserEntityProfileEmail", - false, - ); + await deployment.setOIDCResolver("emailMatchingUserEntityProfileEmail", false); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); @@ -209,10 +184,7 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { // wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.keycloakLogin( - "zeus", - process.env.DEFAULT_USER_PASSWORD!, - ); + const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -221,10 +193,7 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }); test("Login with OIDC emailLocalPartMatchingUserEntityName resolver", async () => { - await deployment.setOIDCResolver( - "emailLocalPartMatchingUserEntityName", - false, - ); + await deployment.setOIDCResolver("emailLocalPartMatchingUserEntityName", false); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); @@ -233,34 +202,23 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { // wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.keycloakLogin( - "zeus", - process.env.DEFAULT_USER_PASSWORD!, - ); + const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); await uiHelper.verifyHeading("Zeus Giove"); await common.signOut(); - const login2 = await common.keycloakLogin( - "atena", - process.env.DEFAULT_USER_PASSWORD!, - ); + const login2 = await common.keycloakLogin("atena", process.env.DEFAULT_USER_PASSWORD!); expect(login2).toBe("Login successful"); - await uiHelper.verifyAlertErrorMessage( - NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE, - ); + await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); await keycloakHelper.initialize(); await keycloakHelper.clearUserSessions("atena"); }); test("Login with OIDC emailLocalPartMatchingUserEntityName with dangerouslyAllowSignInWithoutUserInCatalog resolver", async () => { - await deployment.setOIDCResolver( - "emailLocalPartMatchingUserEntityName", - true, - ); + await deployment.setOIDCResolver("emailLocalPartMatchingUserEntityName", true); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); @@ -269,20 +227,14 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { // wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.keycloakLogin( - "zeus", - process.env.DEFAULT_USER_PASSWORD!, - ); + const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); await uiHelper.verifyHeading("Zeus Giove"); await common.signOut(); - const login2 = await common.keycloakLogin( - "atena", - process.env.DEFAULT_USER_PASSWORD!, - ); + const login2 = await common.keycloakLogin("atena", process.env.DEFAULT_USER_PASSWORD!); expect(login2).toBe("Login successful"); await uiHelper.goToSettingsPage(); await uiHelper.verifyHeading("Atena Minerva"); @@ -290,10 +242,7 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }); test("Login with OIDC preferredUsernameMatchingUserEntityName resolver", async () => { - await deployment.setOIDCResolver( - "preferredUsernameMatchingUserEntityName", - false, - ); + await deployment.setOIDCResolver("preferredUsernameMatchingUserEntityName", false); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); @@ -302,10 +251,7 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { // wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.keycloakLogin( - "atena", - process.env.DEFAULT_USER_PASSWORD!, - ); + const login = await common.keycloakLogin("atena", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -314,10 +260,7 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }); test(`Set sessionDuration and confirm in auth cookie duration has been set`, async () => { - deployment.setAppConfigProperty( - "auth.providers.oidc.production.sessionDuration", - "3days", - ); + deployment.setAppConfigProperty("auth.providers.oidc.production.sessionDuration", "3days"); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); @@ -326,18 +269,13 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { // wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.keycloakLogin( - "zeus", - process.env.DEFAULT_USER_PASSWORD!, - ); + const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); await page.reload(); const cookies = await context.cookies(); - const authCookie = cookies.find( - (cookie) => cookie.name === "oidc-refresh-token", - ); + const authCookie = cookies.find((cookie) => cookie.name === "oidc-refresh-token"); expect(authCookie).toBeDefined(); // expected duration of 3 days in ms @@ -365,41 +303,25 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { "Zeus Giove", ]), ).toBe(true); - expect( - await deployment.checkGroupIsIngestedInCatalog([ - "admins", - "goddesses", - "gods", - ]), - ).toBe(true); - expect(await deployment.checkUserIsInGroup("admin", "admins")).toBe(true); - expect(await deployment.checkUserIsInGroup("zeus", "admins")).toBe(true); - expect(await deployment.checkUserIsInGroup("atena", "goddesses")).toBe( + expect(await deployment.checkGroupIsIngestedInCatalog(["admins", "goddesses", "gods"])).toBe( true, ); + expect(await deployment.checkUserIsInGroup("admin", "admins")).toBe(true); + expect(await deployment.checkUserIsInGroup("zeus", "admins")).toBe(true); + expect(await deployment.checkUserIsInGroup("atena", "goddesses")).toBe(true); expect(await deployment.checkUserIsInGroup("tyke", "goddesses")).toBe(true); expect(await deployment.checkUserIsInGroup("elio", "gods")).toBe(true); expect(await deployment.checkUserIsInGroup("zeus", "gods")).toBe(true); expect(await deployment.checkGroupIsChildOfGroup("gods", "all")).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("goddesses", "all")).toBe( - true, - ); - expect(await deployment.checkGroupIsParentOfGroup("all", "gods")).toBe( - true, - ); - expect(await deployment.checkGroupIsParentOfGroup("all", "goddesses")).toBe( - true, - ); + expect(await deployment.checkGroupIsChildOfGroup("goddesses", "all")).toBe(true); + expect(await deployment.checkGroupIsParentOfGroup("all", "gods")).toBe(true); + expect(await deployment.checkGroupIsParentOfGroup("all", "goddesses")).toBe(true); }); test(`Ingestion of users and groups with invalid characters: check sanitize[User/Group]NameTransformer`, async () => { - expect( - await deployment.checkUserIsIngestedInCatalog(["Invalid Username"]), - ).toBe(true); - expect( - await deployment.checkGroupIsIngestedInCatalog(["invalid@groupname"]), - ).toBe(true); + expect(await deployment.checkUserIsIngestedInCatalog(["Invalid Username"])).toBe(true); + expect(await deployment.checkGroupIsIngestedInCatalog(["invalid@groupname"])).toBe(true); }); test("Ensure Guest login is disabled when setting environment to production", async () => { @@ -413,10 +335,7 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }); test("Login with OIDC as primary sign in provider and GitHub auth as secondary", async () => { - const oidcLogin = await common.keycloakLogin( - "zeus", - process.env.DEFAULT_USER_PASSWORD!, - ); + const oidcLogin = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(oidcLogin).toBe("Login successful"); @@ -430,8 +349,7 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { production: { clientId: "${AUTH_PROVIDERS_GH_ORG_CLIENT_ID}", clientSecret: "${AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET}", - callbackUrl: - "${BASE_URL:-http://localhost:7007}/api/auth/github/handler/frame", + callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/github/handler/frame", }, }); @@ -469,10 +387,7 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { deployment.setAppConfigProperty("auth.autologout.enabled", "true"); // minimum allowed value is 0.5 minutes deployment.setAppConfigProperty("auth.autologout.idleTimeoutMinutes", 0.5); - deployment.setAppConfigProperty( - "auth.autologout.promptBeforeIdleSeconds", - 5, - ); + deployment.setAppConfigProperty("auth.autologout.promptBeforeIdleSeconds", 5); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); @@ -481,17 +396,10 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { // wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.keycloakLogin( - "zeus", - process.env.DEFAULT_USER_PASSWORD!, - ); + const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); - await uiHelper.verifyTextVisible( - "Logging out due to inactivity", - false, - 60000, - ); + await uiHelper.verifyTextVisible("Logging out due to inactivity", false, 60000); await expect(page.getByText("Logging out due to inactivity")).toBeHidden({ timeout: 30000, }); @@ -499,9 +407,7 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { await page.reload(); const cookies = await context.cookies(); - const authCookie = cookies.find( - (cookie) => cookie.name === "oidc-refresh-token", - ); + const authCookie = cookies.find((cookie) => cookie.name === "oidc-refresh-token"); expect(authCookie).toBeUndefined(); }); @@ -509,10 +415,7 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { deployment.setAppConfigProperty("auth.autologout.enabled", "true"); // minimum allowed value is 0.5 minutes deployment.setAppConfigProperty("auth.autologout.idleTimeoutMinutes", 0.5); - deployment.setAppConfigProperty( - "auth.autologout.promptBeforeIdleSeconds", - 5, - ); + deployment.setAppConfigProperty("auth.autologout.promptBeforeIdleSeconds", 5); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); @@ -521,10 +424,7 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { // wait for rhdh first sync and portal to be reachable await deployment.waitForSynced(); - const login = await common.keycloakLogin( - "zeus", - process.env.DEFAULT_USER_PASSWORD!, - ); + const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.clickButtonByText("Don't log me out", { timeout: 60000 }); diff --git a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts index 6d44ebb479..51f0ed6576 100644 --- a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts +++ b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts @@ -1,5 +1,5 @@ import { test } from "@support/coverage/test"; -import { UIhelper } from "../../utils/ui-helper"; + import { Common } from "../../utils/common"; import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; import { @@ -8,6 +8,7 @@ import { configurePostgresCredentials, clearDatabase, } from "../../utils/postgres-config"; +import { UIhelper } from "../../utils/ui-helper"; interface AzureDbConfig { name: string; @@ -43,9 +44,7 @@ test.describe("Verify TLS configuration with Azure Database for PostgreSQL healt ); // Validate certificates are available - const azureCerts = readCertificateFile( - process.env.AZURE_DB_CERTIFICATES_PATH, - ); + const azureCerts = readCertificateFile(process.env.AZURE_DB_CERTIFICATES_PATH); if (azureCerts === undefined || azureCerts === null || azureCerts === "") { throw new Error( "AZURE_DB_CERTIFICATES_PATH environment variable must be set and point to a valid certificate file", @@ -54,17 +53,13 @@ test.describe("Verify TLS configuration with Azure Database for PostgreSQL healt // Validate required environment variables if (!azureUser || !azurePassword) { - throw new Error( - "AZURE_DB_USER and AZURE_DB_PASSWORD environment variables must be set", - ); + throw new Error("AZURE_DB_USER and AZURE_DB_PASSWORD environment variables must be set"); } const kubeClient = new KubeClient(); // Create/update the postgres-crt secret with Azure certificates - console.log( - "Configuring Azure Database for PostgreSQL TLS certificates...", - ); + console.log("Configuring Azure Database for PostgreSQL TLS certificates..."); await configurePostgresCertificate(kubeClient, namespace, azureCerts); }); diff --git a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts index f63981a7e2..4e822b4c99 100644 --- a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts +++ b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts @@ -1,5 +1,5 @@ import { test } from "@support/coverage/test"; -import { UIhelper } from "../../utils/ui-helper"; + import { Common } from "../../utils/common"; import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; import { @@ -8,6 +8,7 @@ import { configurePostgresCredentials, clearDatabase, } from "../../utils/postgres-config"; +import { UIhelper } from "../../utils/ui-helper"; interface RdsConfig { name: string; @@ -52,9 +53,7 @@ test.describe("Verify TLS configuration with RDS PostgreSQL health check", () => // Validate required environment variables if (!rdsUser || !rdsPassword) { - throw new Error( - "RDS_USER and RDS_PASSWORD environment variables must be set", - ); + throw new Error("RDS_USER and RDS_PASSWORD environment variables must be set"); } const kubeClient = new KubeClient(); diff --git a/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-db.ts b/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-db.ts index bd4e8bb501..efccc37d54 100644 --- a/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-db.ts +++ b/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-db.ts @@ -29,9 +29,7 @@ export function normalizeDbHost(host: string): string { let portForwardRestarter: (() => Promise) | null = null; -export function setPortForwardRestarter( - fn: (() => Promise) | null, -): void { +export function setPortForwardRestarter(fn: (() => Promise) | null): void { portForwardRestarter = fn; } @@ -71,9 +69,7 @@ async function connectWithRetry(config: ClientConfig): Promise { ); } } else { - console.warn( - `Connection attempt ${attempt}/${maxRetries} failed, retrying...`, - ); + console.warn(`Connection attempt ${attempt}/${maxRetries} failed, retrying...`); } const delay = Math.min(2000 * attempt, 10000); @@ -86,11 +82,8 @@ async function connectWithRetry(config: ClientConfig): Promise { } } - const errorMsg = - lastError instanceof Error ? lastError.message : String(lastError); - throw new Error( - `Failed to connect after ${maxRetries} attempts: ${errorMsg}`, - ); + const errorMsg = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error(`Failed to connect after ${maxRetries} attempts: ${errorMsg}`); } const defaultConnectionOptions: Partial = { @@ -108,18 +101,12 @@ export function getSchemaModeEnv(): SchemaModeEnv { const dbAdminPassword = process.env.SCHEMA_MODE_DB_ADMIN_PASSWORD; const dbPassword = process.env.SCHEMA_MODE_DB_PASSWORD; - expect( - dbHost, - "SCHEMA_MODE_DB_HOST must be set for schema-mode tests", - ).toBeTruthy(); + expect(dbHost, "SCHEMA_MODE_DB_HOST must be set for schema-mode tests").toBeTruthy(); expect( dbAdminPassword, "SCHEMA_MODE_DB_ADMIN_PASSWORD must be set for schema-mode tests", ).toBeTruthy(); - expect( - dbPassword, - "SCHEMA_MODE_DB_PASSWORD must be set for schema-mode tests", - ).toBeTruthy(); + expect(dbPassword, "SCHEMA_MODE_DB_PASSWORD must be set for schema-mode tests").toBeTruthy(); return { dbHost: dbHost!, @@ -144,9 +131,7 @@ export function connectAdminClient( }); } -export async function cleanupOldPluginDatabases( - adminClient: Client, -): Promise { +export async function cleanupOldPluginDatabases(adminClient: Client): Promise { const oldDbsResult = await adminClient.query<{ datname: string }>(` SELECT datname FROM pg_database WHERE datistemplate = false @@ -158,9 +143,7 @@ export async function cleanupOldPluginDatabases( return; } - console.log( - `Found ${oldDbsResult.rows.length} old plugin databases, cleaning up...`, - ); + console.log(`Found ${oldDbsResult.rows.length} old plugin databases, cleaning up...`); for (const db of oldDbsResult.rows) { try { @@ -171,9 +154,7 @@ export async function cleanupOldPluginDatabases( [db.datname], ); - await adminClient.query( - `DROP DATABASE IF EXISTS ${quoteIdent(db.datname)}`, - ); + await adminClient.query(`DROP DATABASE IF EXISTS ${quoteIdent(db.datname)}`); console.log(` Dropped: ${db.datname}`); } catch (err) { console.warn( @@ -187,15 +168,12 @@ export async function setupSchemaModeDatabase( adminClient: Client, config: SchemaModeEnv, ): Promise { - const { dbHost, dbAdminUser, dbAdminPassword, dbName, dbUser, dbPassword } = - config; + const { dbHost, dbAdminUser, dbAdminPassword, dbName, dbUser, dbPassword } = config; if (dbName === "postgres") { console.log(`✓ Using default postgres database`); } else { - await adminClient - .query(`CREATE DATABASE ${quoteIdent(dbName)}`) - .catch(() => {}); + await adminClient.query(`CREATE DATABASE ${quoteIdent(dbName)}`).catch(() => {}); console.log(`✓ Created/verified test database: ${dbName}`); } @@ -250,15 +228,9 @@ export async function setupSchemaModeDatabase( }); try { - await dbClient.query( - `GRANT CREATE ON DATABASE ${quoteIdent(dbName)} TO ${quoteIdent(dbUser)}`, - ); - await dbClient.query( - `GRANT USAGE ON SCHEMA public TO ${quoteIdent(dbUser)}`, - ); - await dbClient.query( - `GRANT CREATE ON SCHEMA public TO ${quoteIdent(dbUser)}`, - ); + await dbClient.query(`GRANT CREATE ON DATABASE ${quoteIdent(dbName)} TO ${quoteIdent(dbUser)}`); + await dbClient.query(`GRANT USAGE ON SCHEMA public TO ${quoteIdent(dbUser)}`); + await dbClient.query(`GRANT CREATE ON SCHEMA public TO ${quoteIdent(dbUser)}`); await dbClient.query( `GRANT ALL PRIVILEGES ON DATABASE ${quoteIdent(dbName)} TO ${quoteIdent(dbUser)}`, ); diff --git a/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-setup.ts b/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-setup.ts index b9a6ad6326..a68d79259a 100644 --- a/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-setup.ts +++ b/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-setup.ts @@ -4,6 +4,7 @@ */ import * as yaml from "js-yaml"; + import { KubeClient } from "../../utils/kube-client"; import { getSchemaModeEnv, @@ -39,11 +40,7 @@ export class SchemaModeTestSetup { private env: ReturnType; private kubeClient: KubeClient; - constructor( - namespace: string, - releaseName: string, - installMethod: "helm" | "operator", - ) { + constructor(namespace: string, releaseName: string, installMethod: "helm" | "operator") { this.namespace = namespace; this.releaseName = releaseName; this.installMethod = installMethod; @@ -90,11 +87,7 @@ export class SchemaModeTestSetup { private resolveRhdhPostgresHost(): string { const pfNamespace = process.env.SCHEMA_MODE_PORT_FORWARD_NAMESPACE; - if ( - pfNamespace !== undefined && - pfNamespace !== "" && - pfNamespace !== this.namespace - ) { + if (pfNamespace !== undefined && pfNamespace !== "" && pfNamespace !== this.namespace) { return `postgress-external-db-primary.${pfNamespace}.svc.cluster.local`; } @@ -126,12 +119,8 @@ export class SchemaModeTestSetup { metadata: { name: secretName }, data: { password: Buffer.from(this.env.dbPassword).toString("base64"), - "postgres-password": Buffer.from(this.env.dbPassword).toString( - "base64", - ), - POSTGRES_PASSWORD: Buffer.from(this.env.dbPassword).toString( - "base64", - ), + "postgres-password": Buffer.from(this.env.dbPassword).toString("base64"), + POSTGRES_PASSWORD: Buffer.from(this.env.dbPassword).toString("base64"), POSTGRES_DB: Buffer.from(this.env.dbName).toString("base64"), POSTGRES_USER: Buffer.from(this.env.dbUser).toString("base64"), POSTGRES_HOST: Buffer.from(rhdhPostgresHost).toString("base64"), @@ -169,13 +158,8 @@ export class SchemaModeTestSetup { break; } catch (restartError) { if (attempt === maxRestartAttempts) throw restartError; - const msg = - restartError instanceof Error - ? restartError.message - : String(restartError); - console.warn( - `Restart attempt ${attempt} failed (${msg}), retrying in 30s...`, - ); + const msg = restartError instanceof Error ? restartError.message : String(restartError); + console.warn(`Restart attempt ${attempt} failed (${msg}), retrying in 30s...`); await new Promise((resolve) => { setTimeout(() => { resolve(); @@ -185,21 +169,14 @@ export class SchemaModeTestSetup { } } - private async ensureDeploymentEnvVars( - deploymentName: string, - secretName: string, - ): Promise { + private async ensureDeploymentEnvVars(deploymentName: string, secretName: string): Promise { const deployment = await this.kubeClient.appsApi.readNamespacedDeployment( deploymentName, this.namespace, ); const containers = deployment.body.spec?.template?.spec?.containers ?? []; - const backstageContainer = containers.find( - (c) => c.name === "backstage-backend", - ); - const backstageIdx = containers.findIndex( - (c) => c.name === "backstage-backend", - ); + const backstageContainer = containers.find((c) => c.name === "backstage-backend"); + const backstageIdx = containers.findIndex((c) => c.name === "backstage-backend"); if (backstageContainer === undefined) { console.warn("backstage-backend container not found in deployment"); @@ -212,9 +189,7 @@ export class SchemaModeTestSetup { "POSTGRES_USER", "POSTGRES_PASSWORD", ]; - const missingVars = requiredVars.filter( - (v) => !existingEnv.some((e) => e.name === v), - ); + const missingVars = requiredVars.filter((v) => !existingEnv.some((e) => e.name === v)); if (missingVars.length === 0) { console.log("POSTGRES_* env vars already present in deployment"); @@ -224,10 +199,7 @@ export class SchemaModeTestSetup { console.log(`Adding env vars to deployment: ${missingVars.join(", ")}`); const patch: { op: string; path: string; value?: unknown }[] = []; - if ( - backstageContainer.env === undefined || - backstageContainer.env.length === 0 - ) { + if (backstageContainer.env === undefined || backstageContainer.env.length === 0) { patch.push({ op: "add", path: `/spec/template/spec/containers/${backstageIdx}/env`, @@ -264,16 +236,11 @@ export class SchemaModeTestSetup { } private async updateAppConfigForSchemaMode(): Promise { - const configMapName = await this.kubeClient.findAppConfigMap( - this.namespace, - ); + const configMapName = await this.kubeClient.findAppConfigMap(this.namespace); let configMapResponse; try { - configMapResponse = await this.kubeClient.getConfigMap( - configMapName, - this.namespace, - ); + configMapResponse = await this.kubeClient.getConfigMap(configMapName, this.namespace); } catch { throw new Error( `ConfigMap '${configMapName}' not found in namespace '${this.namespace}'. ` + @@ -282,18 +249,10 @@ export class SchemaModeTestSetup { } const configMap = configMapResponse.body; - const configKey = Object.keys(configMap.data ?? {}).find((key) => - key.includes("app-config"), - ); + const configKey = Object.keys(configMap.data ?? {}).find((key) => key.includes("app-config")); - if ( - configKey === undefined || - configKey === "" || - configMap.data === undefined - ) { - throw new Error( - `Could not find app-config key in ConfigMap ${configMapName}`, - ); + if (configKey === undefined || configKey === "" || configMap.data === undefined) { + throw new Error(`Could not find app-config key in ConfigMap ${configMapName}`); } const appConfig = parseAppConfigYaml(yaml.load(configMap.data[configKey])); @@ -340,21 +299,17 @@ export class SchemaModeTestSetup { const routeNames = this.installMethod === "operator" ? [`backstage-${this.releaseName}`, `${this.releaseName}-developer-hub`] - : [ - `${this.releaseName}-developer-hub`, - `backstage-${this.releaseName}`, - ]; + : [`${this.releaseName}-developer-hub`, `backstage-${this.releaseName}`]; for (const routeName of routeNames) { try { - const route = - (await this.kubeClient.customObjectsApi.getNamespacedCustomObject( - "route.openshift.io", - "v1", - this.namespace, - "routes", - routeName, - )) as { body?: { spec?: { host?: string } } }; + const route = (await this.kubeClient.customObjectsApi.getNamespacedCustomObject( + "route.openshift.io", + "v1", + this.namespace, + "routes", + routeName, + )) as { body?: { spec?: { host?: string } } }; const routeHost = route.body?.spec?.host; if (routeHost !== undefined && routeHost !== "") { @@ -392,9 +347,7 @@ export class SchemaModeTestSetup { const hasCreateDb = result.rows[0].rolcreatedb; if (!hasCreateDb) { - console.log( - `Database user "${this.env.dbUser}" has restricted permissions (NOCREATEDB)`, - ); + console.log(`Database user "${this.env.dbUser}" has restricted permissions (NOCREATEDB)`); return true; } console.warn(`Database user "${this.env.dbUser}" has CREATEDB privilege`); diff --git a/e2e-tests/playwright/e2e/plugin-division-mode-schema/verify-schema-mode.spec.ts b/e2e-tests/playwright/e2e/plugin-division-mode-schema/verify-schema-mode.spec.ts index d98214e266..50698c9eb5 100644 --- a/e2e-tests/playwright/e2e/plugin-division-mode-schema/verify-schema-mode.spec.ts +++ b/e2e-tests/playwright/e2e/plugin-division-mode-schema/verify-schema-mode.spec.ts @@ -7,8 +7,10 @@ * Tests are opt-in - they skip when SCHEMA_MODE_* environment variables are not set. */ -import { test, expect } from "@support/coverage/test"; import { ChildProcessWithoutNullStreams, spawn } from "child_process"; + +import { test, expect } from "@support/coverage/test"; + import { Common } from "../../utils/common"; import { KubeClient } from "../../utils/kube-client"; import { setPortForwardRestarter } from "./schema-mode-db"; @@ -23,13 +25,7 @@ function startPortForward( pfResource: string, ): Promise { return new Promise((resolve, reject) => { - const proc = spawn("oc", [ - "port-forward", - "-n", - pfNamespace, - pfResource, - "5432:5432", - ]); + const proc = spawn("oc", ["port-forward", "-n", pfNamespace, pfResource, "5432:5432"]); const timeout = setTimeout(() => { proc.kill("SIGTERM"); @@ -58,9 +54,7 @@ function startPortForward( }); } -function killPortForward( - proc: ChildProcessWithoutNullStreams | undefined, -): Promise { +function killPortForward(proc: ChildProcessWithoutNullStreams | undefined): Promise { if (!proc || proc.exitCode !== null) return Promise.resolve(); return new Promise((resolve) => { @@ -85,8 +79,7 @@ function killPortForward( test.describe("Verify pluginDivisionMode: schema", () => { const namespace = process.env.NAME_SPACE_RUNTIME ?? "showcase-runtime"; const releaseName = process.env.RELEASE_NAME ?? "developer-hub"; - const installMethod = - process.env.INSTALL_METHOD === "operator" ? "operator" : "helm"; + const installMethod = process.env.INSTALL_METHOD === "operator" ? "operator" : "helm"; let portForwardProcess: ChildProcessWithoutNullStreams | undefined; let testSetup: SchemaModeTestSetup; @@ -127,9 +120,7 @@ test.describe("Verify pluginDivisionMode: schema", () => { ); if (hasPortForwardMeta) { - console.log( - `Starting port-forward: ${pfResource} in ${pfNamespace} -> localhost:5432`, - ); + console.log(`Starting port-forward: ${pfResource} in ${pfNamespace} -> localhost:5432`); portForwardProcess = await startPortForward(pfNamespace, pfResource); console.log("Port-forward established"); @@ -160,14 +151,11 @@ test.describe("Verify pluginDivisionMode: schema", () => { }); test("Verify database user has restricted permissions", async () => { - const hasRestrictedPerms = - await testSetup.verifyRestrictedDatabasePermissions(); + const hasRestrictedPerms = await testSetup.verifyRestrictedDatabasePermissions(); expect(hasRestrictedPerms).toBe(true); }); - test("Verify RHDH is accessible with schema mode", async ({ - page, - }, testInfo) => { + test("Verify RHDH is accessible with schema mode", async ({ page }, testInfo) => { const kubeClient = new KubeClient(); const deploymentName = testSetup.getDeploymentName(); @@ -179,10 +167,7 @@ test.describe("Verify pluginDivisionMode: schema", () => { const readyReplicas = deployment.body.status?.readyReplicas ?? 0; if (readyReplicas < 1) { - testInfo.skip( - true, - "Deployment is not ready (cluster capacity or PVC issue)", - ); + testInfo.skip(true, "Deployment is not ready (cluster capacity or PVC issue)"); return; } } catch (error) { @@ -194,8 +179,6 @@ test.describe("Verify pluginDivisionMode: schema", () => { await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); - console.log( - "RHDH is accessible - plugins successfully created schemas in schema mode", - ); + console.log("RHDH is accessible - plugins successfully created schemas in schema mode"); }); }); diff --git a/e2e-tests/playwright/e2e/plugins/scaffolder-backend-module-annotator/annotator.spec.ts b/e2e-tests/playwright/e2e/plugins/scaffolder-backend-module-annotator/annotator.spec.ts index 0164896ebd..595c4d1778 100644 --- a/e2e-tests/playwright/e2e/plugins/scaffolder-backend-module-annotator/annotator.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/scaffolder-backend-module-annotator/annotator.spec.ts @@ -1,10 +1,11 @@ import { Page, test, expect } from "@support/coverage/test"; -import { UIhelper } from "../../../utils/ui-helper"; -import { Common, setupBrowser, teardownBrowser } from "../../../utils/common"; + import { CatalogImport } from "../../../support/pages/catalog-import"; -import { APIHelper } from "../../../utils/api-helper"; -import { GITHUB_API_ENDPOINTS } from "../../../utils/api-endpoints"; import { runAccessibilityTests } from "../../../utils/accessibility"; +import { GITHUB_API_ENDPOINTS } from "../../../utils/api-endpoints"; +import { APIHelper } from "../../../utils/api-helper"; +import { Common, setupBrowser, teardownBrowser } from "../../../utils/common"; +import { UIhelper } from "../../../utils/ui-helper"; let page: Page; @@ -28,10 +29,7 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { label: "some-label", annotation: "some-annotation", repo: `test-annotator-${Date.now()}`, - repoOwner: Buffer.from( - process.env.GITHUB_ORG ?? "amFudXMtcWU=", - "base64", - ).toString("utf8"), + repoOwner: Buffer.from(process.env.GITHUB_ORG ?? "amFudXMtcWU=", "base64").toString("utf8"), }; test.beforeAll(async ({ browser }, testInfo) => { @@ -72,16 +70,10 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.clickButton("Choose"); await uiHelper.fillTextInputByLabel("Name", reactAppDetails.componentName); - await uiHelper.fillTextInputByLabel( - "Description", - reactAppDetails.description, - ); + await uiHelper.fillTextInputByLabel("Description", reactAppDetails.description); await uiHelper.fillTextInputByLabel("Owner", reactAppDetails.owner); await uiHelper.fillTextInputByLabel("Label", reactAppDetails.label); - await uiHelper.fillTextInputByLabel( - "Annotation", - reactAppDetails.annotation, - ); + await uiHelper.fillTextInputByLabel("Annotation", reactAppDetails.annotation); await uiHelper.clickButton("Next"); await uiHelper.fillTextInputByLabel("Owner", reactAppDetails.repoOwner); @@ -89,29 +81,17 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.pressTab(); await uiHelper.clickButton("Review"); - await uiHelper.verifyRowInTableByUniqueText("Owner", [ - `group:${reactAppDetails.owner}`, - ]); - await uiHelper.verifyRowInTableByUniqueText("Name", [ - reactAppDetails.componentName, - ]); - await uiHelper.verifyRowInTableByUniqueText("Description", [ - reactAppDetails.description, - ]); - await uiHelper.verifyRowInTableByUniqueText("Label", [ - reactAppDetails.label, - ]); - await uiHelper.verifyRowInTableByUniqueText("Annotation", [ - reactAppDetails.annotation, - ]); + await uiHelper.verifyRowInTableByUniqueText("Owner", [`group:${reactAppDetails.owner}`]); + await uiHelper.verifyRowInTableByUniqueText("Name", [reactAppDetails.componentName]); + await uiHelper.verifyRowInTableByUniqueText("Description", [reactAppDetails.description]); + await uiHelper.verifyRowInTableByUniqueText("Label", [reactAppDetails.label]); + await uiHelper.verifyRowInTableByUniqueText("Annotation", [reactAppDetails.annotation]); await uiHelper.verifyRowInTableByUniqueText("Repository Location", [ `github.com?owner=${reactAppDetails.repoOwner}&repo=${reactAppDetails.repo}`, ]); await uiHelper.clickButton("Create"); - await expect( - page.getByRole("link", { name: "Open in catalog" }), - ).toBeVisible({ + await expect(page.getByRole("link", { name: "Open in catalog" })).toBeVisible({ timeout: 30_000, }); await uiHelper.clickLink("Open in catalog"); @@ -121,9 +101,7 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.openCatalogSidebar("Component"); await uiHelper.searchInputPlaceholder(reactAppDetails.componentName); - await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, [ - "website", - ]); + await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, ["website"]); await uiHelper.clickLink(reactAppDetails.componentName); await catalogImport.inspectEntityAndVerifyYaml( @@ -135,9 +113,7 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.openCatalogSidebar("Component"); await uiHelper.searchInputPlaceholder(reactAppDetails.componentName); - await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, [ - "website", - ]); + await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, ["website"]); await uiHelper.clickLink(reactAppDetails.componentName); await catalogImport.inspectEntityAndVerifyYaml( @@ -149,14 +125,10 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.openCatalogSidebar("Component"); await uiHelper.searchInputPlaceholder(reactAppDetails.componentName); - await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, [ - "website", - ]); + await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, ["website"]); await uiHelper.clickLink(reactAppDetails.componentName); - await catalogImport.inspectEntityAndVerifyYaml( - `backstage.io/template-version: 0.0.1`, - ); + await catalogImport.inspectEntityAndVerifyYaml(`backstage.io/template-version: 0.0.1`); }); test("Verify template version annotation is present on the template", async () => { @@ -164,23 +136,16 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.selectMuiBox("Kind", "Template"); await uiHelper.searchInputPlaceholder("Create React App Template\n"); - await uiHelper.verifyRowInTableByUniqueText("Create React App Template", [ - "website", - ]); + await uiHelper.verifyRowInTableByUniqueText("Create React App Template", ["website"]); await uiHelper.clickLink("Create React App Template"); - await catalogImport.inspectEntityAndVerifyYaml( - `backstage.io/template-version: 0.0.1`, - ); + await catalogImport.inspectEntityAndVerifyYaml(`backstage.io/template-version: 0.0.1`); }); test.afterAll(async ({}, testInfo) => { await APIHelper.githubRequest( "DELETE", - GITHUB_API_ENDPOINTS.deleteRepo( - reactAppDetails.repoOwner, - reactAppDetails.repo, - ), + GITHUB_API_ENDPOINTS.deleteRepo(reactAppDetails.repoOwner, reactAppDetails.repo), ); await teardownBrowser(page, testInfo); }); diff --git a/e2e-tests/playwright/e2e/plugins/scaffolder-relation-processor/scaffolder-relation-processor.spec.ts b/e2e-tests/playwright/e2e/plugins/scaffolder-relation-processor/scaffolder-relation-processor.spec.ts index 2c50235e8c..ee741ce7cc 100644 --- a/e2e-tests/playwright/e2e/plugins/scaffolder-relation-processor/scaffolder-relation-processor.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/scaffolder-relation-processor/scaffolder-relation-processor.spec.ts @@ -1,9 +1,10 @@ import { expect, Page, test } from "@support/coverage/test"; -import { UIhelper } from "../../../utils/ui-helper"; -import { Common, setupBrowser, teardownBrowser } from "../../../utils/common"; + import { CatalogImport } from "../../../support/pages/catalog-import"; -import { APIHelper } from "../../../utils/api-helper"; import { GITHUB_API_ENDPOINTS } from "../../../utils/api-endpoints"; +import { APIHelper } from "../../../utils/api-helper"; +import { Common, setupBrowser, teardownBrowser } from "../../../utils/common"; +import { UIhelper } from "../../../utils/ui-helper"; let page: Page; @@ -28,10 +29,7 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { label: "test-label", annotation: "test-annotation", repo: `test-relation-${Date.now()}`, - repoOwner: Buffer.from( - process.env.GITHUB_ORG ?? "amFudXMtcWU=", - "base64", - ).toString("utf8"), + repoOwner: Buffer.from(process.env.GITHUB_ORG ?? "amFudXMtcWU=", "base64").toString("utf8"), }; test.beforeAll(async ({ browser }, testInfo) => { @@ -69,16 +67,10 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { await uiHelper.clickButton("Choose"); await uiHelper.fillTextInputByLabel("Name", reactAppDetails.componentName); - await uiHelper.fillTextInputByLabel( - "Description", - reactAppDetails.description, - ); + await uiHelper.fillTextInputByLabel("Description", reactAppDetails.description); await uiHelper.fillTextInputByLabel("Owner", reactAppDetails.owner); await uiHelper.fillTextInputByLabel("Label", reactAppDetails.label); - await uiHelper.fillTextInputByLabel( - "Annotation", - reactAppDetails.annotation, - ); + await uiHelper.fillTextInputByLabel("Annotation", reactAppDetails.annotation); await uiHelper.clickButton("Next"); await uiHelper.fillTextInputByLabel("Owner", reactAppDetails.repoOwner); @@ -88,9 +80,7 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { await uiHelper.clickButton("Create"); // Wait for the scaffolder task to complete and the link to appear - await expect( - page.getByRole("link", { name: "Open in catalog" }), - ).toBeVisible({ + await expect(page.getByRole("link", { name: "Open in catalog" })).toBeVisible({ timeout: 60000, }); await uiHelper.clickLink("Open in catalog"); @@ -124,15 +114,9 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { const labelSelector = 'g[data-testid="label"]'; const nodeSelector = 'g[data-testid="node"]'; - await uiHelper.verifyTextInSelector( - labelSelector, - "scaffolderOf / scaffoldedFrom", - ); + await uiHelper.verifyTextInSelector(labelSelector, "scaffolderOf / scaffoldedFrom"); - await uiHelper.verifyPartialTextInSelector( - nodeSelector, - reactAppDetails.componentPartialName, - ); + await uiHelper.verifyPartialTextInSelector(nodeSelector, reactAppDetails.componentPartialName); }); test("Verify scaffolderOf relation on the template", async () => { @@ -140,9 +124,7 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { await uiHelper.selectMuiBox("Kind", "Template"); await uiHelper.searchInputPlaceholder("Create React App Template\n"); - await uiHelper.verifyRowInTableByUniqueText("Create React App Template", [ - "website", - ]); + await uiHelper.verifyRowInTableByUniqueText("Create React App Template", ["website"]); await uiHelper.clickLink("Create React App Template"); // Verify the scaffolderOf relation in the YAML view @@ -158,10 +140,7 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { test.afterAll(async ({}, testInfo) => { await APIHelper.githubRequest( "DELETE", - GITHUB_API_ENDPOINTS.deleteRepo( - reactAppDetails.repoOwner, - reactAppDetails.repo, - ), + GITHUB_API_ENDPOINTS.deleteRepo(reactAppDetails.repoOwner, reactAppDetails.repo), ); await teardownBrowser(page, testInfo); }); diff --git a/e2e-tests/playwright/e2e/plugins/user-settings-info-card.spec.ts b/e2e-tests/playwright/e2e/plugins/user-settings-info-card.spec.ts index b9143be3f0..e3a11ffc93 100644 --- a/e2e-tests/playwright/e2e/plugins/user-settings-info-card.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/user-settings-info-card.spec.ts @@ -1,49 +1,42 @@ import { test, expect } from "@support/coverage/test"; + import { Common } from "../../utils/common"; import { UIhelper } from "../../utils/ui-helper"; -test.describe( - "Test user settings info card", - { tag: "@layer3-equivalent" }, - () => { - test.beforeAll(() => { - test.info().annotations.push({ - type: "component", - description: "plugins", - }); +test.describe("Test user settings info card", { tag: "@layer3-equivalent" }, () => { + test.beforeAll(() => { + test.info().annotations.push({ + type: "component", + description: "plugins", }); + }); - let uiHelper: UIhelper; + let uiHelper: UIhelper; - test.beforeEach(async ({ page }) => { - const common = new Common(page); - await common.loginAsGuest(); + test.beforeEach(async ({ page }) => { + const common = new Common(page); + await common.loginAsGuest(); - uiHelper = new UIhelper(page); - }); + uiHelper = new UIhelper(page); + }); - test("Check if customized build info is rendered", async ({ page }) => { - await uiHelper.openSidebar("Home"); - await page.getByText("Guest").click(); - await page.getByRole("menuitem", { name: "Settings" }).click(); + test("Check if customized build info is rendered", async ({ page }) => { + await uiHelper.openSidebar("Home"); + await page.getByText("Guest").click(); + await page.getByRole("menuitem", { name: "Settings" }).click(); - // Verify card header is visible - await expect(page.getByText("RHDH Build info")).toBeVisible(); + // Verify card header is visible + await expect(page.getByText("RHDH Build info")).toBeVisible(); - // Verify initial card content using text content - await expect(page.getByText("TechDocs builder: local")).toBeVisible(); - await expect( - page.getByText("Authentication provider: Github"), - ).toBeVisible(); + // Verify initial card content using text content + await expect(page.getByText("TechDocs builder: local")).toBeVisible(); + await expect(page.getByText("Authentication provider: Github")).toBeVisible(); - await page.getByTitle("Show more").click(); + await page.getByTitle("Show more").click(); - // Verify expanded card content shows RBAC status - await expect(page.getByText("TechDocs builder: local")).toBeVisible(); - await expect( - page.getByText("Authentication provider: Github"), - ).toBeVisible(); - await expect(page.getByText("RBAC: disabled")).toBeVisible(); - }); - }, -); + // Verify expanded card content shows RBAC status + await expect(page.getByText("TechDocs builder: local")).toBeVisible(); + await expect(page.getByText("Authentication provider: Github")).toBeVisible(); + await expect(page.getByText("RBAC: disabled")).toBeVisible(); + }); +}); diff --git a/e2e-tests/playwright/support/api/github-structures.ts b/e2e-tests/playwright/support/api/github-structures.ts index 64b8dddf94..d41631ac5d 100644 --- a/e2e-tests/playwright/support/api/github-structures.ts +++ b/e2e-tests/playwright/support/api/github-structures.ts @@ -2,19 +2,13 @@ export class GetOrganizationResponse { reposUrl: string; constructor(response: unknown) { - if ( - typeof response !== "object" || - response === null || - !("repos_url" in response) - ) { + if (typeof response !== "object" || response === null || !("repos_url" in response)) { throw new Error("Invalid GitHub organization response"); } const reposUrl = (response as { repos_url: unknown }).repos_url; if (typeof reposUrl !== "string") { - throw new TypeError( - "Invalid GitHub organization response: missing repos_url", - ); + throw new TypeError("Invalid GitHub organization response: missing repos_url"); } this.reposUrl = reposUrl; diff --git a/e2e-tests/playwright/support/api/github.ts b/e2e-tests/playwright/support/api/github.ts index fcf3440d8a..cd5c604a83 100644 --- a/e2e-tests/playwright/support/api/github.ts +++ b/e2e-tests/playwright/support/api/github.ts @@ -1,20 +1,14 @@ -import { JANUS_ORG } from "../../utils/constants"; -import { APIHelper } from "../../utils/api-helper"; import { GITHUB_API_ENDPOINTS } from "../../utils/api-endpoints"; +import { APIHelper } from "../../utils/api-helper"; +import { JANUS_ORG } from "../../utils/constants"; // https://docs.github.com/en/rest?apiVersion=2022-11-28 export default class GithubApi { public getReposFromOrg(org = JANUS_ORG) { - return APIHelper.getGithubPaginatedRequest( - GITHUB_API_ENDPOINTS.orgRepos(org), - ); + return APIHelper.getGithubPaginatedRequest(GITHUB_API_ENDPOINTS.orgRepos(org)); } - public async fileExistsInRepo( - owner: string, - repo: string, - file: string, - ): Promise { + public async fileExistsInRepo(owner: string, repo: string, file: string): Promise { const resp = await APIHelper.githubRequest( "GET", `${GITHUB_API_ENDPOINTS.contents(owner, repo)}/${file}`, diff --git a/e2e-tests/playwright/support/api/rbac-api.ts b/e2e-tests/playwright/support/api/rbac-api.ts index 33dc8bc9d2..ed57fbb544 100644 --- a/e2e-tests/playwright/support/api/rbac-api.ts +++ b/e2e-tests/playwright/support/api/rbac-api.ts @@ -1,9 +1,5 @@ -import { - APIRequestContext, - APIResponse, - Page, - request, -} from "@playwright/test"; +import { APIRequestContext, APIResponse, Page, request } from "@playwright/test"; + import playwrightConfig from "../../../playwright.config"; import { Policy, Role } from "./rbac-api-structures"; import { RhdhAuthApiHack } from "./rhdh-auth-api-hack"; @@ -46,11 +42,7 @@ export default class RhdhRbacApi { return this.myContext.get(`roles/role/${role}`); } - public updateRole( - role: string, - oldRole: Role, - newRole: Role, - ): Promise { + public updateRole(role: string, oldRole: Role, newRole: Role): Promise { this.checkRoleFormat(role); return this.myContext.put(`roles/role/${role}`, { data: { oldRole, newRole }, @@ -125,9 +117,7 @@ export default class RhdhRbacApi { private checkRoleFormat(role: string) { if (!this.roleRegex.test(role)) { - throw new Error( - "roles passed to the Rbac api must have format like: default/admin", - ); + throw new Error("roles passed to the Rbac api must have format like: default/admin"); } } diff --git a/e2e-tests/playwright/support/page-objects/page-obj.ts b/e2e-tests/playwright/support/page-objects/page-obj.ts index 48f51e6aef..04563973db 100644 --- a/e2e-tests/playwright/support/page-objects/page-obj.ts +++ b/e2e-tests/playwright/support/page-objects/page-obj.ts @@ -1,10 +1,8 @@ /* oxlint-disable playwright/no-raw-locators -- Legacy CSS selector constants; prefer SemanticSelectors get*() methods */ import { Page, Locator } from "@playwright/test"; + +import { getTranslations, getCurrentLanguage } from "../../e2e/localization/locale"; import { SemanticSelectors } from "../selectors/semantic-selectors"; -import { - getTranslations, - getCurrentLanguage, -} from "../../e2e/localization/locale"; const t = getTranslations(); const lang = getCurrentLanguage(); @@ -124,8 +122,7 @@ export const KUBERNETES_COMPONENTS = { * Get pod logs label/button * @example KUBERNETES_COMPONENTS.getPodLogsButton(page).click() */ - getPodLogsButton: (page: Page): Locator => - page.locator('label[aria-label="get logs"]'), + getPodLogsButton: (page: Page): Locator => page.locator('label[aria-label="get logs"]'), /** * Get error/notification snackbar @@ -133,9 +130,7 @@ export const KUBERNETES_COMPONENTS = { * @example await expect(KUBERNETES_COMPONENTS.getNotification(page)).toContainText('Error') */ getNotification: (page: Page, message?: string | RegExp): Locator => - message === undefined - ? SemanticSelectors.alert(page) - : SemanticSelectors.alert(page, message), + message === undefined ? SemanticSelectors.alert(page) : SemanticSelectors.alert(page, message), }; /** @@ -159,8 +154,7 @@ export const BACKSTAGE_SHOWCASE_COMPONENTS = { * ✅ Already semantic, but wrapped for consistency * @example BACKSTAGE_SHOWCASE_COMPONENTS.getNextPageButton(page).click() */ - getNextPageButton: (page: Page): Locator => - page.getByRole("button", { name: "Next Page" }), + getNextPageButton: (page: Page): Locator => page.getByRole("button", { name: "Next Page" }), /** * Get previous page button @@ -173,23 +167,20 @@ export const BACKSTAGE_SHOWCASE_COMPONENTS = { * Get last page button * @example BACKSTAGE_SHOWCASE_COMPONENTS.getLastPageButton(page).click() */ - getLastPageButton: (page: Page): Locator => - page.getByRole("button", { name: "Last Page" }), + getLastPageButton: (page: Page): Locator => page.getByRole("button", { name: "Last Page" }), /** * Get first page button * @example BACKSTAGE_SHOWCASE_COMPONENTS.getFirstPageButton(page).click() */ - getFirstPageButton: (page: Page): Locator => - page.getByRole("button", { name: "First Page" }), + getFirstPageButton: (page: Page): Locator => page.getByRole("button", { name: "First Page" }), /** * Get table rows * ✅ Preferred over tableRows * @example const rows = BACKSTAGE_SHOWCASE_COMPONENTS.getTableRows(page) */ - getTableRows: (page: Page): Locator => - SemanticSelectors.table(page).locator("tbody tr"), + getTableRows: (page: Page): Locator => SemanticSelectors.table(page).locator("tbody tr"), /** * Get specific table row by content @@ -212,8 +203,7 @@ export const SETTINGS_PAGE_COMPONENTS = { * Get user settings menu button * @example SETTINGS_PAGE_COMPONENTS.getUserSettingsMenu(page).click() */ - getUserSettingsMenu: (page: Page): Locator => - page.getByTestId("user-settings-menu"), + getUserSettingsMenu: (page: Page): Locator => page.getByTestId("user-settings-menu"), /** * Get sign out menu item @@ -235,8 +225,7 @@ export const ROLES_PAGE_COMPONENTS = { * Get edit role button * @example ROLES_PAGE_COMPONENTS.getEditRoleButton(page, 'admin').click() */ - getEditRoleButton: (page: Page, name: string): Locator => - page.getByTestId(`edit-role-${name}`), + getEditRoleButton: (page: Page, name: string): Locator => page.getByTestId(`edit-role-${name}`), /** * Get delete role button @@ -258,8 +247,7 @@ export const DELETE_ROLE_COMPONENTS = { * Get role name confirmation input * @example DELETE_ROLE_COMPONENTS.getRoleNameInput(page).fill('role-name') */ - getRoleNameInput: (page: Page): Locator => - page.locator('input[name="delete-role"]'), + getRoleNameInput: (page: Page): Locator => page.locator('input[name="delete-role"]'), }; /** @@ -274,13 +262,11 @@ export const ROLE_OVERVIEW_COMPONENTS_TEST_ID = { * Get update policies button * @example ROLE_OVERVIEW_COMPONENTS_TEST_ID.getUpdatePoliciesButton(page).click() */ - getUpdatePoliciesButton: (page: Page): Locator => - page.getByTestId("update-policies"), + getUpdatePoliciesButton: (page: Page): Locator => page.getByTestId("update-policies"), /** * Get update members button * @example ROLE_OVERVIEW_COMPONENTS_TEST_ID.getUpdateMembersButton(page).click() */ - getUpdateMembersButton: (page: Page): Locator => - page.getByTestId("update-members"), + getUpdateMembersButton: (page: Page): Locator => page.getByTestId("update-members"), }; diff --git a/e2e-tests/playwright/support/page-objects/ui-locators.ts b/e2e-tests/playwright/support/page-objects/ui-locators.ts index 4a5ab83f7f..93806faee8 100644 --- a/e2e-tests/playwright/support/page-objects/ui-locators.ts +++ b/e2e-tests/playwright/support/page-objects/ui-locators.ts @@ -1,11 +1,9 @@ /* oxlint-disable playwright/no-raw-locators -- legacy card/table region selectors pending SemanticSelectors migration */ import { Locator, Page } from "@playwright/test"; + import { SemanticSelectors } from "../selectors/semantic-selectors"; -export function getCardByHeading( - page: Page, - heading: string | RegExp, -): Locator { +export function getCardByHeading(page: Page, heading: string | RegExp): Locator { return page .locator('[role="region"], article, section') .filter({ diff --git a/e2e-tests/playwright/support/pages/backstage-showcase.ts b/e2e-tests/playwright/support/pages/backstage-showcase.ts index 2ccb588348..7a047dc571 100644 --- a/e2e-tests/playwright/support/pages/backstage-showcase.ts +++ b/e2e-tests/playwright/support/pages/backstage-showcase.ts @@ -1,6 +1,7 @@ import { Page, expect } from "@playwright/test"; -import { UIhelper } from "../../utils/ui-helper"; + import { APIHelper } from "../../utils/api-helper"; +import { UIhelper } from "../../utils/ui-helper"; import { BACKSTAGE_SHOWCASE_COMPONENTS } from "../page-objects/page-obj"; export class BackstageShowcase { @@ -21,19 +22,14 @@ export class BackstageShowcase { } async clickPreviousPage() { - await BACKSTAGE_SHOWCASE_COMPONENTS.getPreviousPageButton( - this.page, - ).click(); + await BACKSTAGE_SHOWCASE_COMPONENTS.getPreviousPageButton(this.page).click(); } async clickLastPage() { await BACKSTAGE_SHOWCASE_COMPONENTS.getLastPageButton(this.page).click(); } - async verifyPRRowsPerPage( - rows: number, - allPRs: { title: string; number: string }[], - ) { + async verifyPRRowsPerPage(rows: number, allPRs: { title: string; number: string }[]) { await this.selectRowsPerPage(rows); await this.uiHelper.verifyText(allPRs[rows - 1].title, false); await this.uiHelper.verifyLink(allPRs[rows].number, { @@ -56,16 +52,11 @@ export class BackstageShowcase { } async verifyAboutCardIsDisplayed() { - const url = - "https://github.com/redhat-developer/rhdh/tree/main/catalog-entities/components/"; + const url = "https://github.com/redhat-developer/rhdh/tree/main/catalog-entities/components/"; await expect(this.page.locator(`a[href="${url}"]`)).toBeVisible(); } - async verifyPRRows( - allPRs: { title: string }[], - startRow: number, - lastRow: number, - ) { + async verifyPRRows(allPRs: { title: string }[], startRow: number, lastRow: number) { for (let i = startRow; i < lastRow; i++) { await this.uiHelper.verifyRowsInTable([allPRs[i].title], false); } diff --git a/e2e-tests/playwright/support/pages/catalog-import.ts b/e2e-tests/playwright/support/pages/catalog-import.ts index e03742a346..228535037a 100644 --- a/e2e-tests/playwright/support/pages/catalog-import.ts +++ b/e2e-tests/playwright/support/pages/catalog-import.ts @@ -1,10 +1,8 @@ import { Page, expect } from "@playwright/test"; + +import { getTranslations, getCurrentLanguage } from "../../e2e/localization/locale"; import { UIhelper } from "../../utils/ui-helper"; import { CATALOG_IMPORT_COMPONENTS } from "../page-objects/page-obj"; -import { - getTranslations, - getCurrentLanguage, -} from "../../e2e/localization/locale"; const t = getTranslations(); const lang = getCurrentLanguage(); @@ -42,9 +40,7 @@ export class CatalogImport { * @returns boolean indicating if the component is already registered */ isComponentAlreadyRegistered(): Promise { - return this.uiHelper.isBtnVisible( - t["catalog-import"][lang]["stepReviewLocation.refresh"], - ); + return this.uiHelper.isBtnVisible(t["catalog-import"][lang]["stepReviewLocation.refresh"]); } /** @@ -54,31 +50,21 @@ export class CatalogImport { * @param url - The component URL to register * @param clickViewComponent - Whether to click "View Component" after import */ - async registerExistingComponent( - url: string, - clickViewComponent: boolean = true, - ) { + async registerExistingComponent(url: string, clickViewComponent: boolean = true) { await this.analyzeAndWait(url); - const isComponentAlreadyRegistered = - await this.isComponentAlreadyRegistered(); + const isComponentAlreadyRegistered = await this.isComponentAlreadyRegistered(); if (isComponentAlreadyRegistered) { - await this.uiHelper.clickButton( - t["catalog-import"][lang]["stepReviewLocation.refresh"], - ); + await this.uiHelper.clickButton(t["catalog-import"][lang]["stepReviewLocation.refresh"]); expect( await this.uiHelper.isBtnVisible( t["catalog-import"][lang]["stepFinishImportLocation.backButtonText"], ), ).toBeTruthy(); } else { - await this.uiHelper.clickButton( - t["catalog-import"][lang]["stepReviewLocation.import"], - ); + await this.uiHelper.clickButton(t["catalog-import"][lang]["stepReviewLocation.import"]); if (clickViewComponent) { await this.uiHelper.clickButton( - t["catalog-import"][lang][ - "stepFinishImportLocation.locations.viewButtonText" - ], + t["catalog-import"][lang]["stepFinishImportLocation.locations.viewButtonText"], ); } } @@ -87,9 +73,7 @@ export class CatalogImport { async analyzeComponent(url: string) { await this.page.fill(CATALOG_IMPORT_COMPONENTS.componentURL, url); - await this.uiHelper.clickButton( - t["catalog-import"][lang]["stepInitAnalyzeUrl.nextButtonText"], - ); + await this.uiHelper.clickButton(t["catalog-import"][lang]["stepInitAnalyzeUrl.nextButtonText"]); } async inspectEntityAndVerifyYaml(text: string) { diff --git a/e2e-tests/playwright/support/pages/home-page.ts b/e2e-tests/playwright/support/pages/home-page.ts index 21339320e7..63987b7102 100644 --- a/e2e-tests/playwright/support/pages/home-page.ts +++ b/e2e-tests/playwright/support/pages/home-page.ts @@ -1,11 +1,9 @@ -/* oxlint-disable playwright/no-raw-locators -- MUI home page layout selectors */ -import { - HOME_PAGE_COMPONENTS, - SEARCH_OBJECTS_COMPONENTS, -} from "../page-objects/page-obj"; -import { UIhelper } from "../../utils/ui-helper"; import { Page, expect } from "@playwright/test"; +import { UIhelper } from "../../utils/ui-helper"; +/* oxlint-disable playwright/no-raw-locators -- MUI home page layout selectors */ +import { HOME_PAGE_COMPONENTS, SEARCH_OBJECTS_COMPONENTS } from "../page-objects/page-obj"; + export class HomePage { private page: Page; private uiHelper: UIhelper; @@ -22,22 +20,13 @@ export class HomePage { await this.uiHelper.verifyLink(text); } - async verifyQuickAccess( - section: string, - items: string | string[], - expand = false, - ) { - const sectionLocator = HOME_PAGE_COMPONENTS.getAccordion( - this.page, - section, - ); + async verifyQuickAccess(section: string, items: string | string[], expand = false) { + const sectionLocator = HOME_PAGE_COMPONENTS.getAccordion(this.page, section); await expect(sectionLocator).toBeVisible(); if (expand) { await sectionLocator.click(); - await expect( - sectionLocator.locator('[class*="MuiAccordionDetails-root"]'), - ).toBeVisible(); + await expect(sectionLocator.locator('[class*="MuiAccordionDetails-root"]')).toBeVisible(); } for (const item of Array.isArray(items) ? items : [items]) { diff --git a/e2e-tests/playwright/support/pages/rbac.ts b/e2e-tests/playwright/support/pages/rbac.ts index 75ebada39b..893c7e0677 100644 --- a/e2e-tests/playwright/support/pages/rbac.ts +++ b/e2e-tests/playwright/support/pages/rbac.ts @@ -1,4 +1,5 @@ import { APIResponse, Page, expect } from "@playwright/test"; + import { UIhelper } from "../../utils/ui-helper"; import { Policy, Role } from "../api/rbac-api-structures"; @@ -26,19 +27,14 @@ export class Roles { } static getPermissionPoliciesListCellsIdentifier() { - const policies = - /^(?:(Read|Create|Update|Delete)(?:, (?:Read|Create|Update|Delete))*|Use)$/u; + const policies = /^(?:(Read|Create|Update|Delete)(?:, (?:Read|Create|Update|Delete))*|Use)$/u; return [policies]; } //Depending on the version of the Backstage, it can be 'Permission Policies' or 'Accessible Plugins' // Accepts either term static getRolesListColumnsText() { - return [ - /^Name$/u, - /^Users and groups$/u, - /Permission Policies|Accessible plugins/u, - ]; + return [/^Name$/u, /^Users and groups$/u, /Permission Policies|Accessible plugins/u]; } static getUsersAndGroupsListColumnsText() { @@ -50,16 +46,12 @@ export class Roles { } } -export async function removeMetadataFromResponse( - response: APIResponse, -): Promise { +export async function removeMetadataFromResponse(response: APIResponse): Promise { try { const responseJson: unknown = await response.json(); if (!Array.isArray(responseJson)) { - console.warn( - `Expected an array but received: ${JSON.stringify(responseJson)}`, - ); + console.warn(`Expected an array but received: ${JSON.stringify(responseJson)}`); return []; } @@ -77,10 +69,7 @@ export async function removeMetadataFromResponse( } } -export async function checkRbacResponse( - response: APIResponse, - expected: Role[] | Policy[], -) { +export async function checkRbacResponse(response: APIResponse, expected: Role[] | Policy[]) { const cleanResponse = await removeMetadataFromResponse(response); expect(cleanResponse).toEqual(expected); } diff --git a/e2e-tests/playwright/support/selectors/semantic-selectors-accessibility.ts b/e2e-tests/playwright/support/selectors/semantic-selectors-accessibility.ts index 788247a0b2..a8db3bd61f 100644 --- a/e2e-tests/playwright/support/selectors/semantic-selectors-accessibility.ts +++ b/e2e-tests/playwright/support/selectors/semantic-selectors-accessibility.ts @@ -32,11 +32,7 @@ export const semanticSelectorsAccessibility = { return rows.filter({ hasText: text }); }, - heading( - page: Page, - name: string | RegExp, - level?: 1 | 2 | 3 | 4 | 5 | 6, - ): Locator { + heading(page: Page, name: string | RegExp, level?: 1 | 2 | 3 | 4 | 5 | 6): Locator { return page.getByRole("heading", { name, level }); }, diff --git a/e2e-tests/playwright/support/selectors/semantic-selectors-structure.ts b/e2e-tests/playwright/support/selectors/semantic-selectors-structure.ts index db7411cdb8..8a026de12a 100644 --- a/e2e-tests/playwright/support/selectors/semantic-selectors-structure.ts +++ b/e2e-tests/playwright/support/selectors/semantic-selectors-structure.ts @@ -2,9 +2,7 @@ import { Locator, Page } from "@playwright/test"; export const semanticSelectorsStructure = { dialog(page: Page, name?: string | RegExp): Locator { - return name === undefined - ? page.getByRole("dialog") - : page.getByRole("dialog", { name }); + return name === undefined ? page.getByRole("dialog") : page.getByRole("dialog", { name }); }, navigation(page: Page, name?: string | RegExp): Locator { @@ -30,9 +28,7 @@ export const semanticSelectorsStructure = { }, list(page: Page, name?: string | RegExp): Locator { - return name === undefined - ? page.getByRole("list") - : page.getByRole("list", { name }); + return name === undefined ? page.getByRole("list") : page.getByRole("list", { name }); }, listItem(page: Page, text?: string | RegExp): Locator { @@ -45,15 +41,11 @@ export const semanticSelectorsStructure = { }, region(page: Page, name?: string | RegExp): Locator { - return name === undefined - ? page.getByRole("region") - : page.getByRole("region", { name }); + return name === undefined ? page.getByRole("region") : page.getByRole("region", { name }); }, alert(page: Page, name?: string | RegExp): Locator { - return name === undefined - ? page.getByRole("alert") - : page.getByRole("alert", { name }); + return name === undefined ? page.getByRole("alert") : page.getByRole("alert", { name }); }, testId(page: Page, testId: string): Locator { @@ -83,8 +75,6 @@ export const semanticSelectorsStructure = { | "listitem", name?: string | RegExp, ): Locator { - return name === undefined - ? container.getByRole(role) - : container.getByRole(role, { name }); + return name === undefined ? container.getByRole(role) : container.getByRole(role, { name }); }, }; diff --git a/e2e-tests/playwright/support/selectors/semantic-selectors-table-helpers.ts b/e2e-tests/playwright/support/selectors/semantic-selectors-table-helpers.ts index 5415aa79fb..7d2572372b 100644 --- a/e2e-tests/playwright/support/selectors/semantic-selectors-table-helpers.ts +++ b/e2e-tests/playwright/support/selectors/semantic-selectors-table-helpers.ts @@ -1,11 +1,8 @@ import { Page, Locator } from "@playwright/test"; + import { semanticSelectorsAccessibility } from "./semantic-selectors-accessibility"; -export function findTableCell( - page: Page, - rowText: string | RegExp, - cellIndex: number, -): Locator { +export function findTableCell(page: Page, rowText: string | RegExp, cellIndex: number): Locator { const row = semanticSelectorsAccessibility.tableRow(page, rowText); return row.getByRole("cell").nth(cellIndex); } @@ -16,8 +13,6 @@ export async function findTableCellByColumn( columnName: string | RegExp, ): Promise { const header = semanticSelectorsAccessibility.tableHeader(page, columnName); - const columnIndex = await header.evaluate( - (th: HTMLTableCellElement) => th.cellIndex, - ); + const columnIndex = await header.evaluate((th: HTMLTableCellElement) => th.cellIndex); return findTableCell(page, rowText, columnIndex); } diff --git a/e2e-tests/playwright/support/selectors/semantic-selectors.ts b/e2e-tests/playwright/support/selectors/semantic-selectors.ts index f8831d7dd8..4f321da793 100644 --- a/e2e-tests/playwright/support/selectors/semantic-selectors.ts +++ b/e2e-tests/playwright/support/selectors/semantic-selectors.ts @@ -21,8 +21,5 @@ export const SemanticSelectors = { ...semanticSelectorsStructure, }; -export { - findTableCell, - findTableCellByColumn, -} from "./semantic-selectors-table-helpers"; +export { findTableCell, findTableCellByColumn } from "./semantic-selectors-table-helpers"; export { WaitStrategies } from "./wait-strategies"; diff --git a/e2e-tests/playwright/support/selectors/wait-strategies.ts b/e2e-tests/playwright/support/selectors/wait-strategies.ts index 8ae8ac41e7..892838fe23 100644 --- a/e2e-tests/playwright/support/selectors/wait-strategies.ts +++ b/e2e-tests/playwright/support/selectors/wait-strategies.ts @@ -13,9 +13,7 @@ export const WaitStrategies = { await page.waitForResponse((response) => { const url = response.url(); const matchesUrl = - typeof urlPattern === "string" - ? url.includes(urlPattern) - : urlPattern.test(url); + typeof urlPattern === "string" ? url.includes(urlPattern) : urlPattern.test(url); return matchesUrl && response.status() === statusCode; }); }, diff --git a/e2e-tests/playwright/utils/api-helper-catalog.ts b/e2e-tests/playwright/utils/api-helper-catalog.ts index ddd9cd38dc..06c50fc2bc 100644 --- a/e2e-tests/playwright/utils/api-helper-catalog.ts +++ b/e2e-tests/playwright/utils/api-helper-catalog.ts @@ -1,4 +1,5 @@ import { request } from "@playwright/test"; + import { type CatalogLocationEntry, isCatalogLocationEntry, @@ -6,9 +7,7 @@ import { parseJsonResponse, } from "./api-helper-guards"; -export async function getEntityUidByName( - name: string, -): Promise { +export async function getEntityUidByName(name: string): Promise { const baseUrl = process.env.BASE_URL; const url = `${baseUrl}/api/catalog/entities/by-name/template/default/${name}`; const context = await request.newContext(); @@ -76,9 +75,7 @@ export async function registerLocation(target: string): Promise { return response.status(); } -export async function getLocationIdByTarget( - target: string, -): Promise { +export async function getLocationIdByTarget(target: string): Promise { const baseUrl = process.env.BASE_URL; const url = `${baseUrl}/api/catalog/locations`; const context = await request.newContext(); diff --git a/e2e-tests/playwright/utils/api-helper-github.ts b/e2e-tests/playwright/utils/api-helper-github.ts index 4006011bad..3a6a9ef14c 100644 --- a/e2e-tests/playwright/utils/api-helper-github.ts +++ b/e2e-tests/playwright/utils/api-helper-github.ts @@ -1,4 +1,5 @@ import { request, type APIResponse, expect } from "@playwright/test"; + import { GITHUB_API_ENDPOINTS } from "./api-endpoints"; import { type GitHubPullRequestFile, @@ -63,14 +64,10 @@ async function getGithubPaginatedRequest( export { getGithubPaginatedRequest }; export async function createGitHubRepo(owner: string, repoName: string) { - const response = await githubRequest( - "POST", - GITHUB_API_ENDPOINTS.createRepo(owner), - { - name: repoName, - private: false, - }, - ); + const response = await githubRequest("POST", GITHUB_API_ENDPOINTS.createRepo(owner), { + name: repoName, + private: false, + }); expect(response.status() === 201 || response.ok()).toBeTruthy(); } @@ -102,19 +99,11 @@ export async function createGitHubRepoWithFile( fileContent: string, ) { await createGitHubRepo(owner, repoName); - await createFileInRepo( - owner, - repoName, - filename, - fileContent, - `Add ${filename} file`, - ); + await createFileInRepo(owner, repoName, filename, fileContent, `Add ${filename} file`); } export async function initCommit(owner: string, repo: string, branch = "main") { - const content = Buffer.from( - "This is the initial commit for the repository.", - ).toString("base64"); + const content = Buffer.from("This is the initial commit for the repository.").toString("base64"); const response = await githubRequest( "PUT", `${GITHUB_API_ENDPOINTS.contents(owner, repo)}/initial-commit.md`, @@ -128,21 +117,11 @@ export async function initCommit(owner: string, repo: string, branch = "main") { } export async function deleteGitHubRepo(owner: string, repoName: string) { - await githubRequest( - "DELETE", - GITHUB_API_ENDPOINTS.deleteRepo(owner, repoName), - ); + await githubRequest("DELETE", GITHUB_API_ENDPOINTS.deleteRepo(owner, repoName)); } -export async function mergeGitHubPR( - owner: string, - repoName: string, - pullNumber: number, -) { - await githubRequest( - "PUT", - GITHUB_API_ENDPOINTS.mergePR(owner, repoName, pullNumber), - ); +export async function mergeGitHubPR(owner: string, repoName: string, pullNumber: number) { + await githubRequest("PUT", GITHUB_API_ENDPOINTS.mergePR(owner, repoName, pullNumber)); } export async function getGitHubPRs( @@ -165,10 +144,7 @@ export async function getfileContentFromPR( pr: number, filename: string, ): Promise { - const response = await githubRequest( - "GET", - GITHUB_API_ENDPOINTS.pull_files(owner, repoName, pr), - ); + const response = await githubRequest("GET", GITHUB_API_ENDPOINTS.pull_files(owner, repoName, pr)); const files: unknown = await parseJsonResponse(response); if (!Array.isArray(files)) { throw new TypeError( @@ -182,8 +158,6 @@ export async function getfileContentFromPR( if (file === undefined) { throw new Error(`File ${filename} not found in PR ${pr}`); } - const rawFileContent = await ( - await githubRequest("GET", file.raw_url) - ).text(); + const rawFileContent = await (await githubRequest("GET", file.raw_url)).text(); return rawFileContent; } diff --git a/e2e-tests/playwright/utils/api-helper-guards.ts b/e2e-tests/playwright/utils/api-helper-guards.ts index 7663b29630..346e25a6f4 100644 --- a/e2e-tests/playwright/utils/api-helper-guards.ts +++ b/e2e-tests/playwright/utils/api-helper-guards.ts @@ -1,5 +1,5 @@ -import { type APIResponse } from "@playwright/test"; import { type GroupEntity, type UserEntity } from "@backstage/catalog-model"; +import { type APIResponse } from "@playwright/test"; interface GitHubPullRequestFile { filename: string; @@ -25,9 +25,7 @@ interface CatalogLocationEntry { }; } -export function isGitHubPullRequestFile( - value: unknown, -): value is GitHubPullRequestFile { +export function isGitHubPullRequestFile(value: unknown): value is GitHubPullRequestFile { return ( typeof value === "object" && value !== null && @@ -38,9 +36,7 @@ export function isGitHubPullRequestFile( ); } -export function isGuestTokenResponse( - value: unknown, -): value is GuestTokenResponse { +export function isGuestTokenResponse(value: unknown): value is GuestTokenResponse { return ( typeof value === "object" && value !== null && @@ -52,28 +48,20 @@ export function isGuestTokenResponse( ); } -export function isEntityMetadataResponse( - value: unknown, -): value is EntityMetadataResponse { +export function isEntityMetadataResponse(value: unknown): value is EntityMetadataResponse { return typeof value === "object" && value !== null; } -export function isCatalogLocationEntry( - value: unknown, -): value is CatalogLocationEntry { +export function isCatalogLocationEntry(value: unknown): value is CatalogLocationEntry { return typeof value === "object" && value !== null; } export function isUserEntity(value: unknown): value is UserEntity { - return ( - isEntityMetadataResponse(value) && "kind" in value && value.kind === "User" - ); + return isEntityMetadataResponse(value) && "kind" in value && value.kind === "User"; } export function isGroupEntity(value: unknown): value is GroupEntity { - return ( - isEntityMetadataResponse(value) && "kind" in value && value.kind === "Group" - ); + return isEntityMetadataResponse(value) && "kind" in value && value.kind === "Group"; } export function parseJsonResponse(response: APIResponse): Promise { @@ -82,9 +70,7 @@ export function parseJsonResponse(response: APIResponse): Promise { export function toUnknownArray(value: unknown): unknown[] { if (!Array.isArray(value)) { - throw new TypeError( - `Expected array but got ${typeof value}: ${JSON.stringify(value)}`, - ); + throw new TypeError(`Expected array but got ${typeof value}: ${JSON.stringify(value)}`); } const items: unknown[] = []; for (const item of value) { diff --git a/e2e-tests/playwright/utils/api-helper.ts b/e2e-tests/playwright/utils/api-helper.ts index 57606c1d77..2bc2b9c023 100644 --- a/e2e-tests/playwright/utils/api-helper.ts +++ b/e2e-tests/playwright/utils/api-helper.ts @@ -1,13 +1,14 @@ -import { request, type APIResponse, expect } from "@playwright/test"; import { type GroupEntity, type UserEntity } from "@backstage/catalog-model"; +import { request, type APIResponse, expect } from "@playwright/test"; + +import * as catalogApi from "./api-helper-catalog"; +import * as githubApi from "./api-helper-github"; import { isGuestTokenResponse, isGroupEntity, isUserEntity, parseJsonResponse, } from "./api-helper-guards"; -import * as catalogApi from "./api-helper-catalog"; -import * as githubApi from "./api-helper-github"; export class APIHelper { private staticToken = ""; @@ -96,51 +97,31 @@ export class APIHelper { async getAllCatalogUsersFromAPI(): Promise { const url = `${this.baseUrl}/api/catalog/entities/by-query?orderField=metadata.name%2Casc&filter=kind%3Duser`; - const response = await APIHelper.APIRequestWithStaticToken( - "GET", - url, - this.getAuthToken(), - ); + const response = await APIHelper.APIRequestWithStaticToken("GET", url, this.getAuthToken()); return parseJsonResponse(response); } async getAllCatalogLocationsFromAPI(): Promise { const url = `${this.baseUrl}/api/catalog/entities/by-query?orderField=metadata.name%2Casc&filter=kind%3Dlocation`; - const response = await APIHelper.APIRequestWithStaticToken( - "GET", - url, - this.getAuthToken(), - ); + const response = await APIHelper.APIRequestWithStaticToken("GET", url, this.getAuthToken()); return parseJsonResponse(response); } async getAllCatalogGroupsFromAPI(): Promise { const url = `${this.baseUrl}/api/catalog/entities/by-query?orderField=metadata.name%2Casc&filter=kind%3Dgroup`; - const response = await APIHelper.APIRequestWithStaticToken( - "GET", - url, - this.getAuthToken(), - ); + const response = await APIHelper.APIRequestWithStaticToken("GET", url, this.getAuthToken()); return parseJsonResponse(response); } async getGroupEntityFromAPI(group: string): Promise { const url = `${this.baseUrl}/api/catalog/entities/by-name/group/default/${group}`; - const response = await APIHelper.APIRequestWithStaticToken( - "GET", - url, - this.getAuthToken(), - ); + const response = await APIHelper.APIRequestWithStaticToken("GET", url, this.getAuthToken()); return parseJsonResponse(response); } async getCatalogUserFromAPI(user: string): Promise { const url = `${this.baseUrl}/api/catalog/entities/by-name/user/default/${user}`; - const response = await APIHelper.APIRequestWithStaticToken( - "GET", - url, - this.getAuthToken(), - ); + const response = await APIHelper.APIRequestWithStaticToken("GET", url, this.getAuthToken()); const body: unknown = await parseJsonResponse(response); if (!isUserEntity(body)) { throw new TypeError(`Invalid catalog user response for ${user}`); @@ -155,21 +136,13 @@ export class APIHelper { return undefined; } const url = `${this.baseUrl}/api/catalog/entities/by-uid/${uid}`; - const response = await APIHelper.APIRequestWithStaticToken( - "DELETE", - url, - this.getAuthToken(), - ); + const response = await APIHelper.APIRequestWithStaticToken("DELETE", url, this.getAuthToken()); return response.statusText(); } async getCatalogGroupFromAPI(group: string): Promise { const url = `${this.baseUrl}/api/catalog/entities/by-name/group/default/${group}`; - const response = await APIHelper.APIRequestWithStaticToken( - "GET", - url, - this.getAuthToken(), - ); + const response = await APIHelper.APIRequestWithStaticToken("GET", url, this.getAuthToken()); const body: unknown = await parseJsonResponse(response); if (!isGroupEntity(body)) { throw new TypeError(`Invalid catalog group response for ${group}`); @@ -180,27 +153,14 @@ export class APIHelper { async deleteGroupEntityFromAPI(group: string): Promise { const r = await this.getCatalogGroupFromAPI(group); const url = `${this.baseUrl}/api/catalog/entities/by-uid/${r.metadata.uid}`; - const response = await APIHelper.APIRequestWithStaticToken( - "DELETE", - url, - this.getAuthToken(), - ); + const response = await APIHelper.APIRequestWithStaticToken("DELETE", url, this.getAuthToken()); return response.statusText(); } - async scheduleEntityRefreshFromAPI( - entity: string, - kind: string, - token: string, - ) { + async scheduleEntityRefreshFromAPI(entity: string, kind: string, token: string) { const url = `${this.baseUrl}/api/catalog/refresh`; const reqBody = { entityRef: `${kind}:default/${entity}` }; - const responseRefresh = await APIHelper.APIRequestWithStaticToken( - "POST", - url, - token, - reqBody, - ); + const responseRefresh = await APIHelper.APIRequestWithStaticToken("POST", url, token, reqBody); return responseRefresh.status(); } } diff --git a/e2e-tests/playwright/utils/authentication-providers/gitlab-helper.ts b/e2e-tests/playwright/utils/authentication-providers/gitlab-helper.ts index 1bf80ae72b..0e6a10a697 100644 --- a/e2e-tests/playwright/utils/authentication-providers/gitlab-helper.ts +++ b/e2e-tests/playwright/utils/authentication-providers/gitlab-helper.ts @@ -18,9 +18,7 @@ interface GitLabOAuthAppResponse { scopes?: string[]; } -function isGitLabOAuthAppResponse( - value: unknown, -): value is GitLabOAuthAppResponse { +function isGitLabOAuthAppResponse(value: unknown): value is GitLabOAuthAppResponse { return ( typeof value === "object" && value !== null && @@ -98,8 +96,7 @@ export class GitLabHelper { application_name: app.application_name ?? app.name ?? name, secret: app.secret, callback_url: app.callback_url ?? app.redirect_uri ?? redirectUri, - scopes: - app.scopes ?? (typeof scopes === "string" ? scopes.split(" ") : []), + scopes: app.scopes ?? (typeof scopes === "string" ? scopes.split(" ") : []), }; } @@ -120,16 +117,9 @@ export class GitLabHelper { try { console.log(`[GITLAB] Creating OAuth application: ${name}`); console.log(`[GITLAB] Scopes: ${scopes}, Trusted: ${trusted}`); - const app = await this.postOAuthApplication( - name, - redirectUri, - scopes, - trusted, - ); + const app = await this.postOAuthApplication(name, redirectUri, scopes, trusted); - console.log( - `[GITLAB] OAuth application created successfully with ID: ${app.id}`, - ); + console.log(`[GITLAB] OAuth application created successfully with ID: ${app.id}`); console.log( `[GITLAB] Application ID: ${app.application_id}, Secret: ${app.secret ? "***" : "not provided"}`, ); @@ -148,15 +138,12 @@ export class GitLabHelper { async deleteOAuthApplication(applicationId: number): Promise { try { console.log(`[GITLAB] Deleting OAuth application: ${applicationId}`); - const response = await fetch( - `${this.apiBaseUrl}/applications/${applicationId}`, - { - method: "DELETE", - headers: { - "PRIVATE-TOKEN": this.config.personalAccessToken, - }, + const response = await fetch(`${this.apiBaseUrl}/applications/${applicationId}`, { + method: "DELETE", + headers: { + "PRIVATE-TOKEN": this.config.personalAccessToken, }, - ); + }); if (!response.ok) { // 404 is acceptable if the app was already deleted @@ -172,14 +159,9 @@ export class GitLabHelper { ); } - console.log( - `[GITLAB] OAuth application ${applicationId} deleted successfully`, - ); + console.log(`[GITLAB] OAuth application ${applicationId} deleted successfully`); } catch (error) { - console.error( - `[GITLAB] Failed to delete OAuth application ${applicationId}:`, - error, - ); + console.error(`[GITLAB] Failed to delete OAuth application ${applicationId}:`, error); throw error; } } diff --git a/e2e-tests/playwright/utils/authentication-providers/msgraph-helper-nsg.ts b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper-nsg.ts index 3edc8bda54..2e3768a317 100644 --- a/e2e-tests/playwright/utils/authentication-providers/msgraph-helper-nsg.ts +++ b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper-nsg.ts @@ -4,6 +4,7 @@ import { SecurityRule, SecurityRulesGetResponse, } from "@azure/arm-network"; + import { getErrorMessage, hasStatusCode } from "../errors"; export async function getNetworkSecurityGroupRule( @@ -17,17 +18,11 @@ export async function getNetworkSecurityGroupRule( `Getting network security group rule ${ruleName} from NSG ${nsgName} in resource group ${resourceGroupName}`, ); - const rule = await armNetworkClient.securityRules.get( - resourceGroupName, - nsgName, - ruleName, - ); + const rule = await armNetworkClient.securityRules.get(resourceGroupName, nsgName, ruleName); return rule ?? null; } catch (e) { if (hasStatusCode(e) && e.statusCode === 404) { - console.log( - `Network security group rule ${ruleName} not found in NSG ${nsgName}`, - ); + console.log(`Network security group rule ${ruleName} not found in NSG ${nsgName}`); return null; } console.error("Failed to get network security group rule:", e); @@ -45,14 +40,9 @@ export async function getNetworkSecurityGroup( `Getting network security group ${nsgName} from resource group ${resourceGroupName}`, ); - const nsg = await armNetworkClient.networkSecurityGroups.get( - resourceGroupName, - nsgName, - ); + const nsg = await armNetworkClient.networkSecurityGroups.get(resourceGroupName, nsgName); if (nsg === undefined) { - throw new Error( - `Network security group ${nsgName} not found in ${resourceGroupName}`, - ); + throw new Error(`Network security group ${nsgName} not found in ${resourceGroupName}`); } return nsg; } catch (e) { @@ -66,10 +56,7 @@ async function findAvailablePriority( resourceGroupName: string, nsgName: string, ): Promise { - const existingRules = armNetworkClient.securityRules.list( - resourceGroupName, - nsgName, - ); + const existingRules = armNetworkClient.securityRules.list(resourceGroupName, nsgName); const existingPriorities = new Set(); for await (const rule of existingRules) { @@ -165,14 +152,8 @@ async function resolveTemplateRule( nsgName: string, baseRuleName: string, ): Promise { - console.log( - `[NSG] Verifying NSG exists: ${nsgName} in resource group: ${resourceGroupName}`, - ); - const nsg = await getNetworkSecurityGroup( - armNetworkClient, - resourceGroupName, - nsgName, - ); + console.log(`[NSG] Verifying NSG exists: ${nsgName} in resource group: ${resourceGroupName}`); + const nsg = await getNetworkSecurityGroup(armNetworkClient, resourceGroupName, nsgName); console.log(`[NSG] NSG verified: ${nsg.name} (ID: ${nsg.id})`); console.log(`[NSG] Getting existing rule as template: ${baseRuleName}`); @@ -184,9 +165,7 @@ async function resolveTemplateRule( ); if (templateRule === null) { - throw new Error( - `Template rule ${baseRuleName} not found in NSG ${nsgName}`, - ); + throw new Error(`Template rule ${baseRuleName} not found in NSG ${nsgName}`); } console.log( `[NSG] Template rule found: ${templateRule.name} (Priority: ${templateRule.priority})`, @@ -271,12 +250,7 @@ export async function allowPublicIpInNsg( ruleName, resourceGroupName, nsgName, - cleanup: createNsgRuleCleanup( - armNetworkClient, - resourceGroupName, - nsgName, - ruleName, - ), + cleanup: createNsgRuleCleanup(armNetworkClient, resourceGroupName, nsgName, ruleName), }; } catch (error) { logNsgFailure(error); diff --git a/e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts index 4a2974f93d..c932858740 100644 --- a/e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts +++ b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts @@ -1,14 +1,12 @@ // oxlint-disable-next-line import/no-unassigned-import -- fetch polyfill required by Graph SDK import "isomorphic-fetch"; -import { hasStatusCode } from "../errors"; +import { NetworkManagementClient, SecurityRulesGetResponse } from "@azure/arm-network"; import { ClientSecretCredential } from "@azure/identity"; import { Client, PageCollection } from "@microsoft/microsoft-graph-client"; import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials/index.js"; import { User, Group } from "@microsoft/microsoft-graph-types"; -import { - NetworkManagementClient, - SecurityRulesGetResponse, -} from "@azure/arm-network"; + +import { hasStatusCode } from "../errors"; import { allowPublicIpInNsg, getNetworkSecurityGroup, @@ -27,18 +25,13 @@ interface IpifyResponse { ip: string; } -function isAzureApplicationResponse( - value: unknown, -): value is AzureApplicationResponse { +function isAzureApplicationResponse(value: unknown): value is AzureApplicationResponse { return typeof value === "object" && value !== null; } function isIpifyResponse(value: unknown): value is IpifyResponse { return ( - typeof value === "object" && - value !== null && - "ip" in value && - typeof value.ip === "string" + typeof value === "object" && value !== null && "ip" in value && typeof value.ip === "string" ); } @@ -51,12 +44,7 @@ export class MSClient { private readonly clientSecret: string; private readonly subscriptionId?: string; - constructor( - clientId: string, - clientSecret: string, - tenantId: string, - subscriptionId?: string, - ) { + constructor(clientId: string, clientSecret: string, tenantId: string, subscriptionId?: string) { if (!clientId || !tenantId || !clientSecret) { console.error("Missing required credentials"); throw new Error("Client ID, Tenant ID, and Client Secret are required"); @@ -76,12 +64,9 @@ export class MSClient { ); if (this.appClient === undefined) { - const authProvider = new TokenCredentialAuthenticationProvider( - this.clientSecretCredential, - { - scopes: ["https://graph.microsoft.com/.default"], - }, - ); + const authProvider = new TokenCredentialAuthenticationProvider(this.clientSecretCredential, { + scopes: ["https://graph.microsoft.com/.default"], + }); this.appClient = Client.initWithMiddleware({ authProvider: authProvider, @@ -123,26 +108,20 @@ export class MSClient { } /** Graph SDK requests return untyped data; narrow at call sites. */ - private async graphGet( - request: (client: Client) => Promise, - ): Promise { + private async graphGet(request: (client: Client) => Promise): Promise { const result: unknown = await request(this.getAppClient()); // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Graph SDK has no typed responses return result as T; } /** Graph SDK mutations return untyped data; narrow at call sites. */ - private async graphMutate( - request: (client: Client) => Promise, - ): Promise { + private async graphMutate(request: (client: Client) => Promise): Promise { const result: unknown = await request(this.getAppClient()); // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Graph SDK has no typed responses return result as T; } - private async graphDelete( - request: (client: Client) => Promise, - ): Promise { + private async graphDelete(request: (client: Client) => Promise): Promise { await request(this.getAppClient()); } @@ -167,10 +146,7 @@ export class MSClient { async getGroupsAsync(): Promise { try { return await this.graphGet((client) => - client - .api("/groups") - .select(["id", "displayName", "members", "owners"]) - .get(), + client.api("/groups").select(["id", "displayName", "members", "owners"]).get(), ); } catch (e) { console.error("Failed to get groups:", e); @@ -181,11 +157,7 @@ export class MSClient { async getGroupByNameAsync(groupName: string): Promise { try { return await this.graphGet((client) => - client - .api("/groups") - .filter(`displayName eq '${groupName}'`) - .top(1) - .get(), + client.api("/groups").filter(`displayName eq '${groupName}'`).top(1).get(), ); } catch (e) { if (hasStatusCode(e) && e.statusCode === 404) { @@ -202,14 +174,7 @@ export class MSClient { return await this.graphGet((client) => client .api(`/groups/${groupId}/members`) - .select([ - "displayName", - "id", - "mail", - "userPrincipalName", - "surname", - "firstname", - ]) + .select(["displayName", "id", "mail", "userPrincipalName", "surname", "firstname"]) .get(), ); } catch (e) { @@ -221,9 +186,7 @@ export class MSClient { async createUserAsync(user: User): Promise { try { console.log(`Creating user ${user.userPrincipalName}`); - return await this.graphMutate((client) => - client.api("/users").post(user), - ); + return await this.graphMutate((client) => client.api("/users").post(user)); } catch (e) { console.error("Failed to create user:", e); throw e; @@ -233,9 +196,7 @@ export class MSClient { async createGroupAsync(group: Group): Promise { try { console.log(`Creating group ${group.displayName}`); - return await this.graphMutate((client) => - client.api("/groups").post(group), - ); + return await this.graphMutate((client) => client.api("/groups").post(group)); } catch (e) { console.error("Failed to create group:", e); throw e; @@ -247,14 +208,7 @@ export class MSClient { return await this.graphGet((client) => client .api("/users") - .select([ - "displayName", - "id", - "mail", - "userPrincipalName", - "surname", - "firstname", - ]) + .select(["displayName", "id", "mail", "userPrincipalName", "surname", "firstname"]) .top(25) .orderby("userPrincipalName") .get(), @@ -287,9 +241,7 @@ export class MSClient { async getUserByUpnAsync(upn: string): Promise { try { - return await this.graphGet((client) => - client.api("/users/" + upn).get(), - ); + return await this.graphGet((client) => client.api("/users/" + upn).get()); } catch (e) { if (hasStatusCode(e) && e.statusCode === 404) { console.log(`User ${upn} not found`); @@ -302,17 +254,12 @@ export class MSClient { async addUserToGroupAsync(user: User, group: Group): Promise { const userDirectoryObject = { - "@odata.id": - "https://graph.microsoft.com/v1.0/users/" + user.userPrincipalName, + "@odata.id": "https://graph.microsoft.com/v1.0/users/" + user.userPrincipalName, }; try { - console.log( - `Adding user ${user.userPrincipalName} to group ${group.displayName}`, - ); + console.log(`Adding user ${user.userPrincipalName} to group ${group.displayName}`); await this.graphMutate((client) => - client - .api("/groups/" + group.id + "/members/$ref") - .post(userDirectoryObject), + client.api("/groups/" + group.id + "/members/$ref").post(userDirectoryObject), ); } catch (e) { console.error("Failed to add user to group:", e); @@ -322,9 +269,7 @@ export class MSClient { async removeUserFromGroupAsync(user: User, group: Group): Promise { try { - console.log( - `Removing user ${user.userPrincipalName} from group ${group.displayName}`, - ); + console.log(`Removing user ${user.userPrincipalName} from group ${group.displayName}`); await this.graphDelete((client) => client.api(`/groups/${group.id}/members/${user.id}/$ref`).delete(), ); @@ -339,13 +284,9 @@ export class MSClient { "@odata.id": "https://graph.microsoft.com/v1.0/groups/" + subject.id, }; try { - console.log( - `Adding group ${subject.displayName} to group ${target.displayName}`, - ); + console.log(`Adding group ${subject.displayName} to group ${target.displayName}`); await this.graphMutate((client) => - client - .api("/groups/" + target.id + "/members/$ref") - .post(userDirectoryObject), + client.api("/groups/" + target.id + "/members/$ref").post(userDirectoryObject), ); } catch (e) { console.error("Failed to add group to group:", e); @@ -397,15 +338,11 @@ export class MSClient { async addAppRedirectUrlsAsync(redirectUrls: string[]): Promise { try { - console.log( - `[AZURE] Adding ${redirectUrls.length} redirect URLs to app: ${this.clientId}`, - ); + console.log(`[AZURE] Adding ${redirectUrls.length} redirect URLs to app: ${this.clientId}`); const currentUrls = await this.getAppRedirectUrlsAsync(); const newUrls = [...new Set([...currentUrls, ...redirectUrls])]; - console.log( - `[AZURE] Updating app with ${newUrls.length} total redirect URLs`, - ); + console.log(`[AZURE] Updating app with ${newUrls.length} total redirect URLs`); await this.graphMutate((client) => client.api(`/applications(appId='{${this.clientId}}')`).update({ web: { @@ -428,9 +365,7 @@ export class MSClient { const currentUrls = await this.getAppRedirectUrlsAsync(); const newUrls = currentUrls.filter((url) => !redirectUrls.includes(url)); - console.log( - `[AZURE] Updating app with ${newUrls.length} remaining redirect URLs`, - ); + console.log(`[AZURE] Updating app with ${newUrls.length} remaining redirect URLs`); await this.graphMutate((client) => client.api(`/applications(appId='{${this.clientId}}')`).update({ web: { @@ -477,12 +412,7 @@ export class MSClient { if (this.armNetworkClient === undefined) { throw new Error("ARM network client not initialized"); } - return getNetworkSecurityGroupRule( - this.armNetworkClient, - resourceGroupName, - nsgName, - ruleName, - ); + return getNetworkSecurityGroupRule(this.armNetworkClient, resourceGroupName, nsgName, ruleName); } async getPublicIpAsync(): Promise { @@ -491,9 +421,7 @@ export class MSClient { const response = await fetch("https://api.ipify.org?format=json"); if (!response.ok) { - throw new Error( - `Failed to fetch public IP: ${response.status} ${response.statusText}`, - ); + throw new Error(`Failed to fetch public IP: ${response.status} ${response.statusText}`); } const data: unknown = await response.json(); @@ -515,11 +443,7 @@ export class MSClient { if (this.armNetworkClient === undefined) { throw new Error("ARM network client not initialized"); } - return getNetworkSecurityGroup( - this.armNetworkClient, - resourceGroupName, - nsgName, - ); + return getNetworkSecurityGroup(this.armNetworkClient, resourceGroupName, nsgName); } allowPublicIpInNSG( diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-auth.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-auth.ts index 825341f6ed..126f98239f 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-auth.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-auth.ts @@ -1,5 +1,6 @@ import { expect } from "@playwright/test"; import * as yaml from "yaml"; + import { RHDHDeploymentState } from "./rhdh-deployment-types"; export interface AuthConfigActions { @@ -7,8 +8,7 @@ export interface AuthConfigActions { setAppConfigProperty(path: string, value: unknown): void; } -const OIDC_CALLBACK_URL = - "${BASE_URL:-http://localhost:7007}/api/auth/oidc/handler/frame"; +const OIDC_CALLBACK_URL = "${BASE_URL:-http://localhost:7007}/api/auth/oidc/handler/frame"; export function enableOIDCLoginWithIngestion(actions: AuthConfigActions): void { console.log("Enabling OIDC login with ingestion..."); @@ -101,8 +101,7 @@ export function enableLDAPLoginWithIngestion(actions: AuthConfigActions): void { { dn: "${LDAP_GROUPS_DN}", options: { - filter: - "(&(objectClass=group)(groupType:1.2.840.113556.1.4.803:=2147483648))", + filter: "(&(objectClass=group)(groupType:1.2.840.113556.1.4.803:=2147483648))", scope: "sub", }, }, @@ -128,9 +127,7 @@ export function enableLDAPLoginWithIngestion(actions: AuthConfigActions): void { actions.setAppConfigProperty("signInPage", "oidc"); } -export function enableMicrosoftLoginWithIngestion( - actions: AuthConfigActions, -): void { +export function enableMicrosoftLoginWithIngestion(actions: AuthConfigActions): void { console.log("Enabling Microsoft login with ingestion..."); expect(process.env.AUTH_PROVIDERS_AZURE_CLIENT_ID).toBeDefined(); expect(process.env.AUTH_PROVIDERS_AZURE_CLIENT_SECRET).toBeDefined(); @@ -170,8 +167,7 @@ export function enableMicrosoftLoginWithIngestion( clientSecret: "${AUTH_PROVIDERS_AZURE_CLIENT_SECRET}", prompt: "auto", tenantId: "${AUTH_PROVIDERS_AZURE_TENANT_ID}", - callbackUrl: - "${BASE_URL:-http://localhost:7007}/api/auth/microsoft/handler/frame", + callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/microsoft/handler/frame", }, }); actions.setAppConfigProperty("auth.environment", "production"); @@ -237,8 +233,7 @@ export function enableGithubLoginWithIngestion( production: { clientId: "${AUTH_PROVIDERS_GH_ORG_CLIENT_ID}", clientSecret: "${AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET}", - callbackUrl: - "${BASE_URL:-http://localhost:7007}/api/auth/github/handler/frame", + callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/github/handler/frame", }, }); @@ -246,9 +241,7 @@ export function enableGithubLoginWithIngestion( actions.setAppConfigProperty("signInPage", "github"); } -export function enableGitlabLoginWithIngestion( - actions: AuthConfigActions, -): void { +export function enableGitlabLoginWithIngestion(actions: AuthConfigActions): void { console.log("Enabling GitLab login with ingestion..."); expect(process.env.AUTH_PROVIDERS_GITLAB_HOST).toBeDefined(); expect(process.env.AUTH_PROVIDERS_GITLAB_TOKEN).toBeDefined(); @@ -291,8 +284,7 @@ export function enableGitlabLoginWithIngestion( audience: "https://${AUTH_PROVIDERS_GITLAB_HOST}", clientId: "${AUTH_PROVIDERS_GITLAB_CLIENT_ID}", clientSecret: "${AUTH_PROVIDERS_GITLAB_CLIENT_SECRET}", - callbackUrl: - "${BASE_URL:-http://localhost:7007}/api/auth/gitlab/handler/frame", + callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/gitlab/handler/frame", }, }); @@ -305,15 +297,12 @@ export function setOIDCResolver( resolver: string, dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, ): void { - actions.setAppConfigProperty( - "auth.providers.oidc.production.signIn.resolvers", - [ - { - resolver, - dangerouslyAllowSignInWithoutUserInCatalog, - }, - ], - ); + actions.setAppConfigProperty("auth.providers.oidc.production.signIn.resolvers", [ + { + resolver, + dangerouslyAllowSignInWithoutUserInCatalog, + }, + ]); } export function setMicrosoftResolver( @@ -321,15 +310,12 @@ export function setMicrosoftResolver( resolver: string, dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, ): void { - actions.setAppConfigProperty( - "auth.providers.microsoft.production.signIn.resolvers", - [ - { - resolver, - dangerouslyAllowSignInWithoutUserInCatalog, - }, - ], - ); + actions.setAppConfigProperty("auth.providers.microsoft.production.signIn.resolvers", [ + { + resolver, + dangerouslyAllowSignInWithoutUserInCatalog, + }, + ]); } export function setGithubResolver( @@ -337,15 +323,12 @@ export function setGithubResolver( resolver: string, dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, ): void { - actions.setAppConfigProperty( - "auth.providers.github.production.signIn.resolvers", - [ - { - resolver, - dangerouslyAllowSignInWithoutUserInCatalog, - }, - ], - ); + actions.setAppConfigProperty("auth.providers.github.production.signIn.resolvers", [ + { + resolver, + dangerouslyAllowSignInWithoutUserInCatalog, + }, + ]); } export function setGitlabResolver( @@ -353,15 +336,12 @@ export function setGitlabResolver( resolver: string, dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, ): void { - actions.setAppConfigProperty( - "auth.providers.gitlab.production.signIn.resolvers", - [ - { - resolver, - dangerouslyAllowSignInWithoutUserInCatalog, - }, - ], - ); + actions.setAppConfigProperty("auth.providers.gitlab.production.signIn.resolvers", [ + { + resolver, + dangerouslyAllowSignInWithoutUserInCatalog, + }, + ]); } export function setDynamicPluginEnabled( @@ -369,9 +349,7 @@ export function setDynamicPluginEnabled( pluginName: string, enabled: boolean, ): void { - const plugin = state.dynamicPluginsConfig.plugins.find( - (p) => p.package === pluginName, - ); + const plugin = state.dynamicPluginsConfig.plugins.find((p) => p.package === pluginName); if (plugin === undefined) { state.dynamicPluginsConfig.plugins = [ ...state.dynamicPluginsConfig.plugins, @@ -386,9 +364,7 @@ export function setDynamicPluginEnabled( return; } plugin.disabled = !enabled; - console.log( - `Plugin ${pluginName} has been ${enabled ? "enabled" : "disabled"}.`, - ); + console.log(`Plugin ${pluginName} has been ${enabled ? "enabled" : "disabled"}.`); } export function printDynamicPluginsConfig(state: RHDHDeploymentState): void { diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-catalog.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-catalog.ts index 2ce4fd7d53..33d108e1b2 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-catalog.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-catalog.ts @@ -1,4 +1,5 @@ import { GroupEntity } from "@backstage/catalog-model"; + import { APIHelper } from "../api-helper"; import { getCatalogGroups, @@ -21,18 +22,14 @@ export function parseGroupChildrenFromEntity(group: GroupEntity): string[] { if (group.relations === undefined) { return []; } - return group.relations - .filter((r) => r.type === "parentOf") - .map((r) => r.targetRef.split("/")[1]); + return group.relations.filter((r) => r.type === "parentOf").map((r) => r.targetRef.split("/")[1]); } export function parseGroupParentFromEntity(group: GroupEntity): string[] { if (group.relations === undefined) { return []; } - return group.relations - .filter((r) => r.type === "childOf") - .map((r) => r.targetRef.split("/")[1]); + return group.relations.filter((r) => r.type === "childOf").map((r) => r.targetRef.split("/")[1]); } async function createCatalogApi( @@ -95,9 +92,7 @@ export async function checkUserIsInGroup( throw new Error(`Invalid group entity for ${group}`); } const members = parseGroupMemberFromEntity(entity); - console.log( - `Checking group ${group} (${JSON.stringify(members)}) contains user ${user}`, - ); + console.log(`Checking group ${group} (${JSON.stringify(members)}) contains user ${user}`); return members.includes(user); } @@ -131,9 +126,7 @@ export async function checkGroupIsChildOfGroup( throw new Error(`Invalid group entity for ${child}`); } const parents = parseGroupParentFromEntity(entity); - console.log( - `Checking parents of ${child} (${JSON.stringify(parents)}) contain group ${parent}`, - ); + console.log(`Checking parents of ${child} (${JSON.stringify(parents)}) contain group ${parent}`); return parents.includes(parent); } diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-k8s.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-k8s.ts index 2cc6cde9cd..ef1a6b3bca 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-k8s.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-k8s.ts @@ -1,9 +1,11 @@ -import * as k8s from "@kubernetes/client-node"; -import * as yaml from "yaml"; +import { spawn } from "child_process"; import { promises as fs } from "fs"; import { join, resolve as resolvePath } from "path"; + +import * as k8s from "@kubernetes/client-node"; import { expect } from "@playwright/test"; -import { spawn } from "child_process"; +import * as yaml from "yaml"; + import { hasErrorResponse } from "../errors"; import { BackstageCr, @@ -14,19 +16,14 @@ import { RHDHDeploymentState, rootDirName, } from "./rhdh-deployment-types"; -import { - ensureBackstageCRIsAvailable, - waitForDeploymentReady, -} from "./rhdh-deployment-wait"; +import { ensureBackstageCRIsAvailable, waitForDeploymentReady } from "./rhdh-deployment-wait"; export async function readYamlToJson(filePath: string): Promise { const fileContent = await fs.readFile(filePath, "utf8"); return yaml.parse(fileContent); } -export async function createNamespace( - state: RHDHDeploymentState, -): Promise { +export async function createNamespace(state: RHDHDeploymentState): Promise { if (state.isRunningLocal) { console.log("Skipping namespace creation as isRunningLocal is true."); return; @@ -91,9 +88,7 @@ async function updateConfigMap( ); } -export async function loadBaseConfig( - state: RHDHDeploymentState, -): Promise { +export async function loadBaseConfig(state: RHDHDeploymentState): Promise { const configPath = join(currentDirName, "yamls", "configmap.yaml"); const yamlContent = await fs.readFile(configPath, "utf8"); const configData: unknown = yaml.parse(yamlContent); @@ -103,9 +98,7 @@ export async function loadBaseConfig( } } -export async function createAppConfig( - state: RHDHDeploymentState, -): Promise { +export async function createAppConfig(state: RHDHDeploymentState): Promise { if (state.isRunningLocal) { const appConfigPath = join(currentDirName, "app-config.test.yaml"); const appConfigYaml = yaml.stringify(state.appConfig); @@ -119,9 +112,7 @@ export async function createAppConfig( }); } -export async function updateAppConfig( - state: RHDHDeploymentState, -): Promise { +export async function updateAppConfig(state: RHDHDeploymentState): Promise { if (state.isRunningLocal) { const appConfigPath = join(currentDirName, "app-config.test.yaml"); const appConfigYaml = yaml.stringify(state.appConfig); @@ -135,13 +126,8 @@ export async function updateAppConfig( }); } -export async function deleteConfigMap( - state: RHDHDeploymentState, -): Promise { - await state.k8sApi.deleteNamespacedConfigMap( - state.appConfigMap, - state.namespace, - ); +export async function deleteConfigMap(state: RHDHDeploymentState): Promise { + await state.k8sApi.deleteNamespacedConfigMap(state.appConfigMap, state.namespace); } export async function createSecret(state: RHDHDeploymentState): Promise { @@ -175,11 +161,7 @@ export async function updateSecret(state: RHDHDeploymentState): Promise { }, data: state.secretData, }; - await state.k8sApi.replaceNamespacedSecret( - state.secretName, - state.namespace, - secret, - ); + await state.k8sApi.replaceNamespacedSecret(state.secretName, state.namespace, secret); } export async function deleteSecret(state: RHDHDeploymentState): Promise { @@ -190,16 +172,12 @@ export async function deleteSecret(state: RHDHDeploymentState): Promise { await state.k8sApi.deleteNamespacedSecret(state.secretName, state.namespace); } -export async function loadRbacConfig( - state: RHDHDeploymentState, -): Promise { +export async function loadRbacConfig(state: RHDHDeploymentState): Promise { const configPath = join(currentDirName, "yamls", "rbac-policy.csv"); state.rbacConfig = await fs.readFile(configPath, "utf8"); } -export async function createRbacConfig( - state: RHDHDeploymentState, -): Promise { +export async function createRbacConfig(state: RHDHDeploymentState): Promise { if (state.isRunningLocal) { const rbacConfigPath = join(currentDirName, "rbac.test.csv"); await fs.writeFile(rbacConfigPath, state.rbacConfig, "utf8"); @@ -212,9 +190,7 @@ export async function createRbacConfig( }); } -export async function updateRbacConfig( - state: RHDHDeploymentState, -): Promise { +export async function updateRbacConfig(state: RHDHDeploymentState): Promise { if (state.isRunningLocal) { const rbacConfigPath = join(currentDirName, "rbac.test.csv"); await fs.writeFile(rbacConfigPath, state.rbacConfig, "utf8"); @@ -227,14 +203,8 @@ export async function updateRbacConfig( }); } -export async function loadDynamicPluginsConfig( - state: RHDHDeploymentState, -): Promise { - const configPath = join( - currentDirName, - "yamls", - "dynamic-plugins-config.yaml", - ); +export async function loadDynamicPluginsConfig(state: RHDHDeploymentState): Promise { + const configPath = join(currentDirName, "yamls", "dynamic-plugins-config.yaml"); const yamlContent = await fs.readFile(configPath, "utf8"); const configData: unknown = yaml.parse(yamlContent); @@ -249,23 +219,11 @@ export async function createDynamicPluginsConfig( updateAppConfigFn: (state: RHDHDeploymentState) => Promise, ): Promise { if (state.isRunningLocal) { - const dynamicPluginsConfigPath = join( - currentDirName, - "dynamic-plugins.test.yaml", - ); + const dynamicPluginsConfigPath = join(currentDirName, "dynamic-plugins.test.yaml"); const dynamicPluginsConfigYaml = yaml.stringify(state.dynamicPluginsConfig); - await fs.writeFile( - dynamicPluginsConfigPath, - dynamicPluginsConfigYaml, - "utf8", - ); - console.log( - `Dynamic plugins config written to ${dynamicPluginsConfigPath}`, - ); - setAppConfigProperty( - "dynamicPlugins.rootDirectory", - rootDirName + "/dynamic-plugins-root", - ); + await fs.writeFile(dynamicPluginsConfigPath, dynamicPluginsConfigYaml, "utf8"); + console.log(`Dynamic plugins config written to ${dynamicPluginsConfigPath}`); + setAppConfigProperty("dynamicPlugins.rootDirectory", rootDirName + "/dynamic-plugins-root"); await updateAppConfigFn(state); return; } @@ -275,23 +233,12 @@ export async function createDynamicPluginsConfig( }); } -export async function updateDynamicPluginsConfig( - state: RHDHDeploymentState, -): Promise { +export async function updateDynamicPluginsConfig(state: RHDHDeploymentState): Promise { if (state.isRunningLocal) { - const dynamicPluginsConfigPath = join( - currentDirName, - "dynamic-plugins.test.yaml", - ); + const dynamicPluginsConfigPath = join(currentDirName, "dynamic-plugins.test.yaml"); const dynamicPluginsConfigYaml = yaml.stringify(state.dynamicPluginsConfig); - await fs.writeFile( - dynamicPluginsConfigPath, - dynamicPluginsConfigYaml, - "utf8", - ); - console.log( - `Dynamic plugins config updated in ${dynamicPluginsConfigPath}`, - ); + await fs.writeFile(dynamicPluginsConfigPath, dynamicPluginsConfigYaml, "utf8"); + console.log(`Dynamic plugins config updated in ${dynamicPluginsConfigPath}`); console.log( "Dynamic plugins config in dynamic-plugins.test.yaml has no effect on local deployment. Make sure to update the app-config.test.yaml file to use the dynamic-plugins-root directory and your plugin are already copied there.", ); @@ -303,17 +250,14 @@ export async function updateDynamicPluginsConfig( }); } -export async function loadBackstageCR( - state: RHDHDeploymentState, -): Promise { +export async function loadBackstageCR(state: RHDHDeploymentState): Promise { const configPath = join(currentDirName, "yamls", "backstage.yaml"); const parsed: unknown = await readYamlToJson(configPath); if (!isBackstageCr(parsed)) { throw new Error("Invalid Backstage CR config"); } const imageRegistry = process.env.IMAGE_REGISTRY ?? "quay.io"; - const imageRepo = - process.env.IMAGE_REPO ?? process.env.QUAY_REPO ?? undefined; + const imageRepo = process.env.IMAGE_REPO ?? process.env.QUAY_REPO ?? undefined; const tagName = process.env.TAG_NAME; expect(imageRepo, "IMAGE_REPO or QUAY_REPO must be set").toBeTruthy(); expect(tagName, "TAG_NAME must be set").toBeTruthy(); @@ -382,14 +326,10 @@ function startLocalBackstageProcess(state: RHDHDeploymentState): void { }, ); state.runningProcess.unref(); - console.log( - `Local production server started with PID: ${state.runningProcess.pid}`, - ); + console.log(`Local production server started with PID: ${state.runningProcess.pid}`); } -export async function createBackstageDeployment( - state: RHDHDeploymentState, -): Promise { +export async function createBackstageDeployment(state: RHDHDeploymentState): Promise { try { if (state.isRunningLocal) { startLocalBackstageProcess(state); diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-logs.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-logs.ts index ffe70801c4..885019aa2b 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-logs.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-logs.ts @@ -1,11 +1,9 @@ -import * as k8s from "@kubernetes/client-node"; import stream from "stream"; + +import * as k8s from "@kubernetes/client-node"; + import { getErrorMessage, hasErrorResponse } from "../errors"; -import { - RHDHDeploymentState, - sleep, - syncedLogRegex, -} from "./rhdh-deployment-types"; +import { RHDHDeploymentState, sleep, syncedLogRegex } from "./rhdh-deployment-types"; async function resolvePodName( state: RHDHDeploymentState, @@ -118,16 +116,9 @@ export async function followPodLogs( const resolvedPodName = await resolvePodName(state, podName, podLabels); try { - return await streamPodLogsUntilMatch( - state, - resolvedPodName, - searchString, - timeoutMs, - ); + return await streamPodLogsUntilMatch(state, resolvedPodName, searchString, timeoutMs); } catch (error) { - const message = hasErrorResponse(error) - ? error.body?.message - : getErrorMessage(error); + const message = hasErrorResponse(error) ? error.body?.message : getErrorMessage(error); console.log(`Error: ${message}`); throw new Error( `Timeout waiting for string "${searchString}" in logs after ${timeoutMs}ms. Error: ${message}`, diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-types.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-types.ts index e8138d36b8..90e8275a43 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-types.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-types.ts @@ -1,8 +1,9 @@ -import { GroupEntity, UserEntity } from "@backstage/catalog-model"; import { ChildProcess } from "child_process"; -import * as k8s from "@kubernetes/client-node"; import { resolve as resolvePath } from "path"; +import { GroupEntity, UserEntity } from "@backstage/catalog-model"; +import * as k8s from "@kubernetes/client-node"; + export type YamlConfig = Record; export interface DynamicPluginConfig { @@ -70,9 +71,7 @@ export function isBackstageCr(value: unknown): value is BackstageCr { ); } -export function isDynamicPluginsConfig( - value: unknown, -): value is DynamicPluginsConfig { +export function isDynamicPluginsConfig(value: unknown): value is DynamicPluginsConfig { if (!isRecord(value)) { return false; } @@ -80,9 +79,7 @@ export function isDynamicPluginsConfig( return ( plugins === undefined || (Array.isArray(plugins) && - plugins.every( - (plugin) => isRecord(plugin) && typeof plugin.package === "string", - )) + plugins.every((plugin) => isRecord(plugin) && typeof plugin.package === "string")) ); } diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-wait.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-wait.ts index 436e8fb850..951ba12300 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-wait.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-wait.ts @@ -1,10 +1,7 @@ import * as k8s from "@kubernetes/client-node"; + import { getErrorMessage, hasErrorResponse } from "../errors"; -import { - BackstageCr, - RHDHDeploymentState, - sleep, -} from "./rhdh-deployment-types"; +import { BackstageCr, RHDHDeploymentState, sleep } from "./rhdh-deployment-types"; const BACKSTAGE_LABELS = { "app.kubernetes.io/name": "backstage", @@ -20,9 +17,7 @@ function buildLabelSelector(instanceName: string): string { .join(","); } -export async function getDeploymentGeneration( - state: RHDHDeploymentState, -): Promise { +export async function getDeploymentGeneration(state: RHDHDeploymentState): Promise { const labelSelector = buildLabelSelector(state.instanceName); const deployments = await state.appsV1Api.listNamespacedDeployment( @@ -50,8 +45,7 @@ export async function waitForConfigReconciled( } const baseline = - state.configReconcileBaselineGeneration ?? - (await getDeploymentGeneration(state)); + state.configReconcileBaselineGeneration ?? (await getDeploymentGeneration(state)); const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { @@ -65,9 +59,7 @@ export async function waitForConfigReconciled( await sleep(1000); } - console.log( - `[INFO] No deployment generation change after ${timeoutMs}ms, proceeding`, - ); + console.log(`[INFO] No deployment generation change after ${timeoutMs}ms, proceeding`); } function hasRolloutStarted( @@ -77,23 +69,17 @@ function hasRolloutStarted( isProgressing: boolean, ): boolean { return ( - currentGeneration > initialGeneration || - observedGeneration < currentGeneration || - isProgressing + currentGeneration > initialGeneration || observedGeneration < currentGeneration || isProgressing ); } -function isDeploymentReady( - deployment: k8s.V1Deployment, - cr: BackstageCr, -): boolean { +function isDeploymentReady(deployment: k8s.V1Deployment, cr: BackstageCr): boolean { const conditions = deployment.status?.conditions ?? []; const currentGeneration = deployment.metadata?.generation ?? 0; const observedGeneration = deployment.status?.observedGeneration ?? 0; const isAvailable = conditions.some( - (condition) => - condition.type === "Available" && condition.status === "True", + (condition) => condition.type === "Available" && condition.status === "True", ); const isProgressingWithRollout = conditions.some( @@ -157,17 +143,11 @@ async function waitForRolloutStart( const currentGeneration = deployment.metadata?.generation ?? 0; const observedGeneration = deployment.status?.observedGeneration ?? 0; const isProgressing = conditions.some( - (condition) => - condition.type === "Progressing" && condition.status === "True", + (condition) => condition.type === "Progressing" && condition.status === "True", ); if ( - hasRolloutStarted( - initialGeneration, - currentGeneration, - observedGeneration, - isProgressing, - ) + hasRolloutStarted(initialGeneration, currentGeneration, observedGeneration, isProgressing) ) { rolloutStarted = true; console.log( @@ -243,18 +223,15 @@ async function pollDeploymentReady( await sleep(5000); } catch (error) { if (Date.now() - startTime >= timeoutMs) { - throw new Error( - `Timeout waiting for deployment to be ready: ${getErrorMessage(error)}`, - { cause: error }, - ); + throw new Error(`Timeout waiting for deployment to be ready: ${getErrorMessage(error)}`, { + cause: error, + }); } await sleep(5000); } } - throw new Error( - `Timeout waiting for deployment to be ready after ${timeoutMs}ms`, - ); + throw new Error(`Timeout waiting for deployment to be ready after ${timeoutMs}ms`); } export async function waitForDeploymentReady( @@ -293,18 +270,15 @@ export async function waitForNamespaceActive( await sleep(1000); } catch (error) { if (Date.now() - startTime >= timeoutMs) { - throw new Error( - `Timeout waiting for namespace to be active: ${getErrorMessage(error)}`, - { cause: error }, - ); + throw new Error(`Timeout waiting for namespace to be active: ${getErrorMessage(error)}`, { + cause: error, + }); } await sleep(1000); } } - throw new Error( - `Timeout waiting for namespace to be active after ${timeoutMs}ms`, - ); + throw new Error(`Timeout waiting for namespace to be active after ${timeoutMs}ms`); } export async function ensureBackstageCRIsAvailable( @@ -328,9 +302,7 @@ export async function ensureBackstageCRIsAvailable( ); return; } catch (error) { - console.log( - `Timeout waiting for Backstage CRD to be available: ${getErrorMessage(error)}`, - ); + console.log(`Timeout waiting for Backstage CRD to be available: ${getErrorMessage(error)}`); if (Date.now() - startTime >= timeoutMs) { throw new Error( `Timeout waiting for Backstage CRD to be available: ${getErrorMessage(error)}`, @@ -340,9 +312,7 @@ export async function ensureBackstageCRIsAvailable( await sleep(5000); } } - throw new Error( - `Timeout waiting for Backstage CRD to be available after ${timeoutMs}ms`, - ); + throw new Error(`Timeout waiting for Backstage CRD to be available after ${timeoutMs}ms`); } export async function deleteNamespaceIfExists( @@ -369,9 +339,7 @@ export async function deleteNamespaceIfExists( throw error; } } - throw new Error( - `Timeout waiting for namespace to be deleted after ${timeoutMs}ms`, - ); + throw new Error(`Timeout waiting for namespace to be deleted after ${timeoutMs}ms`); } catch (e) { if (hasErrorResponse(e) && e.response?.statusCode === 404) { return; diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts index 7c978c4632..b9c9b6dedb 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts @@ -1,7 +1,9 @@ -import * as k8s from "@kubernetes/client-node"; import { ChildProcess } from "child_process"; + +import * as k8s from "@kubernetes/client-node"; import { expect } from "@playwright/test"; import { v4 as uuidv4 } from "uuid"; + import { enableGithubLoginWithIngestion, enableGitlabLoginWithIngestion, @@ -153,11 +155,7 @@ class RHDHDeployment implements RHDHDeploymentState { return this; } - setConfigProperty( - config: Record, - path: string, - value: unknown, - ): RHDHDeployment { + setConfigProperty(config: Record, path: string, value: unknown): RHDHDeployment { const parts = path.split("."); let current: Record = config; @@ -199,10 +197,7 @@ class RHDHDeployment implements RHDHDeploymentState { return this.getConfig(this.appConfig); } - setDynamicPluginsConfigProperty( - path: string, - value: unknown, - ): RHDHDeployment { + setDynamicPluginsConfigProperty(path: string, value: unknown): RHDHDeployment { return this.setConfigProperty(this.dynamicPluginsConfig, path, value); } @@ -388,10 +383,7 @@ class RHDHDeployment implements RHDHDeploymentState { expect(isReachable).toBe(true); } - setDynamicPluginEnabled( - pluginName: string, - enabled: boolean, - ): RHDHDeployment { + setDynamicPluginEnabled(pluginName: string, enabled: boolean): RHDHDeployment { setDynamicPluginEnabledImpl(this, pluginName, enabled); return this; } @@ -434,8 +426,7 @@ class RHDHDeployment implements RHDHDeploymentState { async updateAllConfigs(): Promise { if (!this.isRunningLocal) { - this.configReconcileBaselineGeneration = - await this.getDeploymentGeneration(); + this.configReconcileBaselineGeneration = await this.getDeploymentGeneration(); } await this.updateAppConfig(); await this.updateDynamicPluginsConfig(); @@ -466,11 +457,7 @@ class RHDHDeployment implements RHDHDeploymentState { resolver: string, dangerouslyAllowSignInWithoutUserInCatalog = false, ): Promise { - setOIDCResolverImpl( - this, - resolver, - dangerouslyAllowSignInWithoutUserInCatalog, - ); + setOIDCResolverImpl(this, resolver, dangerouslyAllowSignInWithoutUserInCatalog); return Promise.resolve(this); } @@ -478,11 +465,7 @@ class RHDHDeployment implements RHDHDeploymentState { resolver: string, dangerouslyAllowSignInWithoutUserInCatalog = false, ): Promise { - setMicrosoftResolverImpl( - this, - resolver, - dangerouslyAllowSignInWithoutUserInCatalog, - ); + setMicrosoftResolverImpl(this, resolver, dangerouslyAllowSignInWithoutUserInCatalog); return Promise.resolve(this); } @@ -490,11 +473,7 @@ class RHDHDeployment implements RHDHDeploymentState { resolver: string, dangerouslyAllowSignInWithoutUserInCatalog = false, ): Promise { - setGithubResolverImpl( - this, - resolver, - dangerouslyAllowSignInWithoutUserInCatalog, - ); + setGithubResolverImpl(this, resolver, dangerouslyAllowSignInWithoutUserInCatalog); return Promise.resolve(this); } @@ -507,11 +486,7 @@ class RHDHDeployment implements RHDHDeploymentState { resolver: string, dangerouslyAllowSignInWithoutUserInCatalog = false, ): Promise { - setGitlabResolverImpl( - this, - resolver, - dangerouslyAllowSignInWithoutUserInCatalog, - ); + setGitlabResolverImpl(this, resolver, dangerouslyAllowSignInWithoutUserInCatalog); return Promise.resolve(this); } @@ -525,33 +500,23 @@ class RHDHDeployment implements RHDHDeploymentState { parseGroupParentFromEntity = parseGroupParentFromEntity; checkUserIsIngestedInCatalog(users: string[]): Promise { - return checkUserIsIngestedInCatalog(this, users, () => - this.computeBackstageBackendUrl(), - ); + return checkUserIsIngestedInCatalog(this, users, () => this.computeBackstageBackendUrl()); } checkGroupIsIngestedInCatalog(groups: string[]): Promise { - return checkGroupIsIngestedInCatalog(this, groups, () => - this.computeBackstageBackendUrl(), - ); + return checkGroupIsIngestedInCatalog(this, groups, () => this.computeBackstageBackendUrl()); } checkUserIsInGroup(user: string, group: string): Promise { - return checkUserIsInGroup(this, user, group, () => - this.computeBackstageBackendUrl(), - ); + return checkUserIsInGroup(this, user, group, () => this.computeBackstageBackendUrl()); } checkGroupIsParentOfGroup(parent: string, child: string): Promise { - return checkGroupIsParentOfGroup(this, parent, child, () => - this.computeBackstageBackendUrl(), - ); + return checkGroupIsParentOfGroup(this, parent, child, () => this.computeBackstageBackendUrl()); } checkGroupIsChildOfGroup(child: string, parent: string): Promise { - return checkGroupIsChildOfGroup(this, child, parent, () => - this.computeBackstageBackendUrl(), - ); + return checkGroupIsChildOfGroup(this, child, parent, () => this.computeBackstageBackendUrl()); } checkUserHasAnnotation( @@ -559,12 +524,8 @@ class RHDHDeployment implements RHDHDeploymentState { annotationKey: string, expectedValue: string, ): Promise { - return checkUserHasAnnotation( - this, - user, - annotationKey, - expectedValue, - () => this.computeBackstageBackendUrl(), + return checkUserHasAnnotation(this, user, annotationKey, expectedValue, () => + this.computeBackstageBackendUrl(), ); } } diff --git a/e2e-tests/playwright/utils/common-auth-popup.ts b/e2e-tests/playwright/utils/common-auth-popup.ts index 583a1c6ade..1468312cd4 100644 --- a/e2e-tests/playwright/utils/common-auth-popup.ts +++ b/e2e-tests/playwright/utils/common-auth-popup.ts @@ -1,5 +1,5 @@ -import { authenticator } from "otplib"; import { expect, type Locator, type Page } from "@playwright/test"; +import { authenticator } from "otplib"; export async function waitForAuthPopupReady(popup: Page): Promise { await expect(async () => { @@ -75,9 +75,7 @@ async function findGitlabAuthorizeButton(popup: Page): Promise { buttonToClick = authorization; return true; } - if ( - await authorizationByText.isVisible({ timeout: 2000 }).catch(() => false) - ) { + if (await authorizationByText.isVisible({ timeout: 2000 }).catch(() => false)) { buttonToClick = authorizationByText; return true; } @@ -93,10 +91,7 @@ async function findGitlabAuthorizeButton(popup: Page): Promise { return buttonToClick; } -async function clickGitlabAuthorizeButton( - popup: Page, - authorizeButton: Locator, -): Promise { +async function clickGitlabAuthorizeButton(popup: Page, authorizeButton: Locator): Promise { await popup .getByRole("document") .click({ timeout: 1000 }) @@ -133,9 +128,7 @@ export async function handleGitlabPopupLogin( await popup.locator("#user_password").fill(password, { timeout: 5000 }); await popup.getByTestId("sign-in-button").click({ timeout: 5000 }); - await popup - .waitForLoadState("domcontentloaded", { timeout: 10000 }) - .catch(() => {}); + await popup.waitForLoadState("domcontentloaded", { timeout: 10000 }).catch(() => {}); const twoFactorInput = popup.locator("#user_otp_attempt"); if (await twoFactorInput.isVisible({ timeout: 5000 })) { @@ -166,18 +159,12 @@ async function fillMicrosoftCredentials( 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('[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 }); + 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"); diff --git a/e2e-tests/playwright/utils/common-browser.ts b/e2e-tests/playwright/utils/common-browser.ts index 0e8fe752e3..b68c6d97a0 100644 --- a/e2e-tests/playwright/utils/common-browser.ts +++ b/e2e-tests/playwright/utils/common-browser.ts @@ -1,14 +1,8 @@ -import { - type Browser, - type Cookie, - type Page, - type TestInfo, -} from "@playwright/test"; import * as path from "path"; -import { - startCoverageForPage, - stopCoverageForPage, -} from "../support/coverage/test"; + +import { type Browser, type Cookie, type Page, type TestInfo } from "@playwright/test"; + +import { startCoverageForPage, stopCoverageForPage } from "../support/coverage/test"; export function parseAuthStateCookies(content: string): Cookie[] { const parsed: unknown = JSON.parse(content); @@ -18,9 +12,7 @@ export function parseAuthStateCookies(content: string): Cookie[] { !("cookies" in parsed) || !Array.isArray(parsed.cookies) ) { - throw new TypeError( - "Invalid auth state: expected object with cookies array", - ); + throw new TypeError("Invalid auth state: expected object with cookies array"); } const rawCookies: unknown[] = parsed.cookies; const cookies = rawCookies.filter( @@ -55,10 +47,7 @@ export async function setupBrowser(browser: Browser, testInfo: TestInfo) { return { page, context }; } -export async function teardownBrowser( - page: Page, - testInfo: TestInfo, -): Promise { +export async function teardownBrowser(page: Page, testInfo: TestInfo): Promise { await stopCoverageForPage(page, testInfo); await page.close(); } diff --git a/e2e-tests/playwright/utils/common.ts b/e2e-tests/playwright/utils/common.ts index 9f3d6f152c..aaff2cc5ee 100644 --- a/e2e-tests/playwright/utils/common.ts +++ b/e2e-tests/playwright/utils/common.ts @@ -1,14 +1,10 @@ -import { UIhelper } from "./ui-helper"; -import { authenticator } from "otplib"; +import * as fs from "fs"; + import { test, Page } from "@playwright/test"; +import { authenticator } from "otplib"; + +import { getTranslations, getCurrentLanguage } from "../e2e/localization/locale"; import { SETTINGS_PAGE_COMPONENTS } from "../support/page-objects/page-obj"; -import * as fs from "fs"; -import { - getTranslations, - getCurrentLanguage, -} from "../e2e/localization/locale"; -import { getErrorMessage } from "./errors"; -import { parseAuthStateCookies } from "./common-browser"; import { handleGitHubPopupLogin, handleGitlabPopupLogin, @@ -16,6 +12,9 @@ import { handleMicrosoftAzurePopupLogin, handlePingFederatePopupLogin, } from "./common-auth-popup"; +import { parseAuthStateCookies } from "./common-browser"; +import { getErrorMessage } from "./errors"; +import { UIhelper } from "./ui-helper"; export { setupBrowser, teardownBrowser } from "./common-browser"; @@ -47,9 +46,7 @@ export class Common { }); await this.uiHelper.verifyHeading(t["rhdh"][lang]["signIn.page.title"]); - await this.uiHelper.clickButton( - t["core-components"][lang]["signIn.guestProvider.enter"], - ); + await this.uiHelper.clickButton(t["core-components"][lang]["signIn.guestProvider.enter"]); await this.uiHelper.waitForSideBarVisible(); } @@ -91,10 +88,7 @@ export class Common { (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.uiHelper.isTextVisible("too many codes have been submitted", 3000)) ) { // GitHub TOTP codes cannot be reused within ~30s; wait for the next window. await new Promise((resolve) => { @@ -141,24 +135,18 @@ export class Common { const sessionFileName = `authState_${userid}.json`; if (fs.existsSync(sessionFileName)) { - const cookies = parseAuthStateCookies( - fs.readFileSync(sessionFileName, "utf-8"), - ); + const cookies = parseAuthStateCookies(fs.readFileSync(sessionFileName, "utf-8")); await this.page.context().addCookies(cookies); console.log(`Reusing existing authentication state for user: ${userid}`); await this.page.goto("/"); await this.waitForLoad(12000); - await this.uiHelper.clickButton( - t["core-components"][lang]["signIn.title"], - ); + await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); await this.checkAndReauthorizeGithubApp(); } else { await this.logintoGithub(userid); await this.page.goto("/"); await this.waitForLoad(240000); - await this.uiHelper.clickButton( - t["core-components"][lang]["signIn.title"], - ); + await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); await this.checkAndReauthorizeGithubApp(); await this.uiHelper.waitForSideBarVisible(); await this.page.context().storageState({ path: sessionFileName }); @@ -207,15 +195,11 @@ export class Common { await this.uiHelper.clickButton( t["user-settings"][lang]["providerSettingsItem.buttonTitle.signIn"], ); - await this.uiHelper.clickButton( - t["core-components"][lang]["oauthRequestDialog.login"], - ); + await this.uiHelper.clickButton(t["core-components"][lang]["oauthRequestDialog.login"]); await this.checkAndReauthorizeGithubApp(); await this.uiHelper.waitForLoginBtnDisappear(); } else { - console.log( - '"Log in" button is not visible. Skipping login popup actions.', - ); + console.log('"Log in" button is not visible. Skipping login popup actions.'); } } @@ -274,11 +258,7 @@ export class Common { return handleGitHubPopupLogin(popup, username, password, twofactor); } - async githubLoginFromSettingsPage( - username: string, - password: string, - twofactor: string, - ) { + async githubLoginFromSettingsPage(username: string, password: string, twofactor: string) { await this.page.goto("/settings/auth-providers"); const [popup] = await Promise.all([ @@ -291,9 +271,7 @@ export class Common { ), ) .click(), - this.uiHelper.clickButton( - t["core-components"][lang]["oauthRequestDialog.login"], - ), + this.uiHelper.clickButton(t["core-components"][lang]["oauthRequestDialog.login"]), ]); return handleGitHubPopupLogin(popup, username, password, twofactor); diff --git a/e2e-tests/playwright/utils/kube-client-configmap.ts b/e2e-tests/playwright/utils/kube-client-configmap.ts index 474dd57e9d..f93806457c 100644 --- a/e2e-tests/playwright/utils/kube-client-configmap.ts +++ b/e2e-tests/playwright/utils/kube-client-configmap.ts @@ -1,16 +1,11 @@ import * as k8s from "@kubernetes/client-node"; import * as yaml from "js-yaml"; + import { hasErrorResponse } from "./errors"; -import { - APP_CONFIG_NAMES, - getKubeApiErrorMessage, - isRecord, -} from "./kube-client-helpers"; +import { APP_CONFIG_NAMES, getKubeApiErrorMessage, isRecord } from "./kube-client-helpers"; function hasAppConfigDataKey(data: Record): boolean { - return Object.keys(data).some( - (key) => key.includes("app-config") && key.endsWith(".yaml"), - ); + return Object.keys(data).some((key) => key.includes("app-config") && key.endsWith(".yaml")); } function resolveAppConfigDataKey( @@ -29,26 +24,21 @@ function resolveAppConfigDataKey( } return ( - dataKeys.find( - (key) => key.endsWith(".yaml") && key.includes("app-config"), - ) ?? dataKeys.find((key) => key.endsWith(".yaml")) + dataKeys.find((key) => key.endsWith(".yaml") && key.includes("app-config")) ?? + dataKeys.find((key) => key.endsWith(".yaml")) ); } export async function findAppConfigMapName( coreV1Api: k8s.CoreV1Api, - listConfigMaps: ( - namespace: string, - ) => Promise<{ body: { items: k8s.V1ConfigMap[] } }>, + listConfigMaps: (namespace: string) => Promise<{ body: { items: k8s.V1ConfigMap[] } }>, namespace: string, ): Promise { try { const configMapsResponse = await listConfigMaps(namespace); const configMaps = configMapsResponse.body.items; - console.log( - `Found ${configMaps.length} ConfigMaps in namespace ${namespace}`, - ); + console.log(`Found ${configMaps.length} ConfigMaps in namespace ${namespace}`); configMaps.forEach((cm) => { console.log(`ConfigMap: ${cm.metadata?.name}`); }); @@ -69,13 +59,9 @@ export async function findAppConfigMapName( } } - throw new Error( - `No suitable app-config ConfigMap found in namespace ${namespace}`, - ); + throw new Error(`No suitable app-config ConfigMap found in namespace ${namespace}`); } catch (error) { - console.error( - `Error finding app config ConfigMap: ${getKubeApiErrorMessage(error)}`, - ); + console.error(`Error finding app config ConfigMap: ${getKubeApiErrorMessage(error)}`); throw error; } } @@ -92,9 +78,7 @@ async function resolveConfigMapName( return configMapName; } catch (error) { if (hasErrorResponse(error) && error.response?.statusCode === 404) { - console.log( - `ConfigMap ${configMapName} not found, searching for alternatives...`, - ); + console.log(`ConfigMap ${configMapName} not found, searching for alternatives...`); return findAppConfigMap(namespace); } throw error; @@ -113,9 +97,7 @@ function applyTitleToConfigMap( const appConfigYaml = configMap.data[dataKey]; if (appConfigYaml === undefined || appConfigYaml === "") { - throw new Error( - `Data key '${dataKey}' is empty in ConfigMap '${actualConfigMapName}'`, - ); + throw new Error(`Data key '${dataKey}' is empty in ConfigMap '${actualConfigMapName}'`); } const parsedConfig: unknown = yaml.load(appConfigYaml); @@ -126,8 +108,7 @@ function applyTitleToConfigMap( } const appSection = parsedConfig.app; - const currentTitle = - typeof appSection.title === "string" ? appSection.title : undefined; + const currentTitle = typeof appSection.title === "string" ? appSection.title : undefined; console.log(`Current title: ${currentTitle ?? "(none)"}`); appSection.title = newTitle; console.log(`New title: ${newTitle}`); @@ -142,10 +123,7 @@ function applyTitleToConfigMap( export async function updateConfigMapTitleImpl( coreV1Api: k8s.CoreV1Api, - getConfigMap: ( - configmapName: string, - namespace: string, - ) => Promise<{ body: k8s.V1ConfigMap }>, + getConfigMap: (configmapName: string, namespace: string) => Promise<{ body: k8s.V1ConfigMap }>, findAppConfigMap: (namespace: string) => Promise, configMapName: string, namespace: string, @@ -159,16 +137,11 @@ export async function updateConfigMapTitleImpl( findAppConfigMap, ); - const configMapResponse = await getConfigMap( - actualConfigMapName, - namespace, - ); + const configMapResponse = await getConfigMap(actualConfigMapName, namespace); const configMap = configMapResponse.body; console.log(`Using ConfigMap: ${actualConfigMapName}`); - console.log( - `Available data keys: ${Object.keys(configMap.data ?? {}).join(", ")}`, - ); + console.log(`Available data keys: ${Object.keys(configMap.data ?? {}).join(", ")}`); const dataKeys = Object.keys(configMap.data ?? {}); const dataKey = resolveAppConfigDataKey(actualConfigMapName, dataKeys); @@ -182,19 +155,14 @@ export async function updateConfigMapTitleImpl( console.log(`Using data key: ${dataKey}`); applyTitleToConfigMap(configMap, actualConfigMapName, dataKey, newTitle); - await coreV1Api.replaceNamespacedConfigMap( - actualConfigMapName, - namespace, - configMap, - ); + await coreV1Api.replaceNamespacedConfigMap(actualConfigMapName, namespace, configMap); console.log( `ConfigMap '${actualConfigMapName}' updated successfully with new title: '${newTitle}'`, ); } catch (error) { console.error(`Error updating ConfigMap: ${getKubeApiErrorMessage(error)}`); - throw new Error( - `Failed to update ConfigMap: ${getKubeApiErrorMessage(error)}`, - { cause: error }, - ); + throw new Error(`Failed to update ConfigMap: ${getKubeApiErrorMessage(error)}`, { + cause: error, + }); } } diff --git a/e2e-tests/playwright/utils/kube-client-deployment-restart.ts b/e2e-tests/playwright/utils/kube-client-deployment-restart.ts index 7b68704c80..bedf7738e9 100644 --- a/e2e-tests/playwright/utils/kube-client-deployment-restart.ts +++ b/e2e-tests/playwright/utils/kube-client-deployment-restart.ts @@ -1,21 +1,14 @@ import { getKubeApiErrorMessage, sleep } from "./kube-client-helpers"; async function scaleDeploymentDown( - scaleDeployment: ( - deploymentName: string, - namespace: string, - replicas: number, - ) => Promise, + scaleDeployment: (deploymentName: string, namespace: string, replicas: number) => Promise, waitForDeploymentReady: ( deploymentName: string, namespace: string, expectedReplicas: number, timeout?: number, ) => Promise, - logPodConditionsForDeployment: ( - deploymentName: string, - namespace: string, - ) => Promise, + logPodConditionsForDeployment: (deploymentName: string, namespace: string) => Promise, deploymentName: string, namespace: string, ): Promise { @@ -29,11 +22,7 @@ async function scaleDeploymentDown( } async function scaleDeploymentUp( - scaleDeployment: ( - deploymentName: string, - namespace: string, - replicas: number, - ) => Promise, + scaleDeployment: (deploymentName: string, namespace: string, replicas: number) => Promise, waitForDeploymentReady: ( deploymentName: string, namespace: string, @@ -49,32 +38,20 @@ async function scaleDeploymentUp( } export async function restartDeploymentImpl( - scaleDeployment: ( - deploymentName: string, - namespace: string, - replicas: number, - ) => Promise, + scaleDeployment: (deploymentName: string, namespace: string, replicas: number) => Promise, waitForDeploymentReady: ( deploymentName: string, namespace: string, expectedReplicas: number, timeout?: number, ) => Promise, - logPodConditionsForDeployment: ( - deploymentName: string, - namespace: string, - ) => Promise, - logDeploymentEvents: ( - deploymentName: string, - namespace: string, - ) => Promise, + logPodConditionsForDeployment: (deploymentName: string, namespace: string) => Promise, + logDeploymentEvents: (deploymentName: string, namespace: string) => Promise, deploymentName: string, namespace: string, ): Promise { try { - console.log( - `Starting deployment restart for ${deploymentName} in namespace ${namespace}`, - ); + console.log(`Starting deployment restart for ${deploymentName} in namespace ${namespace}`); await scaleDeploymentDown( scaleDeployment, waitForDeploymentReady, @@ -82,15 +59,8 @@ export async function restartDeploymentImpl( deploymentName, namespace, ); - await scaleDeploymentUp( - scaleDeployment, - waitForDeploymentReady, - deploymentName, - namespace, - ); - console.log( - `Restart of deployment ${deploymentName} completed successfully.`, - ); + await scaleDeploymentUp(scaleDeployment, waitForDeploymentReady, deploymentName, namespace); + console.log(`Restart of deployment ${deploymentName} completed successfully.`); } catch (error) { console.error( `Error during deployment restart: Deployment '${deploymentName}' in namespace '${namespace}': ${getKubeApiErrorMessage(error)}`, diff --git a/e2e-tests/playwright/utils/kube-client-deployment-scale.ts b/e2e-tests/playwright/utils/kube-client-deployment-scale.ts index f3014bf658..ebf2639a87 100644 --- a/e2e-tests/playwright/utils/kube-client-deployment-scale.ts +++ b/e2e-tests/playwright/utils/kube-client-deployment-scale.ts @@ -1,19 +1,13 @@ import * as k8s from "@kubernetes/client-node"; -import { - getErrorStatusCode, - getKubeApiErrorMessage, - sleep, -} from "./kube-client-helpers"; + +import { getErrorStatusCode, getKubeApiErrorMessage, sleep } from "./kube-client-helpers"; export async function getDeploymentPodSelectorImpl( appsApi: k8s.AppsV1Api, deploymentName: string, namespace: string, ): Promise { - const response = await appsApi.readNamespacedDeployment( - deploymentName, - namespace, - ); + const response = await appsApi.readNamespacedDeployment(deploymentName, namespace); const matchLabels = response.body.spec?.selector?.matchLabels ?? {}; const entries = Object.entries(matchLabels); if (entries.length === 0) { @@ -55,8 +49,7 @@ async function handleScaleRetry( deploymentName: string, ): Promise { const statusCode = getErrorStatusCode(error); - const isRetryable = - statusCode === 404 || statusCode === 503 || statusCode === 429; + const isRetryable = statusCode === 404 || statusCode === 503 || statusCode === 429; if (isRetryable && attempt < maxRetries) { const delay = attempt * 2000; @@ -84,17 +77,10 @@ export async function scaleDeploymentImpl( for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await patchDeploymentScale(appsApi, deploymentName, namespace, replicas); - console.log( - `Deployment ${deploymentName} scaled to ${replicas} replicas.`, - ); + console.log(`Deployment ${deploymentName} scaled to ${replicas} replicas.`); return; } catch (error) { - const shouldRetry = await handleScaleRetry( - error, - attempt, - maxRetries, - deploymentName, - ); + const shouldRetry = await handleScaleRetry(error, attempt, maxRetries, deploymentName); if (!shouldRetry) { return; } diff --git a/e2e-tests/playwright/utils/kube-client-deployment-wait.ts b/e2e-tests/playwright/utils/kube-client-deployment-wait.ts index 170b5f97c1..c21925b18d 100644 --- a/e2e-tests/playwright/utils/kube-client-deployment-wait.ts +++ b/e2e-tests/playwright/utils/kube-client-deployment-wait.ts @@ -1,19 +1,10 @@ import * as k8s from "@kubernetes/client-node"; -import { - getKubeApiErrorMessage, - PodFailureResult, - sleep, -} from "./kube-client-helpers"; + +import { getKubeApiErrorMessage, PodFailureResult, sleep } from "./kube-client-helpers"; export interface DeploymentDiagnostics { - logDeploymentEvents: ( - deploymentName: string, - namespace: string, - ) => Promise; - logReplicaSetStatus: ( - deploymentName: string, - namespace: string, - ) => Promise; + logDeploymentEvents: (deploymentName: string, namespace: string) => Promise; + logReplicaSetStatus: (deploymentName: string, namespace: string) => Promise; logPodEvents: (namespace: string, labelSelector: string) => Promise; logPodConditions: (namespace: string, labelSelector: string) => Promise; logPodContainerLogs: ( @@ -30,21 +21,13 @@ async function handlePodFailureDuringWait( finalLabelSelector: string, podFailure: PodFailureResult, ): Promise { - console.error( - `Pod failure detected: ${podFailure.message}. Logging events and pod logs...`, - ); + console.error(`Pod failure detected: ${podFailure.message}. Logging events and pod logs...`); await diagnostics.logDeploymentEvents(deploymentName, namespace); await diagnostics.logReplicaSetStatus(deploymentName, namespace); await diagnostics.logPodEvents(namespace, finalLabelSelector); await diagnostics.logPodConditions(namespace, finalLabelSelector); - await diagnostics.logPodContainerLogs( - namespace, - finalLabelSelector, - podFailure.containerName, - ); - throw new Error( - `Deployment ${deploymentName} failed to start: ${podFailure.message}`, - ); + await diagnostics.logPodContainerLogs(namespace, finalLabelSelector, podFailure.containerName); + throw new Error(`Deployment ${deploymentName} failed to start: ${podFailure.message}`); } function logDeploymentStatus(response: { body: k8s.V1Deployment }): number { @@ -77,10 +60,7 @@ async function checkDeploymentReplicaStatus( podSelector: string, finalLabelSelector: string, ): Promise { - const response = await appsApi.readNamespacedDeployment( - deploymentName, - namespace, - ); + const response = await appsApi.readNamespacedDeployment(deploymentName, namespace); const availableReplicas = logDeploymentStatus(response); if (expectedReplicas > 0 && podSelector !== "") { @@ -99,9 +79,7 @@ async function checkDeploymentReplicaStatus( await logPodConditions(namespace, podSelector); if (availableReplicas === expectedReplicas) { - console.log( - `Deployment ${deploymentName} is ready with ${availableReplicas} replicas.`, - ); + console.log(`Deployment ${deploymentName} is ready with ${availableReplicas} replicas.`); return true; } @@ -118,10 +96,7 @@ async function logDeploymentWaitProgress( namespace: string, expectedReplicas: number, ): Promise { - const response = await appsApi.readNamespacedDeployment( - deploymentName, - namespace, - ); + const response = await appsApi.readNamespacedDeployment(deploymentName, namespace); const readyReplicas = response.body.status?.readyReplicas ?? 0; console.log( `Waiting for ${deploymentName} to become ready (${readyReplicas}/${expectedReplicas} ready)...`, @@ -134,9 +109,7 @@ async function logDeploymentTimeoutDiagnostics( namespace: string, finalLabelSelector: string, ): Promise { - console.error( - `Timeout waiting for deployment ${deploymentName}. Collecting diagnostics...`, - ); + console.error(`Timeout waiting for deployment ${deploymentName}. Collecting diagnostics...`); await diagnostics.logDeploymentEvents(deploymentName, namespace); await diagnostics.logReplicaSetStatus(deploymentName, namespace); await diagnostics.logPodEvents(namespace, finalLabelSelector); @@ -145,10 +118,7 @@ async function logDeploymentTimeoutDiagnostics( export async function waitForDeploymentReadyImpl( appsApi: k8s.AppsV1Api, - getDeploymentPodSelector: ( - deploymentName: string, - namespace: string, - ) => Promise, + getDeploymentPodSelector: (deploymentName: string, namespace: string) => Promise, checkPodFailureStates: ( namespace: string, labelSelector: string, @@ -185,17 +155,10 @@ export async function waitForDeploymentReadyImpl( } if (Date.now() > progressLogStart) { - await logDeploymentWaitProgress( - appsApi, - deploymentName, - namespace, - expectedReplicas, - ); + await logDeploymentWaitProgress(appsApi, deploymentName, namespace, expectedReplicas); } } catch (error) { - console.error( - `Error checking deployment status: ${getKubeApiErrorMessage(error)}`, - ); + console.error(`Error checking deployment status: ${getKubeApiErrorMessage(error)}`); if (isPodStartupFailure(error)) { throw error; } @@ -204,12 +167,7 @@ export async function waitForDeploymentReadyImpl( await sleep(checkInterval); } - await logDeploymentTimeoutDiagnostics( - diagnostics, - deploymentName, - namespace, - finalLabelSelector, - ); + await logDeploymentTimeoutDiagnostics(diagnostics, deploymentName, namespace, finalLabelSelector); throw new Error( `Deployment ${deploymentName} did not become ready in time (timeout: ${timeout / 1000}s).`, ); diff --git a/e2e-tests/playwright/utils/kube-client-diagnostics-events.ts b/e2e-tests/playwright/utils/kube-client-diagnostics-events.ts index d91754dbf3..c24f97ff24 100644 --- a/e2e-tests/playwright/utils/kube-client-diagnostics-events.ts +++ b/e2e-tests/playwright/utils/kube-client-diagnostics-events.ts @@ -1,4 +1,5 @@ import * as k8s from "@kubernetes/client-node"; + import { DEFAULT_BACKSTAGE_LABEL_SELECTOR, formatEventTimestamp, @@ -22,21 +23,14 @@ function collectPodNames( }); allPodsResponse.body.items.forEach((pod) => { const name = pod.metadata?.name; - if ( - name !== undefined && - name !== "" && - name.includes(BACKSTAGE_POD_NAME_FRAGMENT) - ) { + if (name !== undefined && name !== "" && name.includes(BACKSTAGE_POD_NAME_FRAGMENT)) { podNames.add(name); } }); return podNames; } -function isRelevantPodEvent( - event: k8s.CoreV1Event, - podNames: Set, -): boolean { +function isRelevantPodEvent(event: k8s.CoreV1Event, podNames: Set): boolean { const involvedObject = event.involvedObject; if (involvedObject?.kind !== "Pod") { return false; @@ -53,9 +47,7 @@ function isRelevantPodEvent( function logPodEvent(event: k8s.CoreV1Event): void { const podName = podNameOrUnknown(event.involvedObject?.name); const timestamp = formatEventTimestamp(event); - console.log( - ` [${timestamp}] Pod ${podName}: [${event.type}] ${event.reason}: ${event.message}`, - ); + console.log(` [${timestamp}] Pod ${podName}: [${event.type}] ${event.reason}: ${event.message}`); } async function logExistingPodLogs( @@ -92,9 +84,7 @@ async function logExistingPodLogs( }); } } catch (logError) { - console.log( - ` Could not get logs from ${podName}: ${getKubeApiErrorMessage(logError)}`, - ); + console.log(` Could not get logs from ${podName}: ${getKubeApiErrorMessage(logError)}`); } } } @@ -139,11 +129,7 @@ export async function logPodEventsImpl( const selector = labelSelector ?? DEFAULT_BACKSTAGE_LABEL_SELECTOR; try { - const { podsResponse, podEvents } = await fetchPodEventContext( - coreV1Api, - namespace, - selector, - ); + const { podsResponse, podEvents } = await fetchPodEventContext(coreV1Api, namespace, selector); if (podEvents.length > 0) { console.log(`Recent pod events (last ${podEvents.length}):`); diff --git a/e2e-tests/playwright/utils/kube-client-diagnostics-pods.ts b/e2e-tests/playwright/utils/kube-client-diagnostics-pods.ts index 8dbfea39e3..e49ef15864 100644 --- a/e2e-tests/playwright/utils/kube-client-diagnostics-pods.ts +++ b/e2e-tests/playwright/utils/kube-client-diagnostics-pods.ts @@ -1,4 +1,5 @@ import * as k8s from "@kubernetes/client-node"; + import { DEFAULT_BACKSTAGE_LABEL_SELECTOR, formatContainerStartedAt, @@ -10,9 +11,7 @@ function logWaitingContainerStatus( containerName: string, waiting: k8s.V1ContainerStateWaiting, ): void { - console.log( - ` ${containerName}: Waiting - ${waiting.reason}: ${waiting.message}`, - ); + console.log(` ${containerName}: Waiting - ${waiting.reason}: ${waiting.message}`); } function logRunningContainerStatus( @@ -36,9 +35,7 @@ function logTerminatedContainerStatus( } } -function logSingleContainerStatus( - containerStatus: k8s.V1ContainerStatus, -): void { +function logSingleContainerStatus(containerStatus: k8s.V1ContainerStatus): void { const containerName = containerStatus.name; const waiting = containerStatus.state?.waiting; const running = containerStatus.state?.running; @@ -96,10 +93,7 @@ export async function logPodConditionsImpl( const podName = podNameOrUnknown(pod.metadata?.name); const phase = pod.status?.phase; console.log(`Pod: ${podName} (Phase: ${phase})`); - console.log( - "Conditions:", - JSON.stringify(pod.status?.conditions, null, 2), - ); + console.log("Conditions:", JSON.stringify(pod.status?.conditions, null, 2)); logPodContainerStatuses(pod); } } catch (error) { @@ -115,9 +109,7 @@ async function readContainerLogs( namespace: string, containerName: string, ): Promise { - console.log( - `\n=== Pod ${podName} - Container ${containerName} Logs (last 100 lines) ===`, - ); + console.log(`\n=== Pod ${podName} - Container ${containerName} Logs (last 100 lines) ===`); const logs = await coreV1Api.readNamespacedPodLog( podName, namespace, @@ -143,10 +135,7 @@ async function readContainerLogs( console.log("(No logs available)"); } -function resolvePodContainers( - pod: k8s.V1Pod, - containerName?: string, -): Array<{ name: string }> { +function resolvePodContainers(pod: k8s.V1Pod, containerName?: string): Array<{ name: string }> { if (containerName !== undefined && containerName !== "") { return [{ name: containerName }]; } @@ -190,25 +179,18 @@ export async function logPodContainerLogsImpl( await readContainerLogs(coreV1Api, podName, namespace, cn); } catch (logError) { const errorMsg = getKubeApiErrorMessage(logError); - console.warn( - `Could not retrieve logs for pod ${podName} container ${cn}: ${errorMsg}`, - ); + console.warn(`Could not retrieve logs for pod ${podName} container ${cn}: ${errorMsg}`); } } } } catch (error) { - console.error( - `Error retrieving pod logs: ${getKubeApiErrorMessage(error)}`, - ); + console.error(`Error retrieving pod logs: ${getKubeApiErrorMessage(error)}`); } } export async function logPodConditionsForDeploymentImpl( logPodConditions: (namespace: string, labelSelector: string) => Promise, - getDeploymentPodSelector: ( - deploymentName: string, - namespace: string, - ) => Promise, + getDeploymentPodSelector: (deploymentName: string, namespace: string) => Promise, deploymentName: string, namespace: string, ): Promise { diff --git a/e2e-tests/playwright/utils/kube-client-diagnostics-replicasets.ts b/e2e-tests/playwright/utils/kube-client-diagnostics-replicasets.ts index 15a2857c06..df52acd050 100644 --- a/e2e-tests/playwright/utils/kube-client-diagnostics-replicasets.ts +++ b/e2e-tests/playwright/utils/kube-client-diagnostics-replicasets.ts @@ -1,12 +1,8 @@ import * as k8s from "@kubernetes/client-node"; -import { - getKubeApiErrorMessage, - podNameOrUnknown, -} from "./kube-client-helpers"; -function sortReplicaSetsByCreation( - replicaSets: k8s.V1ReplicaSet[], -): k8s.V1ReplicaSet[] { +import { getKubeApiErrorMessage, podNameOrUnknown } from "./kube-client-helpers"; + +function sortReplicaSetsByCreation(replicaSets: k8s.V1ReplicaSet[]): k8s.V1ReplicaSet[] { // oxlint-disable-next-line unicorn/no-array-sort -- es2022 lib has no Array#toSorted return [...replicaSets].sort((a: k8s.V1ReplicaSet, b: k8s.V1ReplicaSet) => { const aTime = a.metadata?.creationTimestamp?.getTime() ?? 0; @@ -69,10 +65,7 @@ export async function logReplicaSetStatusImpl( namespace: string, ): Promise { try { - const deployment = await appsApi.readNamespacedDeployment( - deploymentName, - namespace, - ); + const deployment = await appsApi.readNamespacedDeployment(deploymentName, namespace); const labelSelector = deployment.body.spec?.selector?.matchLabels; if (labelSelector === undefined) { diff --git a/e2e-tests/playwright/utils/kube-client-exec.ts b/e2e-tests/playwright/utils/kube-client-exec.ts index 4d2ea17bdc..e0c2e56e32 100644 --- a/e2e-tests/playwright/utils/kube-client-exec.ts +++ b/e2e-tests/playwright/utils/kube-client-exec.ts @@ -1,5 +1,7 @@ import * as stream from "stream"; + import * as k8s from "@kubernetes/client-node"; + import { getKubeApiErrorMessage } from "./kube-client-helpers"; function createOutputCaptureStreams(): { @@ -30,9 +32,7 @@ function createOutputCaptureStreams(): { function buildExecFailureMessage(status: k8s.V1Status, stderr: string): string { const statusMessage = - status.message !== undefined && status.message !== "" - ? status.message - : undefined; + status.message !== undefined && status.message !== "" ? status.message : undefined; const stderrMessage = stderr === "" ? "unknown error" : stderr; return statusMessage ?? stderrMessage; } diff --git a/e2e-tests/playwright/utils/kube-client-helpers.ts b/e2e-tests/playwright/utils/kube-client-helpers.ts index 2599c74c22..b53353223b 100644 --- a/e2e-tests/playwright/utils/kube-client-helpers.ts +++ b/e2e-tests/playwright/utils/kube-client-helpers.ts @@ -1,4 +1,5 @@ import * as k8s from "@kubernetes/client-node"; + import { getErrorMessage, hasErrorResponse, hasStatusCode } from "./errors"; export function isRecord(value: unknown): value is Record { @@ -79,9 +80,7 @@ export function formatEventTimestamp(event: k8s.CoreV1Event): string { return "unknown"; } -export function formatContainerStartedAt( - startedAt: Date | string | undefined, -): string { +export function formatContainerStartedAt(startedAt: Date | string | undefined): string { if (startedAt === undefined || startedAt === "") { return "unknown"; } @@ -110,9 +109,7 @@ export function getKubeApiErrorMessage(error: unknown): string { const response: unknown = error.response; if (isRecord(response) && typeof response.statusCode === "number") { const statusMessage = - typeof response.statusMessage === "string" - ? response.statusMessage - : "Unknown error"; + typeof response.statusMessage === "string" ? response.statusMessage : "Unknown error"; return `HTTP ${String(response.statusCode)}: ${statusMessage}`; } } @@ -144,10 +141,7 @@ export function getRhdhDeploymentName(): string { return `${releaseName}-developer-hub`; } -export function rejectAsError( - reject: (reason: Error) => void, - err: unknown, -): void { +export function rejectAsError(reject: (reason: Error) => void, err: unknown): void { reject(err instanceof Error ? err : new Error(getErrorMessage(err))); } diff --git a/e2e-tests/playwright/utils/kube-client-pod-failure.ts b/e2e-tests/playwright/utils/kube-client-pod-failure.ts index 70e39615c9..4229610ff1 100644 --- a/e2e-tests/playwright/utils/kube-client-pod-failure.ts +++ b/e2e-tests/playwright/utils/kube-client-pod-failure.ts @@ -1,9 +1,6 @@ import * as k8s from "@kubernetes/client-node"; -import { - getKubeApiErrorMessage, - PodFailureResult, - podNameOrUnknown, -} from "./kube-client-helpers"; + +import { getKubeApiErrorMessage, PodFailureResult, podNameOrUnknown } from "./kube-client-helpers"; const POD_READY_ERROR_REASONS = [ "Unhealthy", @@ -23,10 +20,7 @@ const CONTAINER_FAILURE_STATES = [ ] as const; function isTransientPvcSchedulingMessage(message: string): boolean { - return ( - message.includes("ephemeral volume") || - message.includes("persistentvolumeclaim") - ); + return message.includes("ephemeral volume") || message.includes("persistentvolumeclaim"); } function checkFailedPodPhase(pod: k8s.V1Pod): PodFailureResult | null { @@ -65,11 +59,7 @@ function checkPodReadyCondition( condition: k8s.V1PodCondition, ): PodFailureResult | null { const reason = condition.reason; - if ( - reason === undefined || - reason === "" || - reason === "ContainersNotReady" - ) { + if (reason === undefined || reason === "" || reason === "ContainersNotReady") { return null; } @@ -121,14 +111,8 @@ function checkWaitingContainerState( }; } - if ( - reason === "ContainerCreating" && - waiting.message !== undefined && - waiting.message !== "" - ) { - console.log( - `Pod ${podName} container ${containerName} is being created: ${waiting.message}`, - ); + if (reason === "ContainerCreating" && waiting.message !== undefined && waiting.message !== "") { + console.log(`Pod ${podName} container ${containerName} is being created: ${waiting.message}`); } return null; @@ -162,11 +146,7 @@ function checkContainerStatuses(pod: k8s.V1Pod): PodFailureResult | null { for (const containerStatus of containerStatuses) { const waiting = containerStatus.state?.waiting; if (waiting !== undefined) { - const waitingResult = checkWaitingContainerState( - pod, - containerStatus, - waiting, - ); + const waitingResult = checkWaitingContainerState(pod, containerStatus, waiting); if (waitingResult !== null) { return waitingResult; } @@ -174,11 +154,7 @@ function checkContainerStatuses(pod: k8s.V1Pod): PodFailureResult | null { const terminated = containerStatus.state?.terminated; if (terminated !== undefined) { - const terminatedResult = checkTerminatedContainerState( - pod, - containerStatus, - terminated, - ); + const terminatedResult = checkTerminatedContainerState(pod, containerStatus, terminated); if (terminatedResult !== null) { return terminatedResult; } @@ -189,11 +165,7 @@ function checkContainerStatuses(pod: k8s.V1Pod): PodFailureResult | null { } function checkSinglePodFailure(pod: k8s.V1Pod): PodFailureResult | null { - return ( - checkFailedPodPhase(pod) ?? - checkPodConditions(pod) ?? - checkContainerStatuses(pod) - ); + return checkFailedPodPhase(pod) ?? checkPodConditions(pod) ?? checkContainerStatuses(pod); } export async function checkPodFailureStatesImpl( @@ -225,9 +197,7 @@ export async function checkPodFailureStatesImpl( return null; } catch (error) { - console.error( - `Error checking pod failure states: ${getKubeApiErrorMessage(error)}`, - ); + console.error(`Error checking pod failure states: ${getKubeApiErrorMessage(error)}`); return null; } } diff --git a/e2e-tests/playwright/utils/kube-client.ts b/e2e-tests/playwright/utils/kube-client.ts index dc613030a8..883decc602 100644 --- a/e2e-tests/playwright/utils/kube-client.ts +++ b/e2e-tests/playwright/utils/kube-client.ts @@ -1,26 +1,18 @@ import * as k8s from "@kubernetes/client-node"; import { V1ConfigMap } from "@kubernetes/client-node"; + import { hasStatusCode } from "./errors"; -import { - findAppConfigMapName, - updateConfigMapTitleImpl, -} from "./kube-client-configmap"; -import { - logDeploymentEventsImpl, - logPodEventsImpl, -} from "./kube-client-diagnostics-events"; +import { findAppConfigMapName, updateConfigMapTitleImpl } from "./kube-client-configmap"; +import { restartDeploymentImpl } from "./kube-client-deployment-restart"; +import { getDeploymentPodSelectorImpl, scaleDeploymentImpl } from "./kube-client-deployment-scale"; +import { waitForDeploymentReadyImpl } from "./kube-client-deployment-wait"; +import { logDeploymentEventsImpl, logPodEventsImpl } from "./kube-client-diagnostics-events"; import { logPodConditionsForDeploymentImpl, logPodContainerLogsImpl, logPodConditionsImpl, } from "./kube-client-diagnostics-pods"; import { logReplicaSetStatusImpl } from "./kube-client-diagnostics-replicasets"; -import { restartDeploymentImpl } from "./kube-client-deployment-restart"; -import { - getDeploymentPodSelectorImpl, - scaleDeploymentImpl, -} from "./kube-client-deployment-scale"; -import { waitForDeploymentReadyImpl } from "./kube-client-deployment-wait"; import { execPodCommandImpl } from "./kube-client-exec"; import { formatKubeErrorLog, @@ -72,22 +64,15 @@ export class KubeClient { this.coreV1Api = this.kc.makeApiClient(k8s.CoreV1Api); this.customObjectsApi = this.kc.makeApiClient(k8s.CustomObjectsApi); } catch (e) { - console.log( - `Error initializing KubeClient: ${getKubeApiErrorMessage(e)}`, - ); + console.log(`Error initializing KubeClient: ${getKubeApiErrorMessage(e)}`); throw e; } } async getConfigMap(configmapName: string, namespace: string) { try { - console.log( - `Getting configmap ${configmapName} from namespace ${namespace}`, - ); - return await this.coreV1Api.readNamespacedConfigMap( - configmapName, - namespace, - ); + console.log(`Getting configmap ${configmapName} from namespace ${namespace}`); + return await this.coreV1Api.readNamespacedConfigMap(configmapName, namespace); } catch (e) { console.log(formatKubeErrorLog(e)); throw e; @@ -105,20 +90,14 @@ export class KubeClient { } findAppConfigMap(namespace: string): Promise { - return findAppConfigMapName( - this.coreV1Api, - (ns) => this.listConfigMaps(ns), - namespace, - ); + return findAppConfigMapName(this.coreV1Api, (ns) => this.listConfigMaps(ns), namespace); } async getNamespaceByName(name: string): Promise { try { return (await this.coreV1Api.readNamespace(name)).body; } catch (e) { - console.log( - `Error getting namespace ${name}: ${getKubeApiErrorMessage(e)}`, - ); + console.log(`Error getting namespace ${name}: ${getKubeApiErrorMessage(e)}`); throw e; } } @@ -129,13 +108,7 @@ export class KubeClient { replicas: number, maxRetries: number = 3, ) { - return scaleDeploymentImpl( - this.appsApi, - deploymentName, - namespace, - replicas, - maxRetries, - ); + return scaleDeploymentImpl(this.appsApi, deploymentName, namespace, replicas, maxRetries); } async getSecret(secretName: string, namespace: string) { @@ -148,11 +121,7 @@ export class KubeClient { } } - async updateConfigMap( - configmapName: string, - namespace: string, - patch: object, - ) { + async updateConfigMap(configmapName: string, namespace: string, patch: object) { try { console.log("updateConfigMap called"); console.log("Namespace: ", namespace); @@ -160,9 +129,7 @@ export class KubeClient { const options = { headers: { "Content-type": k8s.PatchUtils.PATCH_FORMAT_JSON_PATCH }, }; - console.log( - `Updating configmap ${configmapName} in namespace ${namespace}`, - ); + console.log(`Updating configmap ${configmapName} in namespace ${namespace}`); await this.coreV1Api.patchNamespacedConfigMap( configmapName, namespace, @@ -180,11 +147,7 @@ export class KubeClient { } } - updateConfigMapTitle( - configMapName: string, - namespace: string, - newTitle: string, - ) { + updateConfigMapTitle(configMapName: string, namespace: string, newTitle: string) { return updateConfigMapTitleImpl( this.coreV1Api, (name, ns) => this.getConfigMap(name, ns), @@ -226,9 +189,7 @@ export class KubeClient { if (configMapName === undefined || configMapName === "") { throw new Error("ConfigMap metadata.name is required"); } - console.log( - `Creating configmap ${configMapName} in namespace ${namespace}`, - ); + console.log(`Creating configmap ${configMapName} in namespace ${namespace}`); return await this.coreV1Api.createNamespacedConfigMap(namespace, body); } catch (err) { console.log(getKubeApiErrorMessage(err)); @@ -280,9 +241,7 @@ export class KubeClient { try { await this.deleteNamespaceAndWait(namespace); } catch (err) { - console.log( - `Error deleting namespace ${namespace}: ${getKubeApiErrorMessage(err)}`, - ); + console.log(`Error deleting namespace ${namespace}: ${getKubeApiErrorMessage(err)}`); throw err; } } @@ -313,20 +272,14 @@ export class KubeClient { } } - async createOrUpdateSecret( - secret: k8s.V1Secret, - namespace: string, - ): Promise { + async createOrUpdateSecret(secret: k8s.V1Secret, namespace: string): Promise { const secretName = secret.metadata?.name; if (secretName === undefined || secretName === "") { throw new Error("Secret metadata.name is required"); } try { - const existing = await this.coreV1Api.readNamespacedSecret( - secretName, - namespace, - ); + const existing = await this.coreV1Api.readNamespacedSecret(secretName, namespace); const body = existing.body; body.data = { ...body.data, ...secret.data }; await this.coreV1Api.replaceNamespacedSecret(secretName, namespace, body); @@ -334,9 +287,7 @@ export class KubeClient { } catch (err: unknown) { const statusCode = getErrorStatusCode(err); if (statusCode === 404) { - console.log( - `Secret ${secretName} not found, creating in namespace ${namespace}`, - ); + console.log(`Secret ${secretName} not found, creating in namespace ${namespace}`); await this.createSecret(secret, namespace); console.log(`Secret ${secretName} created in namespace ${namespace}`); } else { @@ -385,8 +336,7 @@ export class KubeClient { restartDeployment(deploymentName: string, namespace: string) { return restartDeploymentImpl( (name, ns, replicas) => this.scaleDeployment(name, ns, replicas), - (name, ns, replicas, t) => - this.waitForDeploymentReady(name, ns, replicas, t), + (name, ns, replicas, t) => this.waitForDeploymentReady(name, ns, replicas, t), (name, ns) => this.logPodConditionsForDeployment(name, ns), (name, ns) => this.logDeploymentEvents(name, ns), deploymentName, @@ -394,15 +344,8 @@ export class KubeClient { ); } - private getDeploymentPodSelector( - deploymentName: string, - namespace: string, - ): Promise { - return getDeploymentPodSelectorImpl( - this.appsApi, - deploymentName, - namespace, - ); + private getDeploymentPodSelector(deploymentName: string, namespace: string): Promise { + return getDeploymentPodSelectorImpl(this.appsApi, deploymentName, namespace); } logPodConditionsForDeployment(deploymentName: string, namespace: string) { @@ -418,17 +361,8 @@ export class KubeClient { return logPodConditionsImpl(this.coreV1Api, namespace, labelSelector); } - logPodContainerLogs( - namespace: string, - labelSelector?: string, - containerName?: string, - ) { - return logPodContainerLogsImpl( - this.coreV1Api, - namespace, - labelSelector, - containerName, - ); + logPodContainerLogs(namespace: string, labelSelector?: string, containerName?: string) { + return logPodContainerLogsImpl(this.coreV1Api, namespace, labelSelector, containerName); } logPodEvents(namespace: string, labelSelector?: string) { @@ -440,18 +374,10 @@ export class KubeClient { } logReplicaSetStatus(deploymentName: string, namespace: string) { - return logReplicaSetStatusImpl( - this.coreV1Api, - this.appsApi, - deploymentName, - namespace, - ); + return logReplicaSetStatusImpl(this.coreV1Api, this.appsApi, deploymentName, namespace); } - async getServiceByLabel( - namespace: string, - labelSelector: string, - ): Promise { + async getServiceByLabel(namespace: string, labelSelector: string): Promise { try { const response = await this.coreV1Api.listNamespacedService( namespace, @@ -477,13 +403,6 @@ export class KubeClient { command: string[], timeout: number = 60000, ): Promise<{ stdout: string; stderr: string }> { - return execPodCommandImpl( - this.kc, - podName, - namespace, - containerName, - command, - timeout, - ); + return execPodCommandImpl(this.kc, podName, namespace, containerName, command, timeout); } } diff --git a/e2e-tests/playwright/utils/postgres-config.ts b/e2e-tests/playwright/utils/postgres-config.ts index 78785c3c4a..61fececc4d 100644 --- a/e2e-tests/playwright/utils/postgres-config.ts +++ b/e2e-tests/playwright/utils/postgres-config.ts @@ -11,7 +11,9 @@ */ import { readFileSync, existsSync } from "fs"; + import { Client } from "pg"; + import { KubeClient } from "./kube-client"; /** @@ -27,9 +29,7 @@ function unescapeNewlines(value: string): string { * @param filePath - Path to the certificate file * @returns Certificate content with escaped newlines converted, or null if file doesn't exist */ -export function readCertificateFile( - filePath: string | undefined, -): string | null { +export function readCertificateFile(filePath: string | undefined): string | null { if (filePath === undefined || filePath === "") { return null; } @@ -78,18 +78,14 @@ export async function configurePostgresCredentials( POSTGRES_HOST: Buffer.from(credentials.host).toString("base64"), POSTGRES_PORT: Buffer.from(credentials.port ?? "5432").toString("base64"), PGSSLMODE: Buffer.from(credentials.sslMode ?? "require").toString("base64"), - NODE_EXTRA_CA_CERTS: Buffer.from( - "/opt/app-root/src/postgres-crt.pem", - ).toString("base64"), + NODE_EXTRA_CA_CERTS: Buffer.from("/opt/app-root/src/postgres-crt.pem").toString("base64"), }; if (credentials.user !== "") { data.POSTGRES_USER = Buffer.from(credentials.user).toString("base64"); } if (credentials.password !== "") { - data.POSTGRES_PASSWORD = Buffer.from(credentials.password).toString( - "base64", - ); + data.POSTGRES_PASSWORD = Buffer.from(credentials.password).toString("base64"); } if (credentials.database !== undefined && credentials.database !== "") { data.POSTGRES_DB = Buffer.from(credentials.database).toString("base64"); @@ -113,9 +109,7 @@ const SYSTEM_DATABASES = [ "azure_sys", ]; -function buildSslConfig( - certificatePath: string | undefined, -): { ca: string } | boolean { +function buildSslConfig(certificatePath: string | undefined): { ca: string } | boolean { if (certificatePath === undefined || certificatePath === "") { return true; } @@ -228,9 +222,7 @@ export async function clearDatabase(credentials: { const { succeeded, failed } = await dropUserDatabases(client, databases); - console.log( - `Database cleanup completed: ${succeeded.length} dropped, ${failed.length} failed`, - ); + console.log(`Database cleanup completed: ${succeeded.length} dropped, ${failed.length} failed`); if (succeeded.length > 0) { console.log(`Successfully dropped: ${succeeded.join(", ")}`); } @@ -238,10 +230,7 @@ export async function clearDatabase(credentials: { console.log(`Failed to drop: ${failed.join(", ")}`); } } catch (error) { - console.error( - "Failed to connect to database or retrieve database list:", - error, - ); + console.error("Failed to connect to database or retrieve database list:", error); throw error; } finally { await client.end(); diff --git a/e2e-tests/playwright/utils/ui-helper/class.ts b/e2e-tests/playwright/utils/ui-helper/class.ts index 3743aa44d4..cac7e08c57 100644 --- a/e2e-tests/playwright/utils/ui-helper/class.ts +++ b/e2e-tests/playwright/utils/ui-helper/class.ts @@ -1,4 +1,5 @@ import { Locator, Page } from "@playwright/test"; + import { SEARCH_OBJECTS_COMPONENTS } from "../../support/page-objects/page-obj"; import * as interaction from "./interaction"; import * as misc from "./misc"; @@ -27,17 +28,11 @@ export class UIhelper { } searchInputPlaceholder(searchText: string) { - return this.page.fill( - SEARCH_OBJECTS_COMPONENTS.placeholderSearch, - searchText, - ); + return this.page.fill(SEARCH_OBJECTS_COMPONENTS.placeholderSearch, searchText); } searchInputAriaLabel(searchText: string) { - return this.page.fill( - SEARCH_OBJECTS_COMPONENTS.ariaLabelSearch, - searchText, - ); + return this.page.fill(SEARCH_OBJECTS_COMPONENTS.ariaLabelSearch, searchText); } pressTab() { @@ -52,10 +47,7 @@ export class UIhelper { return interaction.uncheckCheckbox(this.page, text); } - clickButton( - label: string | RegExp, - options?: { exact?: boolean; force?: boolean }, - ) { + clickButton(label: string | RegExp, options?: { exact?: boolean; force?: boolean }) { return interaction.clickButton(this.page, label, options); } @@ -122,10 +114,7 @@ export class UIhelper { return navigation.goToSelfServicePage(this.page); } - verifyLink( - arg: string | { label: string }, - options?: { exact?: boolean; notVisible?: boolean }, - ) { + verifyLink(arg: string | { label: string }, options?: { exact?: boolean; notVisible?: boolean }) { return verification.verifyLink(this.page, arg, options); } @@ -177,11 +166,7 @@ export class UIhelper { return verification.waitForTextDisappear(this.page, text); } - verifyText( - text: string | RegExp, - exact: boolean = true, - timeout: number = 5000, - ) { + verifyText(text: string | RegExp, exact: boolean = true, timeout: number = 5000) { return verification.verifyText(this.page, text, exact, timeout); } @@ -190,11 +175,7 @@ export class UIhelper { } verifyPartialTextInSelector(selector: string, partialText: string) { - return verification.verifyPartialTextInSelector( - this.page, - selector, - partialText, - ); + return verification.verifyPartialTextInSelector(this.page, selector, partialText); } verifyColumnHeading(rowTexts: string[] | RegExp[], exact: boolean = true) { @@ -241,15 +222,8 @@ export class UIhelper { return table.verifyButtonURL(this.page, label, url, options); } - verifyRowInTableByUniqueText( - uniqueRowText: string, - cellTexts: string[] | RegExp[], - ) { - return table.verifyRowInTableByUniqueText( - this.page, - uniqueRowText, - cellTexts, - ); + verifyRowInTableByUniqueText(uniqueRowText: string, cellTexts: string[] | RegExp[]) { + return table.verifyRowInTableByUniqueText(this.page, uniqueRowText, cellTexts); } clickOnLinkInTableByUniqueText( @@ -257,23 +231,11 @@ export class UIhelper { linkText: string | RegExp, exact: boolean = true, ) { - return table.clickOnLinkInTableByUniqueText( - this.page, - uniqueRowText, - linkText, - exact, - ); + return table.clickOnLinkInTableByUniqueText(this.page, uniqueRowText, linkText, exact); } - clickOnButtonInTableByUniqueText( - uniqueRowText: string, - textOrLabel: string | RegExp, - ) { - return table.clickOnButtonInTableByUniqueText( - this.page, - uniqueRowText, - textOrLabel, - ); + clickOnButtonInTableByUniqueText(uniqueRowText: string, textOrLabel: string | RegExp) { + return table.clickOnButtonInTableByUniqueText(this.page, uniqueRowText, textOrLabel); } verifyLinkinCard(cardHeading: string, linkText: string, exact = true) { @@ -330,17 +292,8 @@ export class UIhelper { return misc.clickUnregisterButtonForDisplayedEntity(this.page, buttonName); } - verifyPluginRow( - text: string, - expectedEnabled: string, - expectedPreinstalled: string, - ) { - return table.verifyPluginRow( - this.page, - text, - expectedEnabled, - expectedPreinstalled, - ); + verifyPluginRow(text: string, expectedEnabled: string, expectedPreinstalled: string) { + return table.verifyPluginRow(this.page, text, expectedEnabled, expectedPreinstalled); } verifyTextInTooltip(text: string | RegExp) { diff --git a/e2e-tests/playwright/utils/ui-helper/interaction.ts b/e2e-tests/playwright/utils/ui-helper/interaction.ts index e2a7d10da6..637c847bb9 100644 --- a/e2e-tests/playwright/utils/ui-helper/interaction.ts +++ b/e2e-tests/playwright/utils/ui-helper/interaction.ts @@ -1,10 +1,8 @@ import { expect, Locator, Page } from "@playwright/test"; + import { getCardByText } from "../../support/page-objects/ui-locators"; import { getErrorMessage } from "../errors"; -import { - DEFAULT_CLICK_BUTTON_BY_TEXT_OPTIONS, - DEFAULT_CLICK_BUTTON_OPTIONS, -} from "./defaults"; +import { DEFAULT_CLICK_BUTTON_BY_TEXT_OPTIONS, DEFAULT_CLICK_BUTTON_OPTIONS } from "./defaults"; export function getGlobalHeader(page: Page): Locator { return page.getByRole("navigation").filter({ @@ -67,9 +65,7 @@ export async function clickButtonByText( ...DEFAULT_CLICK_BUTTON_BY_TEXT_OPTIONS, ...options, }; - const buttonElement = page - .getByRole("button") - .getByText(buttonText, { exact }); + const buttonElement = page.getByRole("button").getByText(buttonText, { exact }); await buttonElement.waitFor({ state: "visible", @@ -88,11 +84,7 @@ export async function clickButtonByLabel(page: Page, label: string | RegExp) { await page.getByRole("button", { name: label }).first().click(); } -export async function fillTextInputByLabel( - page: Page, - label: string, - text: string, -) { +export async function fillTextInputByLabel(page: Page, label: string, text: string) { await page.getByLabel(label).fill(text); } @@ -148,9 +140,7 @@ export async function clickLink( } else if ("href" in options) { linkLocator = page.locator(`a[href="${options.href}"]`).first(); } else { - linkLocator = page - .locator(`div[aria-label='${options.ariaLabel}'] a`) - .first(); + linkLocator = page.locator(`div[aria-label='${options.ariaLabel}'] a`).first(); } await linkLocator.waitFor({ state: "visible" }); @@ -169,16 +159,8 @@ export async function clickById(page: Page, id: string) { await locator.click(); } -export async function clickBtnInCard( - page: Page, - cardText: string, - btnText: string, - exact = true, -) { +export async function clickBtnInCard(page: Page, cardText: string, btnText: string, exact = true) { const cardLocator = getCardByText(page, cardText).first(); await cardLocator.scrollIntoViewIfNeeded(); - await cardLocator - .getByRole("button", { name: btnText, exact }) - .first() - .click(); + await cardLocator.getByRole("button", { name: btnText, exact }).first().click(); } diff --git a/e2e-tests/playwright/utils/ui-helper/misc.ts b/e2e-tests/playwright/utils/ui-helper/misc.ts index 11a51eb0b7..abd9a1fbd3 100644 --- a/e2e-tests/playwright/utils/ui-helper/misc.ts +++ b/e2e-tests/playwright/utils/ui-helper/misc.ts @@ -1,18 +1,11 @@ import { expect, Page } from "@playwright/test"; -import { getCardByHeading } from "../../support/page-objects/ui-locators"; + import { getCurrentLanguage } from "../../e2e/localization/locale"; -import { - clickButtonByLabel, - clickByDataTestId, - clickLink, -} from "./interaction"; +import { getCardByHeading } from "../../support/page-objects/ui-locators"; +import { clickButtonByLabel, clickByDataTestId, clickLink } from "./interaction"; import { openSidebar, selectMuiBox } from "./navigation"; -import { - verifyAlertErrorMessage, - verifyHeading, - verifyRowsInTable, -} from "./verification"; import { verifyCellsInTable } from "./table"; +import { verifyAlertErrorMessage, verifyHeading, verifyRowsInTable } from "./verification"; export async function verifyLinkinCard( page: Page, @@ -34,9 +27,7 @@ export async function verifyTextinCard( text: string | RegExp, exact = true, ) { - const locator = getCardByHeading(page, cardHeading) - .getByText(text, { exact }) - .first(); + const locator = getCardByHeading(page, cardHeading).getByText(text, { exact }).first(); await locator.scrollIntoViewIfNeeded(); await expect(locator).toBeVisible(); } @@ -59,19 +50,13 @@ export function toRgb(color: string): string { return `rgb(${r}, ${g}, ${b})`; } -export async function checkCssColor( - page: Page, - selector: string, - expectedColor: string, -) { +export async function checkCssColor(page: Page, selector: string, expectedColor: string) { const elements = page.locator(selector); const count = await elements.count(); const expectedRgbColor = toRgb(expectedColor); for (let i = 0; i < count; i++) { - const color = await elements - .nth(i) - .evaluate((el) => window.getComputedStyle(el).color); + const color = await elements.nth(i).evaluate((el) => window.getComputedStyle(el).color); expect(color).toBe(expectedRgbColor); } } @@ -115,10 +100,7 @@ export async function openQuickstartIfHidden(page: Page): Promise { await expect(quickstartHideButton).toBeVisible(); } -export async function verifyLocationRefreshButtonIsEnabled( - page: Page, - locationName: string, -) { +export async function verifyLocationRefreshButtonIsEnabled(page: Page, locationName: string) { await expect(async () => { await page.goto("/"); await openSidebar(page, "Catalog"); @@ -178,11 +160,7 @@ export async function clickUnregisterButtonForDisplayedEntity( await deleteButton.click(); } -export async function verifyComponentInCatalog( - page: Page, - kind: string, - expectedRows: string[], -) { +export async function verifyComponentInCatalog(page: Page, kind: string, expectedRows: string[]) { await openSidebar(page, "Catalog"); await selectMuiBox(page, "Kind", kind); await verifyRowsInTable(page, expectedRows); diff --git a/e2e-tests/playwright/utils/ui-helper/navigation.ts b/e2e-tests/playwright/utils/ui-helper/navigation.ts index 65396f8c4c..27690adad0 100644 --- a/e2e-tests/playwright/utils/ui-helper/navigation.ts +++ b/e2e-tests/playwright/utils/ui-helper/navigation.ts @@ -1,15 +1,8 @@ import { expect, Page } from "@playwright/test"; -import { - getTranslations, - getCurrentLanguage, -} from "../../e2e/localization/locale"; + +import { getTranslations, getCurrentLanguage } from "../../e2e/localization/locale"; import { getErrorMessage } from "../errors"; -import { - clickButtonByText, - clickByDataTestId, - clickLink, - getGlobalHeader, -} from "./interaction"; +import { clickButtonByText, clickByDataTestId, clickLink, getGlobalHeader } from "./interaction"; import { verifyHeading } from "./verification"; const t = getTranslations(); @@ -65,11 +58,7 @@ export async function openSidebar(page: Page, navBarText: string) { export async function openCatalogSidebar(page: Page, kind: string) { await openSidebar(page, t["rhdh"][lang]["menuItem.catalog"]); - await selectMuiBox( - page, - t["catalog-react"][lang]["entityKindPicker.title"], - kind, - ); + await selectMuiBox(page, t["catalog-react"][lang]["entityKindPicker.title"], kind); await expect(async () => { await clickByDataTestId(page, "user-picker-all"); await verifyHeading(page, new RegExp(`all ${kind}`, "iu")); @@ -85,12 +74,7 @@ export async function openSidebarButton(page: Page, navBarButtonLabel: string) { await navLink.click(); } -export async function selectMuiBox( - page: Page, - label: string, - value: string, - notVisible?: boolean, -) { +export async function selectMuiBox(page: Page, label: string, value: string, notVisible?: boolean) { // Wait for any overlaying dialogs to close before interacting await page .getByRole("dialog") diff --git a/e2e-tests/playwright/utils/ui-helper/table.ts b/e2e-tests/playwright/utils/ui-helper/table.ts index 9bd64d4ea6..d9f2f0cb58 100644 --- a/e2e-tests/playwright/utils/ui-helper/table.ts +++ b/e2e-tests/playwright/utils/ui-helper/table.ts @@ -1,14 +1,9 @@ import { expect, Locator, Page } from "@playwright/test"; -import { - getTableCell, - getTableRow, -} from "../../support/page-objects/ui-locators"; + +import { getTableCell, getTableRow } from "../../support/page-objects/ui-locators"; import { DEFAULT_VERIFY_BUTTON_URL_OPTIONS } from "./defaults"; -export async function verifyCellsInTable( - page: Page, - texts: (string | RegExp)[], -) { +export async function verifyCellsInTable(page: Page, texts: (string | RegExp)[]) { for (const text of texts) { const cellLocator = getTableCell(page, text); const count = await cellLocator.count(); @@ -58,9 +53,7 @@ export async function verifyRowInTableByUniqueText( const row = getTableRow(page, uniqueRowText); await row.waitFor(); for (const cellText of cellTexts) { - await expect( - row.getByRole("cell").filter({ hasText: cellText }).first(), - ).toBeVisible(); + await expect(row.getByRole("cell").filter({ hasText: cellText }).first()).toBeVisible(); } } @@ -128,7 +121,5 @@ export async function verifyPluginRow( } export async function waitForLoginBtnDisappear(page: Page) { - await page - .getByRole("button", { name: "Log in" }) - .waitFor({ state: "detached" }); + await page.getByRole("button", { name: "Log in" }).waitFor({ state: "detached" }); } diff --git a/e2e-tests/playwright/utils/ui-helper/verification.ts b/e2e-tests/playwright/utils/ui-helper/verification.ts index cf3612908a..ffb25312e2 100644 --- a/e2e-tests/playwright/utils/ui-helper/verification.ts +++ b/e2e-tests/playwright/utils/ui-helper/verification.ts @@ -1,4 +1,5 @@ import { expect, Locator, Page } from "@playwright/test"; + import { getErrorMessage } from "../errors"; import { DEFAULT_VERIFY_LINK_OPTIONS } from "./defaults"; @@ -43,11 +44,7 @@ export async function verifyTextVisible( await expect(locator).toBeVisible({ timeout }); } -export async function verifyLinkVisible( - page: Page, - text: string, - timeout = 10000, -): Promise { +export async function verifyLinkVisible(page: Page, text: string, timeout = 10000): Promise { const locator = page.locator(`a:has-text("${text}")`); await expect(locator).toBeVisible({ timeout }); } @@ -92,21 +89,13 @@ async function verifyTextInLocator( try { await elementLocator.scrollIntoViewIfNeeded(); } catch (error) { - console.warn( - `Warning: Could not scroll element into view. Error: ${getErrorMessage(error)}`, - ); + console.warn(`Warning: Could not scroll element into view. Error: ${getErrorMessage(error)}`); } await expect(elementLocator).toBeVisible(); } -export async function verifyTextInSelector( - page: Page, - selector: string, - expectedText: string, -) { - const elementLocator = page - .locator(selector) - .getByText(expectedText, { exact: true }); +export async function verifyTextInSelector(page: Page, selector: string, expectedText: string) { + const elementLocator = page.locator(selector).getByText(expectedText, { exact: true }); try { await elementLocator.waitFor({ state: "visible" }); @@ -120,9 +109,7 @@ export async function verifyTextInSelector( `Expected text "${expectedText}" not found. Actual content: "${actualText}".`, ); } - console.log( - `Text "${expectedText}" verified successfully in selector: ${selector}`, - ); + console.log(`Text "${expectedText}" verified successfully in selector: ${selector}`); } catch (error) { const allTextContent = await page.locator(selector).allTextContents(); console.error( @@ -144,9 +131,7 @@ export async function verifyPartialTextInSelector( for (let i = 0; i < count; i++) { const textContent = await elements.nth(i).textContent(); if (textContent !== null && textContent.includes(partialText)) { - console.log( - `Found partial text: ${partialText} in element: ${textContent}`, - ); + console.log(`Found partial text: ${partialText} in element: ${textContent}`); return; } } @@ -166,25 +151,15 @@ export async function verifyColumnHeading( exact: boolean = true, ) { for (const rowText of rowTexts) { - const rowLocator = page - .getByRole("columnheader") - .getByText(rowText, { exact }) - .first(); + const rowLocator = page.getByRole("columnheader").getByText(rowText, { exact }).first(); await rowLocator.waitFor({ state: "visible" }); await rowLocator.scrollIntoViewIfNeeded(); await expect(rowLocator).toBeVisible(); } } -export async function verifyHeading( - page: Page, - heading: string | RegExp, - timeout: number = 20000, -) { - const headingLocator = page - .getByRole("heading") - .filter({ hasText: heading }) - .first(); +export async function verifyHeading(page: Page, heading: string | RegExp, timeout: number = 20000) { + const headingLocator = page.getByRole("heading").filter({ hasText: heading }).first(); await headingLocator.waitFor({ state: "visible", timeout }); await expect(headingLocator).toBeVisible(); @@ -196,18 +171,11 @@ export async function verifyParagraph(page: Page, paragraph: string) { await expect(headingLocator).toBeVisible(); } -export async function waitForTitle( - page: Page, - text: string, - level: number = 1, -) { +export async function waitForTitle(page: Page, text: string, level: number = 1) { await page.waitForSelector(`h${level}:has-text("${text}")`); } -export async function verifyAlertErrorMessage( - page: Page, - message: string | RegExp, -) { +export async function verifyAlertErrorMessage(page: Page, message: string | RegExp) { const alert = page.getByRole("alert"); await alert.waitFor(); await expect(alert).toHaveText(message); diff --git a/e2e-tests/playwright/utils/ui-helper/visibility.ts b/e2e-tests/playwright/utils/ui-helper/visibility.ts index 42da432666..8aac3d8199 100644 --- a/e2e-tests/playwright/utils/ui-helper/visibility.ts +++ b/e2e-tests/playwright/utils/ui-helper/visibility.ts @@ -19,10 +19,7 @@ async function isElementVisible( } } -export function isBtnVisibleByTitle( - page: Page, - text: string, -): Promise { +export function isBtnVisibleByTitle(page: Page, text: string): Promise { const locator = `BUTTON[title="${text}"]`; return isElementVisible(page, locator); } @@ -32,11 +29,7 @@ export function isBtnVisible(page: Page, text: string): Promise { return isElementVisible(page, locator); } -export function isTextVisible( - page: Page, - text: string, - timeout = 10000, -): Promise { +export function isTextVisible(page: Page, text: string, timeout = 10000): Promise { const locator = `:has-text("${text}")`; return isElementVisible(page, locator, timeout); } From 83739d0ddf0152a49cbe785f9185aabd5d8137c2 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 24 Jun 2026 13:03:34 -0500 Subject: [PATCH 5/8] refactor(e2e): group split modules into folders for clearer naming Move kube-client, api-helper, common, rhdh-deployment, and semantic selector splits into directories with short filenames, matching the ui-helper pattern. Co-authored-by: Cursor --- .../support/page-objects/global-obj.ts | 2 +- .../support/page-objects/page-obj.ts | 2 +- .../support/page-objects/ui-locators.ts | 2 +- .../accessibility.ts} | 0 .../index.ts} | 6 +- .../structure.ts} | 0 .../table-helpers.ts} | 2 +- .../{ => semantic}/wait-strategies.ts | 0 .../catalog.ts} | 2 +- .../github.ts} | 2 +- .../guards.ts} | 0 .../{api-helper.ts => api-helper/index.ts} | 11 +- .../auth.ts} | 2 +- .../catalog.ts} | 4 +- .../index.ts} | 12 +- .../k8s.ts} | 6 +- .../logs.ts} | 4 +- .../types.ts} | 0 .../wait.ts} | 4 +- e2e-tests/playwright/utils/common.ts | 322 +----------------- .../auth-popup.ts} | 0 .../{common-browser.ts => common/browser.ts} | 0 e2e-tests/playwright/utils/common/index.ts | 321 +++++++++++++++++ .../configmap.ts} | 2 +- .../deployment/restart.ts} | 2 +- .../deployment/scale.ts} | 2 +- .../deployment/wait.ts} | 2 +- .../diagnostics/events.ts} | 2 +- .../diagnostics/pods.ts} | 2 +- .../diagnostics/replicasets.ts} | 2 +- .../exec.ts} | 2 +- .../helpers.ts} | 2 +- .../{kube-client.ts => kube-client/index.ts} | 22 +- .../pod-failure.ts} | 2 +- 34 files changed, 371 insertions(+), 375 deletions(-) rename e2e-tests/playwright/support/selectors/{semantic-selectors-accessibility.ts => semantic/accessibility.ts} (100%) rename e2e-tests/playwright/support/selectors/{semantic-selectors.ts => semantic/index.ts} (74%) rename e2e-tests/playwright/support/selectors/{semantic-selectors-structure.ts => semantic/structure.ts} (100%) rename e2e-tests/playwright/support/selectors/{semantic-selectors-table-helpers.ts => semantic/table-helpers.ts} (88%) rename e2e-tests/playwright/support/selectors/{ => semantic}/wait-strategies.ts (100%) rename e2e-tests/playwright/utils/{api-helper-catalog.ts => api-helper/catalog.ts} (98%) rename e2e-tests/playwright/utils/{api-helper-github.ts => api-helper/github.ts} (99%) rename e2e-tests/playwright/utils/{api-helper-guards.ts => api-helper/guards.ts} (100%) rename e2e-tests/playwright/utils/{api-helper.ts => api-helper/index.ts} (96%) rename e2e-tests/playwright/utils/authentication-providers/{rhdh-deployment-auth.ts => rhdh-deployment/auth.ts} (99%) rename e2e-tests/playwright/utils/authentication-providers/{rhdh-deployment-catalog.ts => rhdh-deployment/catalog.ts} (98%) rename e2e-tests/playwright/utils/authentication-providers/{rhdh-deployment.ts => rhdh-deployment/index.ts} (98%) rename e2e-tests/playwright/utils/authentication-providers/{rhdh-deployment-k8s.ts => rhdh-deployment/k8s.ts} (99%) rename e2e-tests/playwright/utils/authentication-providers/{rhdh-deployment-logs.ts => rhdh-deployment/logs.ts} (97%) rename e2e-tests/playwright/utils/authentication-providers/{rhdh-deployment-types.ts => rhdh-deployment/types.ts} (100%) rename e2e-tests/playwright/utils/authentication-providers/{rhdh-deployment-wait.ts => rhdh-deployment/wait.ts} (98%) rename e2e-tests/playwright/utils/{common-auth-popup.ts => common/auth-popup.ts} (100%) rename e2e-tests/playwright/utils/{common-browser.ts => common/browser.ts} (100%) create mode 100644 e2e-tests/playwright/utils/common/index.ts rename e2e-tests/playwright/utils/{kube-client-configmap.ts => kube-client/configmap.ts} (99%) rename e2e-tests/playwright/utils/{kube-client-deployment-restart.ts => kube-client/deployment/restart.ts} (97%) rename e2e-tests/playwright/utils/{kube-client-deployment-scale.ts => kube-client/deployment/scale.ts} (98%) rename e2e-tests/playwright/utils/{kube-client-deployment-wait.ts => kube-client/deployment/wait.ts} (99%) rename e2e-tests/playwright/utils/{kube-client-diagnostics-events.ts => kube-client/diagnostics/events.ts} (99%) rename e2e-tests/playwright/utils/{kube-client-diagnostics-pods.ts => kube-client/diagnostics/pods.ts} (99%) rename e2e-tests/playwright/utils/{kube-client-diagnostics-replicasets.ts => kube-client/diagnostics/replicasets.ts} (97%) rename e2e-tests/playwright/utils/{kube-client-exec.ts => kube-client/exec.ts} (97%) rename e2e-tests/playwright/utils/{kube-client-helpers.ts => kube-client/helpers.ts} (99%) rename e2e-tests/playwright/utils/{kube-client.ts => kube-client/index.ts} (95%) rename e2e-tests/playwright/utils/{kube-client-pod-failure.ts => kube-client/pod-failure.ts} (99%) diff --git a/e2e-tests/playwright/support/page-objects/global-obj.ts b/e2e-tests/playwright/support/page-objects/global-obj.ts index c295b5012f..b8d9571371 100644 --- a/e2e-tests/playwright/support/page-objects/global-obj.ts +++ b/e2e-tests/playwright/support/page-objects/global-obj.ts @@ -1,7 +1,7 @@ /* oxlint-disable playwright/no-raw-locators -- Legacy CSS selector constants; prefer SemanticSelectors get*() methods */ import { Page, Locator } from "@playwright/test"; -import { SemanticSelectors } from "../selectors/semantic-selectors"; +import { SemanticSelectors } from "../selectors/semantic"; /** * WAIT_OBJECTS - Loading indicators diff --git a/e2e-tests/playwright/support/page-objects/page-obj.ts b/e2e-tests/playwright/support/page-objects/page-obj.ts index 04563973db..1f24d2cb9c 100644 --- a/e2e-tests/playwright/support/page-objects/page-obj.ts +++ b/e2e-tests/playwright/support/page-objects/page-obj.ts @@ -2,7 +2,7 @@ import { Page, Locator } from "@playwright/test"; import { getTranslations, getCurrentLanguage } from "../../e2e/localization/locale"; -import { SemanticSelectors } from "../selectors/semantic-selectors"; +import { SemanticSelectors } from "../selectors/semantic"; const t = getTranslations(); const lang = getCurrentLanguage(); diff --git a/e2e-tests/playwright/support/page-objects/ui-locators.ts b/e2e-tests/playwright/support/page-objects/ui-locators.ts index 93806faee8..976db10c53 100644 --- a/e2e-tests/playwright/support/page-objects/ui-locators.ts +++ b/e2e-tests/playwright/support/page-objects/ui-locators.ts @@ -1,7 +1,7 @@ /* oxlint-disable playwright/no-raw-locators -- legacy card/table region selectors pending SemanticSelectors migration */ import { Locator, Page } from "@playwright/test"; -import { SemanticSelectors } from "../selectors/semantic-selectors"; +import { SemanticSelectors } from "../selectors/semantic"; export function getCardByHeading(page: Page, heading: string | RegExp): Locator { return page diff --git a/e2e-tests/playwright/support/selectors/semantic-selectors-accessibility.ts b/e2e-tests/playwright/support/selectors/semantic/accessibility.ts similarity index 100% rename from e2e-tests/playwright/support/selectors/semantic-selectors-accessibility.ts rename to e2e-tests/playwright/support/selectors/semantic/accessibility.ts diff --git a/e2e-tests/playwright/support/selectors/semantic-selectors.ts b/e2e-tests/playwright/support/selectors/semantic/index.ts similarity index 74% rename from e2e-tests/playwright/support/selectors/semantic-selectors.ts rename to e2e-tests/playwright/support/selectors/semantic/index.ts index 4f321da793..0e7a6e9de5 100644 --- a/e2e-tests/playwright/support/selectors/semantic-selectors.ts +++ b/e2e-tests/playwright/support/selectors/semantic/index.ts @@ -1,5 +1,5 @@ -import { semanticSelectorsAccessibility } from "./semantic-selectors-accessibility"; -import { semanticSelectorsStructure } from "./semantic-selectors-structure"; +import { semanticSelectorsAccessibility } from "./accessibility"; +import { semanticSelectorsStructure } from "./structure"; /** * Semantic Selectors - Playwright Best Practices @@ -21,5 +21,5 @@ export const SemanticSelectors = { ...semanticSelectorsStructure, }; -export { findTableCell, findTableCellByColumn } from "./semantic-selectors-table-helpers"; +export { findTableCell, findTableCellByColumn } from "./table-helpers"; export { WaitStrategies } from "./wait-strategies"; diff --git a/e2e-tests/playwright/support/selectors/semantic-selectors-structure.ts b/e2e-tests/playwright/support/selectors/semantic/structure.ts similarity index 100% rename from e2e-tests/playwright/support/selectors/semantic-selectors-structure.ts rename to e2e-tests/playwright/support/selectors/semantic/structure.ts diff --git a/e2e-tests/playwright/support/selectors/semantic-selectors-table-helpers.ts b/e2e-tests/playwright/support/selectors/semantic/table-helpers.ts similarity index 88% rename from e2e-tests/playwright/support/selectors/semantic-selectors-table-helpers.ts rename to e2e-tests/playwright/support/selectors/semantic/table-helpers.ts index 7d2572372b..ba13151d83 100644 --- a/e2e-tests/playwright/support/selectors/semantic-selectors-table-helpers.ts +++ b/e2e-tests/playwright/support/selectors/semantic/table-helpers.ts @@ -1,6 +1,6 @@ import { Page, Locator } from "@playwright/test"; -import { semanticSelectorsAccessibility } from "./semantic-selectors-accessibility"; +import { semanticSelectorsAccessibility } from "./accessibility"; export function findTableCell(page: Page, rowText: string | RegExp, cellIndex: number): Locator { const row = semanticSelectorsAccessibility.tableRow(page, rowText); diff --git a/e2e-tests/playwright/support/selectors/wait-strategies.ts b/e2e-tests/playwright/support/selectors/semantic/wait-strategies.ts similarity index 100% rename from e2e-tests/playwright/support/selectors/wait-strategies.ts rename to e2e-tests/playwright/support/selectors/semantic/wait-strategies.ts diff --git a/e2e-tests/playwright/utils/api-helper-catalog.ts b/e2e-tests/playwright/utils/api-helper/catalog.ts similarity index 98% rename from e2e-tests/playwright/utils/api-helper-catalog.ts rename to e2e-tests/playwright/utils/api-helper/catalog.ts index 06c50fc2bc..1650fef5d4 100644 --- a/e2e-tests/playwright/utils/api-helper-catalog.ts +++ b/e2e-tests/playwright/utils/api-helper/catalog.ts @@ -5,7 +5,7 @@ import { isCatalogLocationEntry, isEntityMetadataResponse, parseJsonResponse, -} from "./api-helper-guards"; +} from "./guards"; export async function getEntityUidByName(name: string): Promise { const baseUrl = process.env.BASE_URL; diff --git a/e2e-tests/playwright/utils/api-helper-github.ts b/e2e-tests/playwright/utils/api-helper/github.ts similarity index 99% rename from e2e-tests/playwright/utils/api-helper-github.ts rename to e2e-tests/playwright/utils/api-helper/github.ts index 3a6a9ef14c..5cfc8b8ac3 100644 --- a/e2e-tests/playwright/utils/api-helper-github.ts +++ b/e2e-tests/playwright/utils/api-helper/github.ts @@ -6,7 +6,7 @@ import { isGitHubPullRequestFile, parseJsonResponse, toUnknownArray, -} from "./api-helper-guards"; +} from "./guards"; type FetchOptions = { method: string; diff --git a/e2e-tests/playwright/utils/api-helper-guards.ts b/e2e-tests/playwright/utils/api-helper/guards.ts similarity index 100% rename from e2e-tests/playwright/utils/api-helper-guards.ts rename to e2e-tests/playwright/utils/api-helper/guards.ts diff --git a/e2e-tests/playwright/utils/api-helper.ts b/e2e-tests/playwright/utils/api-helper/index.ts similarity index 96% rename from e2e-tests/playwright/utils/api-helper.ts rename to e2e-tests/playwright/utils/api-helper/index.ts index 2bc2b9c023..5afaefd3f4 100644 --- a/e2e-tests/playwright/utils/api-helper.ts +++ b/e2e-tests/playwright/utils/api-helper/index.ts @@ -1,14 +1,9 @@ import { type GroupEntity, type UserEntity } from "@backstage/catalog-model"; import { request, type APIResponse, expect } from "@playwright/test"; -import * as catalogApi from "./api-helper-catalog"; -import * as githubApi from "./api-helper-github"; -import { - isGuestTokenResponse, - isGroupEntity, - isUserEntity, - parseJsonResponse, -} from "./api-helper-guards"; +import * as catalogApi from "./catalog"; +import * as githubApi from "./github"; +import { isGuestTokenResponse, isGroupEntity, isUserEntity, parseJsonResponse } from "./guards"; export class APIHelper { private staticToken = ""; diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-auth.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/auth.ts similarity index 99% rename from e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-auth.ts rename to e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/auth.ts index 126f98239f..7a7a0e1d4b 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-auth.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/auth.ts @@ -1,7 +1,7 @@ import { expect } from "@playwright/test"; import * as yaml from "yaml"; -import { RHDHDeploymentState } from "./rhdh-deployment-types"; +import { RHDHDeploymentState } from "./types"; export interface AuthConfigActions { setDynamicPluginEnabled(pluginName: string, enabled: boolean): void; diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-catalog.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/catalog.ts similarity index 98% rename from e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-catalog.ts rename to e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/catalog.ts index 33d108e1b2..1670abdb94 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-catalog.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/catalog.ts @@ -1,13 +1,13 @@ import { GroupEntity } from "@backstage/catalog-model"; -import { APIHelper } from "../api-helper"; +import { APIHelper } from "../../api-helper"; import { getCatalogGroups, getCatalogUsers, isGroupEntity, isUserEntity, RHDHDeploymentState, -} from "./rhdh-deployment-types"; +} from "./types"; export function parseGroupMemberFromEntity(group: GroupEntity): string[] { if (group.relations === undefined) { diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/index.ts similarity index 98% rename from e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts rename to e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/index.ts index b9c9b6dedb..c716dc203c 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/index.ts @@ -17,7 +17,7 @@ import { setGitlabResolver as setGitlabResolverImpl, setMicrosoftResolver as setMicrosoftResolverImpl, setOIDCResolver as setOIDCResolverImpl, -} from "./rhdh-deployment-auth"; +} from "./auth"; import { checkGroupIsChildOfGroup, checkGroupIsIngestedInCatalog, @@ -28,7 +28,7 @@ import { parseGroupChildrenFromEntity, parseGroupMemberFromEntity, parseGroupParentFromEntity, -} from "./rhdh-deployment-catalog"; +} from "./catalog"; import { applyCustomResource, computeBackstageBackendUrl as computeBackstageBackendUrlImpl, @@ -51,13 +51,13 @@ import { updateDynamicPluginsConfig as updateDynamicPluginsConfigImpl, updateRbacConfig as updateRbacConfigImpl, updateSecret as updateSecretImpl, -} from "./rhdh-deployment-k8s"; +} from "./k8s"; import { followLocalLogs as followLocalLogsImpl, followLogs as followLogsImpl, followPodLogs as followPodLogsImpl, waitForSynced as waitForSyncedImpl, -} from "./rhdh-deployment-logs"; +} from "./logs"; import { BackstageCr, DynamicPluginsConfig, @@ -66,14 +66,14 @@ import { RHDHDeploymentState, shouldUseKubernetesClient, YamlConfig, -} from "./rhdh-deployment-types"; +} from "./types"; import { deleteNamespaceIfExists as deleteNamespaceIfExistsImpl, getDeploymentGeneration as getDeploymentGenerationImpl, waitForConfigReconciled as waitForConfigReconciledImpl, waitForDeploymentReady as waitForDeploymentReadyImpl, waitForNamespaceActive as waitForNamespaceActiveImpl, -} from "./rhdh-deployment-wait"; +} from "./wait"; class RHDHDeployment implements RHDHDeploymentState { instanceName = ""; diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-k8s.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/k8s.ts similarity index 99% rename from e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-k8s.ts rename to e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/k8s.ts index ef1a6b3bca..238ecea354 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-k8s.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/k8s.ts @@ -6,7 +6,7 @@ import * as k8s from "@kubernetes/client-node"; import { expect } from "@playwright/test"; import * as yaml from "yaml"; -import { hasErrorResponse } from "../errors"; +import { hasErrorResponse } from "../../errors"; import { BackstageCr, currentDirName, @@ -15,8 +15,8 @@ import { isRecord, RHDHDeploymentState, rootDirName, -} from "./rhdh-deployment-types"; -import { ensureBackstageCRIsAvailable, waitForDeploymentReady } from "./rhdh-deployment-wait"; +} from "./types"; +import { ensureBackstageCRIsAvailable, waitForDeploymentReady } from "./wait"; export async function readYamlToJson(filePath: string): Promise { const fileContent = await fs.readFile(filePath, "utf8"); diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-logs.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/logs.ts similarity index 97% rename from e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-logs.ts rename to e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/logs.ts index 885019aa2b..fbb025785d 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-logs.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/logs.ts @@ -2,8 +2,8 @@ import stream from "stream"; import * as k8s from "@kubernetes/client-node"; -import { getErrorMessage, hasErrorResponse } from "../errors"; -import { RHDHDeploymentState, sleep, syncedLogRegex } from "./rhdh-deployment-types"; +import { getErrorMessage, hasErrorResponse } from "../../errors"; +import { RHDHDeploymentState, sleep, syncedLogRegex } from "./types"; async function resolvePodName( state: RHDHDeploymentState, diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-types.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/types.ts similarity index 100% rename from e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-types.ts rename to e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/types.ts diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-wait.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/wait.ts similarity index 98% rename from e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-wait.ts rename to e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/wait.ts index 951ba12300..def11e0955 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment-wait.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/wait.ts @@ -1,7 +1,7 @@ import * as k8s from "@kubernetes/client-node"; -import { getErrorMessage, hasErrorResponse } from "../errors"; -import { BackstageCr, RHDHDeploymentState, sleep } from "./rhdh-deployment-types"; +import { getErrorMessage, hasErrorResponse } from "../../errors"; +import { BackstageCr, RHDHDeploymentState, sleep } from "./types"; const BACKSTAGE_LABELS = { "app.kubernetes.io/name": "backstage", diff --git a/e2e-tests/playwright/utils/common.ts b/e2e-tests/playwright/utils/common.ts index aaff2cc5ee..832c605070 100644 --- a/e2e-tests/playwright/utils/common.ts +++ b/e2e-tests/playwright/utils/common.ts @@ -1,321 +1 @@ -import * as fs from "fs"; - -import { test, Page } from "@playwright/test"; -import { authenticator } from "otplib"; - -import { getTranslations, getCurrentLanguage } from "../e2e/localization/locale"; -import { SETTINGS_PAGE_COMPONENTS } from "../support/page-objects/page-obj"; -import { - handleGitHubPopupLogin, - handleGitlabPopupLogin, - handleKeycloakPopupLogin, - handleMicrosoftAzurePopupLogin, - handlePingFederatePopupLogin, -} from "./common-auth-popup"; -import { parseAuthStateCookies } from "./common-browser"; -import { getErrorMessage } from "./errors"; -import { UIhelper } from "./ui-helper"; - -export { setupBrowser, teardownBrowser } from "./common-browser"; - -const t = getTranslations(); -const lang = getCurrentLanguage(); - -const LOADING_INDICATOR_SELECTORS = [ - 'div[class*="MuiLinearProgress-root"]', - '[class*="MuiCircularProgress-root"]', -] as const; - -export class Common { - page: Page; - uiHelper: UIhelper; - private readonly authStateFileName = "authState.json"; - - constructor(page: Page) { - this.page = page; - this.uiHelper = new UIhelper(page); - } - - async loginAsGuest() { - await this.page.goto("/"); - await this.waitForLoad(240000); - // RHIDP-2043: Remove dialog handler after dynamic Guest Authentication Provider plugin is created - this.page.on("dialog", async (dialog) => { - console.log(`Dialog message: ${dialog.message()}`); - await dialog.accept(); - }); - - await this.uiHelper.verifyHeading(t["rhdh"][lang]["signIn.page.title"]); - await this.uiHelper.clickButton(t["core-components"][lang]["signIn.guestProvider.enter"]); - await this.uiHelper.waitForSideBarVisible(); - } - - async waitForLoad(timeout = 120000) { - for (const selector of LOADING_INDICATOR_SELECTORS) { - await this.page.waitForSelector(selector, { - state: "hidden", - timeout: timeout, - }); - } - } - - async signOut() { - await this.page.click(SETTINGS_PAGE_COMPONENTS.userSettingsMenu); - await this.page.click(SETTINGS_PAGE_COMPONENTS.signOut); - await this.uiHelper.verifyHeading(t["rhdh"][lang]["signIn.page.title"]); - } - - private async logintoGithub(userid: string) { - await this.page.goto("https://github.com/login"); - await this.page.waitForSelector("#login_field"); - await this.page.fill("#login_field", userid); - - const password = - userid === process.env.GH_USER_ID - ? process.env.GH_USER_PASS - : userid === process.env.GH_USER2_ID - ? process.env.GH_USER2_PASS - : undefined; - if (password === undefined || password === "") { - throw new Error("Invalid User ID"); - } - await this.page.fill("#password", password); - - await this.page.click('[value="Sign in"]'); - await this.page.fill("#app_totp", this.getGitHub2FAOTP(userid)); - test.setTimeout(130000); - 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)) - ) { - // GitHub TOTP codes cannot be reused within ~30s; wait for the next window. - await new Promise((resolve) => { - setTimeout(resolve, 60_000); - }); - await this.page.fill("#app_totp", this.getGitHub2FAOTP(userid)); - } - - await this.page.waitForLoadState("networkidle"); - } - - async logintoKeycloak(userid: string, password: string) { - /* oxlint-disable playwright/no-raw-locators -- Keycloak login popup (third-party) */ - await new Promise((resolve) => { - this.page.once("popup", async (popup) => { - await popup.waitForLoadState(); - await popup.locator("#username").fill(userid); - await popup.locator("#password").fill(password); - try { - await popup.locator("#kc-login").click({ timeout: 5000 }); - } catch (error) { - if (!getErrorMessage(error).includes("Target closed")) { - throw error; - } - } - resolve(); - }); - }); - /* oxlint-enable playwright/no-raw-locators */ - } - - async loginAsKeycloakUser( - userid: string = process.env.GH_USER_ID ?? "", - password: string = process.env.GH_USER_PASS ?? "", - ) { - await this.page.goto("/"); - await this.waitForLoad(240000); - await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); - await this.logintoKeycloak(userid, password); - await this.uiHelper.waitForSideBarVisible(); - } - - async loginAsGithubUser(userid: string = process.env.GH_USER_ID ?? "") { - const sessionFileName = `authState_${userid}.json`; - - if (fs.existsSync(sessionFileName)) { - const cookies = parseAuthStateCookies(fs.readFileSync(sessionFileName, "utf-8")); - await this.page.context().addCookies(cookies); - console.log(`Reusing existing authentication state for user: ${userid}`); - await this.page.goto("/"); - await this.waitForLoad(12000); - await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); - await this.checkAndReauthorizeGithubApp(); - } else { - await this.logintoGithub(userid); - await this.page.goto("/"); - await this.waitForLoad(240000); - await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); - await this.checkAndReauthorizeGithubApp(); - await this.uiHelper.waitForSideBarVisible(); - await this.page.context().storageState({ path: sessionFileName }); - console.log(`Authentication state saved for user: ${userid}`); - } - } - - async checkAndReauthorizeGithubApp() { - /* oxlint-disable playwright/no-raw-locators -- GitHub OAuth authorize popup (third-party) */ - await new Promise((resolve) => { - this.page.once("popup", async (popup) => { - await popup.waitForLoadState(); - - const authorizeButton = popup.locator("button.js-oauth-authorize-btn"); - await Promise.race([ - popup.waitForEvent("close", { timeout: 10_000 }), - authorizeButton.waitFor({ state: "visible", timeout: 10_000 }), - ]).catch(() => {}); - - if (!popup.isClosed() && (await authorizeButton.isVisible())) { - await popup.locator("body").click(); - await authorizeButton.waitFor(); - await authorizeButton.click(); - } - resolve(); - }); - }); - /* oxlint-enable playwright/no-raw-locators */ - } - - 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; - } - } - - async clickOnGHloginPopup() { - const isLoginRequiredVisible = await this.uiHelper.isTextVisible( - t["user-settings"][lang]["providerSettingsItem.buttonTitle.signIn"], - ); - if (isLoginRequiredVisible) { - await this.uiHelper.clickButton( - t["user-settings"][lang]["providerSettingsItem.buttonTitle.signIn"], - ); - await this.uiHelper.clickButton(t["core-components"][lang]["oauthRequestDialog.login"]); - await this.checkAndReauthorizeGithubApp(); - await this.uiHelper.waitForLoginBtnDisappear(); - } else { - console.log('"Log in" button is not visible. Skipping login popup actions.'); - } - } - - getGitHub2FAOTP(userid: string): string { - const ghUserId = process.env.GH_USER_ID; - const ghUser2Id = process.env.GH_USER2_ID; - const secrets: Record = {}; - if (ghUserId !== undefined && ghUserId !== "") { - secrets[ghUserId] = process.env.GH_2FA_SECRET; - } - if (ghUser2Id !== undefined && ghUser2Id !== "") { - secrets[ghUser2Id] = process.env.GH_USER2_2FA_SECRET; - } - - const secret = secrets[userid]; - if (secret === undefined || secret === "") { - throw new Error("Invalid User ID"); - } - - return authenticator.generate(secret); - } - - getGoogle2FAOTP(): string { - const secret = process.env.GOOGLE_2FA_SECRET; - if (secret === undefined || secret === "") { - throw new Error("GOOGLE_2FA_SECRET is not set"); - } - return authenticator.generate(secret); - } - - async keycloakLogin(username: string, password: string) { - await this.page.goto("/"); - await this.page.waitForSelector( - `p:has-text("${t["rhdh"][lang]["signIn.providers.oidc.message"]}")`, - ); - - const [popup] = await Promise.all([ - this.page.waitForEvent("popup"), - this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), - ]); - - return handleKeycloakPopupLogin(popup, username, password); - } - - async githubLogin(username: string, password: string, twofactor: string) { - await this.page.goto("/"); - await this.page.waitForSelector( - `p:has-text("${t["rhdh"][lang]["signIn.providers.github.message"]}")`, - ); - - const [popup] = await Promise.all([ - this.page.waitForEvent("popup"), - this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), - ]); - - return handleGitHubPopupLogin(popup, username, password, twofactor); - } - - async githubLoginFromSettingsPage(username: string, password: string, twofactor: string) { - await this.page.goto("/settings/auth-providers"); - - const [popup] = await Promise.all([ - this.page.waitForEvent("popup"), - this.page - .getByTitle( - t["user-settings"][lang]["providerSettingsItem.title.signIn"].replace( - "{{title}}", - "GitHub", - ), - ) - .click(), - this.uiHelper.clickButton(t["core-components"][lang]["oauthRequestDialog.login"]), - ]); - - return handleGitHubPopupLogin(popup, username, password, twofactor); - } - - async gitlabLogin(username: string, password: string) { - await this.page.goto("/"); - await this.page.waitForSelector( - `p:has-text("${t["rhdh"][lang]["signIn.providers.gitlab.message"]}")`, - ); - - const [popup] = await Promise.all([ - this.page.waitForEvent("popup"), - this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), - ]); - - return handleGitlabPopupLogin(popup, username, password); - } - - async MicrosoftAzureLogin(username: string, password: string) { - await this.page.goto("/"); - await this.page.waitForSelector( - `p:has-text("${t["rhdh"][lang]["signIn.providers.microsoft.message"]}")`, - ); - - const [popup] = await Promise.all([ - this.page.waitForEvent("popup"), - this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), - ]); - - return handleMicrosoftAzurePopupLogin(popup, username, password); - } - - async pingFederateLogin(username: string, password: string) { - await this.page.goto("/"); - await this.page.waitForSelector( - `p:has-text("${t["rhdh"][lang]["signIn.providers.oidc.message"]}")`, - ); - - const [popup] = await Promise.all([ - this.page.waitForEvent("popup"), - this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), - ]); - - return handlePingFederatePopupLogin(popup, username, password); - } -} +export { Common, setupBrowser, teardownBrowser } from "./common/index"; diff --git a/e2e-tests/playwright/utils/common-auth-popup.ts b/e2e-tests/playwright/utils/common/auth-popup.ts similarity index 100% rename from e2e-tests/playwright/utils/common-auth-popup.ts rename to e2e-tests/playwright/utils/common/auth-popup.ts diff --git a/e2e-tests/playwright/utils/common-browser.ts b/e2e-tests/playwright/utils/common/browser.ts similarity index 100% rename from e2e-tests/playwright/utils/common-browser.ts rename to e2e-tests/playwright/utils/common/browser.ts diff --git a/e2e-tests/playwright/utils/common/index.ts b/e2e-tests/playwright/utils/common/index.ts new file mode 100644 index 0000000000..73629c1624 --- /dev/null +++ b/e2e-tests/playwright/utils/common/index.ts @@ -0,0 +1,321 @@ +import * as fs from "fs"; + +import { test, Page } from "@playwright/test"; +import { authenticator } from "otplib"; + +import { getTranslations, getCurrentLanguage } from "../e2e/localization/locale"; +import { getErrorMessage } from "../errors"; +import { SETTINGS_PAGE_COMPONENTS } from "../support/page-objects/page-obj"; +import { UIhelper } from "../ui-helper"; +import { + handleGitHubPopupLogin, + handleGitlabPopupLogin, + handleKeycloakPopupLogin, + handleMicrosoftAzurePopupLogin, + handlePingFederatePopupLogin, +} from "./auth-popup"; +import { parseAuthStateCookies } from "./browser"; + +export { setupBrowser, teardownBrowser } from "./browser"; + +const t = getTranslations(); +const lang = getCurrentLanguage(); + +const LOADING_INDICATOR_SELECTORS = [ + 'div[class*="MuiLinearProgress-root"]', + '[class*="MuiCircularProgress-root"]', +] as const; + +export class Common { + page: Page; + uiHelper: UIhelper; + private readonly authStateFileName = "authState.json"; + + constructor(page: Page) { + this.page = page; + this.uiHelper = new UIhelper(page); + } + + async loginAsGuest() { + await this.page.goto("/"); + await this.waitForLoad(240000); + // RHIDP-2043: Remove dialog handler after dynamic Guest Authentication Provider plugin is created + this.page.on("dialog", async (dialog) => { + console.log(`Dialog message: ${dialog.message()}`); + await dialog.accept(); + }); + + await this.uiHelper.verifyHeading(t["rhdh"][lang]["signIn.page.title"]); + await this.uiHelper.clickButton(t["core-components"][lang]["signIn.guestProvider.enter"]); + await this.uiHelper.waitForSideBarVisible(); + } + + async waitForLoad(timeout = 120000) { + for (const selector of LOADING_INDICATOR_SELECTORS) { + await this.page.waitForSelector(selector, { + state: "hidden", + timeout: timeout, + }); + } + } + + async signOut() { + await this.page.click(SETTINGS_PAGE_COMPONENTS.userSettingsMenu); + await this.page.click(SETTINGS_PAGE_COMPONENTS.signOut); + await this.uiHelper.verifyHeading(t["rhdh"][lang]["signIn.page.title"]); + } + + private async logintoGithub(userid: string) { + await this.page.goto("https://github.com/login"); + await this.page.waitForSelector("#login_field"); + await this.page.fill("#login_field", userid); + + const password = + userid === process.env.GH_USER_ID + ? process.env.GH_USER_PASS + : userid === process.env.GH_USER2_ID + ? process.env.GH_USER2_PASS + : undefined; + if (password === undefined || password === "") { + throw new Error("Invalid User ID"); + } + await this.page.fill("#password", password); + + await this.page.click('[value="Sign in"]'); + await this.page.fill("#app_totp", this.getGitHub2FAOTP(userid)); + test.setTimeout(130000); + 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)) + ) { + // GitHub TOTP codes cannot be reused within ~30s; wait for the next window. + await new Promise((resolve) => { + setTimeout(resolve, 60_000); + }); + await this.page.fill("#app_totp", this.getGitHub2FAOTP(userid)); + } + + await this.page.waitForLoadState("networkidle"); + } + + async logintoKeycloak(userid: string, password: string) { + /* oxlint-disable playwright/no-raw-locators -- Keycloak login popup (third-party) */ + await new Promise((resolve) => { + this.page.once("popup", async (popup) => { + await popup.waitForLoadState(); + await popup.locator("#username").fill(userid); + await popup.locator("#password").fill(password); + try { + await popup.locator("#kc-login").click({ timeout: 5000 }); + } catch (error) { + if (!getErrorMessage(error).includes("Target closed")) { + throw error; + } + } + resolve(); + }); + }); + /* oxlint-enable playwright/no-raw-locators */ + } + + async loginAsKeycloakUser( + userid: string = process.env.GH_USER_ID ?? "", + password: string = process.env.GH_USER_PASS ?? "", + ) { + await this.page.goto("/"); + await this.waitForLoad(240000); + await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); + await this.logintoKeycloak(userid, password); + await this.uiHelper.waitForSideBarVisible(); + } + + async loginAsGithubUser(userid: string = process.env.GH_USER_ID ?? "") { + const sessionFileName = `authState_${userid}.json`; + + if (fs.existsSync(sessionFileName)) { + const cookies = parseAuthStateCookies(fs.readFileSync(sessionFileName, "utf-8")); + await this.page.context().addCookies(cookies); + console.log(`Reusing existing authentication state for user: ${userid}`); + await this.page.goto("/"); + await this.waitForLoad(12000); + await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); + await this.checkAndReauthorizeGithubApp(); + } else { + await this.logintoGithub(userid); + await this.page.goto("/"); + await this.waitForLoad(240000); + await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); + await this.checkAndReauthorizeGithubApp(); + await this.uiHelper.waitForSideBarVisible(); + await this.page.context().storageState({ path: sessionFileName }); + console.log(`Authentication state saved for user: ${userid}`); + } + } + + async checkAndReauthorizeGithubApp() { + /* oxlint-disable playwright/no-raw-locators -- GitHub OAuth authorize popup (third-party) */ + await new Promise((resolve) => { + this.page.once("popup", async (popup) => { + await popup.waitForLoadState(); + + const authorizeButton = popup.locator("button.js-oauth-authorize-btn"); + await Promise.race([ + popup.waitForEvent("close", { timeout: 10_000 }), + authorizeButton.waitFor({ state: "visible", timeout: 10_000 }), + ]).catch(() => {}); + + if (!popup.isClosed() && (await authorizeButton.isVisible())) { + await popup.locator("body").click(); + await authorizeButton.waitFor(); + await authorizeButton.click(); + } + resolve(); + }); + }); + /* oxlint-enable playwright/no-raw-locators */ + } + + 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; + } + } + + async clickOnGHloginPopup() { + const isLoginRequiredVisible = await this.uiHelper.isTextVisible( + t["user-settings"][lang]["providerSettingsItem.buttonTitle.signIn"], + ); + if (isLoginRequiredVisible) { + await this.uiHelper.clickButton( + t["user-settings"][lang]["providerSettingsItem.buttonTitle.signIn"], + ); + await this.uiHelper.clickButton(t["core-components"][lang]["oauthRequestDialog.login"]); + await this.checkAndReauthorizeGithubApp(); + await this.uiHelper.waitForLoginBtnDisappear(); + } else { + console.log('"Log in" button is not visible. Skipping login popup actions.'); + } + } + + getGitHub2FAOTP(userid: string): string { + const ghUserId = process.env.GH_USER_ID; + const ghUser2Id = process.env.GH_USER2_ID; + const secrets: Record = {}; + if (ghUserId !== undefined && ghUserId !== "") { + secrets[ghUserId] = process.env.GH_2FA_SECRET; + } + if (ghUser2Id !== undefined && ghUser2Id !== "") { + secrets[ghUser2Id] = process.env.GH_USER2_2FA_SECRET; + } + + const secret = secrets[userid]; + if (secret === undefined || secret === "") { + throw new Error("Invalid User ID"); + } + + return authenticator.generate(secret); + } + + getGoogle2FAOTP(): string { + const secret = process.env.GOOGLE_2FA_SECRET; + if (secret === undefined || secret === "") { + throw new Error("GOOGLE_2FA_SECRET is not set"); + } + return authenticator.generate(secret); + } + + async keycloakLogin(username: string, password: string) { + await this.page.goto("/"); + await this.page.waitForSelector( + `p:has-text("${t["rhdh"][lang]["signIn.providers.oidc.message"]}")`, + ); + + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), + ]); + + return handleKeycloakPopupLogin(popup, username, password); + } + + async githubLogin(username: string, password: string, twofactor: string) { + await this.page.goto("/"); + await this.page.waitForSelector( + `p:has-text("${t["rhdh"][lang]["signIn.providers.github.message"]}")`, + ); + + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), + ]); + + return handleGitHubPopupLogin(popup, username, password, twofactor); + } + + async githubLoginFromSettingsPage(username: string, password: string, twofactor: string) { + await this.page.goto("/settings/auth-providers"); + + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.page + .getByTitle( + t["user-settings"][lang]["providerSettingsItem.title.signIn"].replace( + "{{title}}", + "GitHub", + ), + ) + .click(), + this.uiHelper.clickButton(t["core-components"][lang]["oauthRequestDialog.login"]), + ]); + + return handleGitHubPopupLogin(popup, username, password, twofactor); + } + + async gitlabLogin(username: string, password: string) { + await this.page.goto("/"); + await this.page.waitForSelector( + `p:has-text("${t["rhdh"][lang]["signIn.providers.gitlab.message"]}")`, + ); + + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), + ]); + + return handleGitlabPopupLogin(popup, username, password); + } + + async MicrosoftAzureLogin(username: string, password: string) { + await this.page.goto("/"); + await this.page.waitForSelector( + `p:has-text("${t["rhdh"][lang]["signIn.providers.microsoft.message"]}")`, + ); + + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), + ]); + + return handleMicrosoftAzurePopupLogin(popup, username, password); + } + + async pingFederateLogin(username: string, password: string) { + await this.page.goto("/"); + await this.page.waitForSelector( + `p:has-text("${t["rhdh"][lang]["signIn.providers.oidc.message"]}")`, + ); + + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), + ]); + + return handlePingFederatePopupLogin(popup, username, password); + } +} diff --git a/e2e-tests/playwright/utils/kube-client-configmap.ts b/e2e-tests/playwright/utils/kube-client/configmap.ts similarity index 99% rename from e2e-tests/playwright/utils/kube-client-configmap.ts rename to e2e-tests/playwright/utils/kube-client/configmap.ts index f93806457c..65139a8c49 100644 --- a/e2e-tests/playwright/utils/kube-client-configmap.ts +++ b/e2e-tests/playwright/utils/kube-client/configmap.ts @@ -2,7 +2,7 @@ import * as k8s from "@kubernetes/client-node"; import * as yaml from "js-yaml"; import { hasErrorResponse } from "./errors"; -import { APP_CONFIG_NAMES, getKubeApiErrorMessage, isRecord } from "./kube-client-helpers"; +import { APP_CONFIG_NAMES, getKubeApiErrorMessage, isRecord } from "./helpers"; function hasAppConfigDataKey(data: Record): boolean { return Object.keys(data).some((key) => key.includes("app-config") && key.endsWith(".yaml")); diff --git a/e2e-tests/playwright/utils/kube-client-deployment-restart.ts b/e2e-tests/playwright/utils/kube-client/deployment/restart.ts similarity index 97% rename from e2e-tests/playwright/utils/kube-client-deployment-restart.ts rename to e2e-tests/playwright/utils/kube-client/deployment/restart.ts index bedf7738e9..d7ad531ae6 100644 --- a/e2e-tests/playwright/utils/kube-client-deployment-restart.ts +++ b/e2e-tests/playwright/utils/kube-client/deployment/restart.ts @@ -1,4 +1,4 @@ -import { getKubeApiErrorMessage, sleep } from "./kube-client-helpers"; +import { getKubeApiErrorMessage, sleep } from "../helpers"; async function scaleDeploymentDown( scaleDeployment: (deploymentName: string, namespace: string, replicas: number) => Promise, diff --git a/e2e-tests/playwright/utils/kube-client-deployment-scale.ts b/e2e-tests/playwright/utils/kube-client/deployment/scale.ts similarity index 98% rename from e2e-tests/playwright/utils/kube-client-deployment-scale.ts rename to e2e-tests/playwright/utils/kube-client/deployment/scale.ts index ebf2639a87..3a76f369ae 100644 --- a/e2e-tests/playwright/utils/kube-client-deployment-scale.ts +++ b/e2e-tests/playwright/utils/kube-client/deployment/scale.ts @@ -1,6 +1,6 @@ import * as k8s from "@kubernetes/client-node"; -import { getErrorStatusCode, getKubeApiErrorMessage, sleep } from "./kube-client-helpers"; +import { getErrorStatusCode, getKubeApiErrorMessage, sleep } from "../helpers"; export async function getDeploymentPodSelectorImpl( appsApi: k8s.AppsV1Api, diff --git a/e2e-tests/playwright/utils/kube-client-deployment-wait.ts b/e2e-tests/playwright/utils/kube-client/deployment/wait.ts similarity index 99% rename from e2e-tests/playwright/utils/kube-client-deployment-wait.ts rename to e2e-tests/playwright/utils/kube-client/deployment/wait.ts index c21925b18d..8a98e17abc 100644 --- a/e2e-tests/playwright/utils/kube-client-deployment-wait.ts +++ b/e2e-tests/playwright/utils/kube-client/deployment/wait.ts @@ -1,6 +1,6 @@ import * as k8s from "@kubernetes/client-node"; -import { getKubeApiErrorMessage, PodFailureResult, sleep } from "./kube-client-helpers"; +import { getKubeApiErrorMessage, PodFailureResult, sleep } from "../helpers"; export interface DeploymentDiagnostics { logDeploymentEvents: (deploymentName: string, namespace: string) => Promise; diff --git a/e2e-tests/playwright/utils/kube-client-diagnostics-events.ts b/e2e-tests/playwright/utils/kube-client/diagnostics/events.ts similarity index 99% rename from e2e-tests/playwright/utils/kube-client-diagnostics-events.ts rename to e2e-tests/playwright/utils/kube-client/diagnostics/events.ts index c24f97ff24..76b050d500 100644 --- a/e2e-tests/playwright/utils/kube-client-diagnostics-events.ts +++ b/e2e-tests/playwright/utils/kube-client/diagnostics/events.ts @@ -6,7 +6,7 @@ import { getEventSortTimestamp, getKubeApiErrorMessage, podNameOrUnknown, -} from "./kube-client-helpers"; +} from "../helpers"; const BACKSTAGE_POD_NAME_FRAGMENT = "backstage-developer-hub"; diff --git a/e2e-tests/playwright/utils/kube-client-diagnostics-pods.ts b/e2e-tests/playwright/utils/kube-client/diagnostics/pods.ts similarity index 99% rename from e2e-tests/playwright/utils/kube-client-diagnostics-pods.ts rename to e2e-tests/playwright/utils/kube-client/diagnostics/pods.ts index e49ef15864..8b18d8cbff 100644 --- a/e2e-tests/playwright/utils/kube-client-diagnostics-pods.ts +++ b/e2e-tests/playwright/utils/kube-client/diagnostics/pods.ts @@ -5,7 +5,7 @@ import { formatContainerStartedAt, getKubeApiErrorMessage, podNameOrUnknown, -} from "./kube-client-helpers"; +} from "../helpers"; function logWaitingContainerStatus( containerName: string, diff --git a/e2e-tests/playwright/utils/kube-client-diagnostics-replicasets.ts b/e2e-tests/playwright/utils/kube-client/diagnostics/replicasets.ts similarity index 97% rename from e2e-tests/playwright/utils/kube-client-diagnostics-replicasets.ts rename to e2e-tests/playwright/utils/kube-client/diagnostics/replicasets.ts index df52acd050..42eb76b83d 100644 --- a/e2e-tests/playwright/utils/kube-client-diagnostics-replicasets.ts +++ b/e2e-tests/playwright/utils/kube-client/diagnostics/replicasets.ts @@ -1,6 +1,6 @@ import * as k8s from "@kubernetes/client-node"; -import { getKubeApiErrorMessage, podNameOrUnknown } from "./kube-client-helpers"; +import { getKubeApiErrorMessage, podNameOrUnknown } from "../helpers"; function sortReplicaSetsByCreation(replicaSets: k8s.V1ReplicaSet[]): k8s.V1ReplicaSet[] { // oxlint-disable-next-line unicorn/no-array-sort -- es2022 lib has no Array#toSorted diff --git a/e2e-tests/playwright/utils/kube-client-exec.ts b/e2e-tests/playwright/utils/kube-client/exec.ts similarity index 97% rename from e2e-tests/playwright/utils/kube-client-exec.ts rename to e2e-tests/playwright/utils/kube-client/exec.ts index e0c2e56e32..e46892f44d 100644 --- a/e2e-tests/playwright/utils/kube-client-exec.ts +++ b/e2e-tests/playwright/utils/kube-client/exec.ts @@ -2,7 +2,7 @@ import * as stream from "stream"; import * as k8s from "@kubernetes/client-node"; -import { getKubeApiErrorMessage } from "./kube-client-helpers"; +import { getKubeApiErrorMessage } from "./helpers"; function createOutputCaptureStreams(): { capture: { stdout: string; stderr: string }; diff --git a/e2e-tests/playwright/utils/kube-client-helpers.ts b/e2e-tests/playwright/utils/kube-client/helpers.ts similarity index 99% rename from e2e-tests/playwright/utils/kube-client-helpers.ts rename to e2e-tests/playwright/utils/kube-client/helpers.ts index b53353223b..b201471e71 100644 --- a/e2e-tests/playwright/utils/kube-client-helpers.ts +++ b/e2e-tests/playwright/utils/kube-client/helpers.ts @@ -1,6 +1,6 @@ import * as k8s from "@kubernetes/client-node"; -import { getErrorMessage, hasErrorResponse, hasStatusCode } from "./errors"; +import { getErrorMessage, hasErrorResponse, hasStatusCode } from "../errors"; export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/e2e-tests/playwright/utils/kube-client.ts b/e2e-tests/playwright/utils/kube-client/index.ts similarity index 95% rename from e2e-tests/playwright/utils/kube-client.ts rename to e2e-tests/playwright/utils/kube-client/index.ts index 883decc602..0c8ae85e22 100644 --- a/e2e-tests/playwright/utils/kube-client.ts +++ b/e2e-tests/playwright/utils/kube-client/index.ts @@ -1,19 +1,19 @@ import * as k8s from "@kubernetes/client-node"; import { V1ConfigMap } from "@kubernetes/client-node"; -import { hasStatusCode } from "./errors"; -import { findAppConfigMapName, updateConfigMapTitleImpl } from "./kube-client-configmap"; -import { restartDeploymentImpl } from "./kube-client-deployment-restart"; -import { getDeploymentPodSelectorImpl, scaleDeploymentImpl } from "./kube-client-deployment-scale"; -import { waitForDeploymentReadyImpl } from "./kube-client-deployment-wait"; -import { logDeploymentEventsImpl, logPodEventsImpl } from "./kube-client-diagnostics-events"; +import { hasStatusCode } from "../errors"; +import { findAppConfigMapName, updateConfigMapTitleImpl } from "./configmap"; +import { restartDeploymentImpl } from "./deployment/restart"; +import { getDeploymentPodSelectorImpl, scaleDeploymentImpl } from "./deployment/scale"; +import { waitForDeploymentReadyImpl } from "./deployment/wait"; +import { logDeploymentEventsImpl, logPodEventsImpl } from "./diagnostics/events"; import { logPodConditionsForDeploymentImpl, logPodContainerLogsImpl, logPodConditionsImpl, -} from "./kube-client-diagnostics-pods"; -import { logReplicaSetStatusImpl } from "./kube-client-diagnostics-replicasets"; -import { execPodCommandImpl } from "./kube-client-exec"; +} from "./diagnostics/pods"; +import { logReplicaSetStatusImpl } from "./diagnostics/replicasets"; +import { execPodCommandImpl } from "./exec"; import { formatKubeErrorLog, getErrorStatusCode, @@ -21,8 +21,8 @@ import { getRhdhDeploymentName, PodFailureResult, rejectAsError, -} from "./kube-client-helpers"; -import { checkPodFailureStatesImpl } from "./kube-client-pod-failure"; +} from "./helpers"; +import { checkPodFailureStatesImpl } from "./pod-failure"; export { getRhdhDeploymentName }; export type { PodFailureResult }; diff --git a/e2e-tests/playwright/utils/kube-client-pod-failure.ts b/e2e-tests/playwright/utils/kube-client/pod-failure.ts similarity index 99% rename from e2e-tests/playwright/utils/kube-client-pod-failure.ts rename to e2e-tests/playwright/utils/kube-client/pod-failure.ts index 4229610ff1..e62146a941 100644 --- a/e2e-tests/playwright/utils/kube-client-pod-failure.ts +++ b/e2e-tests/playwright/utils/kube-client/pod-failure.ts @@ -1,6 +1,6 @@ import * as k8s from "@kubernetes/client-node"; -import { getKubeApiErrorMessage, PodFailureResult, podNameOrUnknown } from "./kube-client-helpers"; +import { getKubeApiErrorMessage, PodFailureResult, podNameOrUnknown } from "./helpers"; const POD_READY_ERROR_REASONS = [ "Unhealthy", From c584b32457e9469c41e9be5c83de56acead0bb09 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 24 Jun 2026 13:14:09 -0500 Subject: [PATCH 6/8] refactor(e2e): use folder index entry points for all split utils Remove redundant top-level common.ts and ui-helper.ts facades so split modules match api-helper and kube-client (folder + index only). Add ui-helper/index.ts and move msgraph-helper split into msgraph-helper/. Co-authored-by: Cursor --- .../{msgraph-helper.ts => msgraph-helper/index.ts} | 8 ++------ .../{msgraph-helper-nsg.ts => msgraph-helper/nsg.ts} | 2 +- e2e-tests/playwright/utils/common.ts | 1 - e2e-tests/playwright/utils/ui-helper.ts | 1 - e2e-tests/playwright/utils/ui-helper/index.ts | 1 + 5 files changed, 4 insertions(+), 9 deletions(-) rename e2e-tests/playwright/utils/authentication-providers/{msgraph-helper.ts => msgraph-helper/index.ts} (98%) rename e2e-tests/playwright/utils/authentication-providers/{msgraph-helper-nsg.ts => msgraph-helper/nsg.ts} (99%) delete mode 100644 e2e-tests/playwright/utils/common.ts delete mode 100644 e2e-tests/playwright/utils/ui-helper.ts create mode 100644 e2e-tests/playwright/utils/ui-helper/index.ts diff --git a/e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper/index.ts similarity index 98% rename from e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts rename to e2e-tests/playwright/utils/authentication-providers/msgraph-helper/index.ts index c932858740..8296025452 100644 --- a/e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts +++ b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper/index.ts @@ -6,12 +6,8 @@ import { Client, PageCollection } from "@microsoft/microsoft-graph-client"; import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials/index.js"; import { User, Group } from "@microsoft/microsoft-graph-types"; -import { hasStatusCode } from "../errors"; -import { - allowPublicIpInNsg, - getNetworkSecurityGroup, - getNetworkSecurityGroupRule, -} from "./msgraph-helper-nsg"; +import { hasStatusCode } from "../../errors"; +import { allowPublicIpInNsg, getNetworkSecurityGroup, getNetworkSecurityGroupRule } from "./nsg"; interface AzureApplicationWeb { redirectUris?: string[]; diff --git a/e2e-tests/playwright/utils/authentication-providers/msgraph-helper-nsg.ts b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper/nsg.ts similarity index 99% rename from e2e-tests/playwright/utils/authentication-providers/msgraph-helper-nsg.ts rename to e2e-tests/playwright/utils/authentication-providers/msgraph-helper/nsg.ts index 2e3768a317..7bab0dbae4 100644 --- a/e2e-tests/playwright/utils/authentication-providers/msgraph-helper-nsg.ts +++ b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper/nsg.ts @@ -5,7 +5,7 @@ import { SecurityRulesGetResponse, } from "@azure/arm-network"; -import { getErrorMessage, hasStatusCode } from "../errors"; +import { getErrorMessage, hasStatusCode } from "../../errors"; export async function getNetworkSecurityGroupRule( armNetworkClient: NetworkManagementClient, diff --git a/e2e-tests/playwright/utils/common.ts b/e2e-tests/playwright/utils/common.ts deleted file mode 100644 index 832c605070..0000000000 --- a/e2e-tests/playwright/utils/common.ts +++ /dev/null @@ -1 +0,0 @@ -export { Common, setupBrowser, teardownBrowser } from "./common/index"; diff --git a/e2e-tests/playwright/utils/ui-helper.ts b/e2e-tests/playwright/utils/ui-helper.ts deleted file mode 100644 index ac3175238e..0000000000 --- a/e2e-tests/playwright/utils/ui-helper.ts +++ /dev/null @@ -1 +0,0 @@ -export { UIhelper } from "./ui-helper/class"; diff --git a/e2e-tests/playwright/utils/ui-helper/index.ts b/e2e-tests/playwright/utils/ui-helper/index.ts new file mode 100644 index 0000000000..931149cc10 --- /dev/null +++ b/e2e-tests/playwright/utils/ui-helper/index.ts @@ -0,0 +1 @@ +export { UIhelper } from "./class"; From 52c2a19a8eeeb4c315aae6cb028d448cd8053cd6 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 24 Jun 2026 13:30:21 -0500 Subject: [PATCH 7/8] fix(e2e): correct relative imports after utils folder split Folder moves left one-level-too-shallow paths to errors, api-endpoints, support, and locale modules, which broke type-aware oxlint in CI. Use global fetch in keycloak helper now that Node 24 provides it natively. Co-authored-by: Cursor --- e2e-tests/playwright/utils/api-helper/github.ts | 2 +- .../utils/authentication-providers/rhdh-deployment/index.ts | 2 +- e2e-tests/playwright/utils/common/browser.ts | 2 +- e2e-tests/playwright/utils/common/index.ts | 4 ++-- e2e-tests/playwright/utils/keycloak/keycloak.ts | 1 - e2e-tests/playwright/utils/kube-client/configmap.ts | 2 +- 6 files changed, 6 insertions(+), 7 deletions(-) diff --git a/e2e-tests/playwright/utils/api-helper/github.ts b/e2e-tests/playwright/utils/api-helper/github.ts index 5cfc8b8ac3..6f4d067437 100644 --- a/e2e-tests/playwright/utils/api-helper/github.ts +++ b/e2e-tests/playwright/utils/api-helper/github.ts @@ -1,6 +1,6 @@ import { request, type APIResponse, expect } from "@playwright/test"; -import { GITHUB_API_ENDPOINTS } from "./api-endpoints"; +import { GITHUB_API_ENDPOINTS } from "../api-endpoints"; import { type GitHubPullRequestFile, isGitHubPullRequestFile, diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/index.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/index.ts index c716dc203c..9cad3cb804 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/index.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/index.ts @@ -372,7 +372,7 @@ class RHDHDeployment implements RHDHDeploymentState { const response = await fetch(baseUrl, { method: "HEAD" }); return response.status === 200; } catch (error: unknown) { - const { getErrorMessage } = await import("../errors"); + const { getErrorMessage } = await import("../../errors"); console.log(`Error: ${getErrorMessage(error)}`); return false; } diff --git a/e2e-tests/playwright/utils/common/browser.ts b/e2e-tests/playwright/utils/common/browser.ts index b68c6d97a0..42b59dc978 100644 --- a/e2e-tests/playwright/utils/common/browser.ts +++ b/e2e-tests/playwright/utils/common/browser.ts @@ -2,7 +2,7 @@ import * as path from "path"; import { type Browser, type Cookie, type Page, type TestInfo } from "@playwright/test"; -import { startCoverageForPage, stopCoverageForPage } from "../support/coverage/test"; +import { startCoverageForPage, stopCoverageForPage } from "../../support/coverage/test"; export function parseAuthStateCookies(content: string): Cookie[] { const parsed: unknown = JSON.parse(content); diff --git a/e2e-tests/playwright/utils/common/index.ts b/e2e-tests/playwright/utils/common/index.ts index 73629c1624..be52055712 100644 --- a/e2e-tests/playwright/utils/common/index.ts +++ b/e2e-tests/playwright/utils/common/index.ts @@ -3,9 +3,9 @@ import * as fs from "fs"; import { test, Page } from "@playwright/test"; import { authenticator } from "otplib"; -import { getTranslations, getCurrentLanguage } from "../e2e/localization/locale"; +import { getTranslations, getCurrentLanguage } from "../../e2e/localization/locale"; +import { SETTINGS_PAGE_COMPONENTS } from "../../support/page-objects/page-obj"; import { getErrorMessage } from "../errors"; -import { SETTINGS_PAGE_COMPONENTS } from "../support/page-objects/page-obj"; import { UIhelper } from "../ui-helper"; import { handleGitHubPopupLogin, diff --git a/e2e-tests/playwright/utils/keycloak/keycloak.ts b/e2e-tests/playwright/utils/keycloak/keycloak.ts index 7e0cab190d..2613c9b133 100644 --- a/e2e-tests/playwright/utils/keycloak/keycloak.ts +++ b/e2e-tests/playwright/utils/keycloak/keycloak.ts @@ -1,5 +1,4 @@ import { expect, Page } from "@playwright/test"; -import fetch from "node-fetch"; import { CatalogUsersPO } from "../../support/page-objects/catalog/catalog-users-obj"; import { UIhelper } from "../ui-helper"; diff --git a/e2e-tests/playwright/utils/kube-client/configmap.ts b/e2e-tests/playwright/utils/kube-client/configmap.ts index 65139a8c49..078e71395a 100644 --- a/e2e-tests/playwright/utils/kube-client/configmap.ts +++ b/e2e-tests/playwright/utils/kube-client/configmap.ts @@ -1,7 +1,7 @@ import * as k8s from "@kubernetes/client-node"; import * as yaml from "js-yaml"; -import { hasErrorResponse } from "./errors"; +import { hasErrorResponse } from "../errors"; import { APP_CONFIG_NAMES, getKubeApiErrorMessage, isRecord } from "./helpers"; function hasAppConfigDataKey(data: Record): boolean { From b76cb6c447a1bed49693e96cadd470bd33ab6680 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 24 Jun 2026 14:35:24 -0500 Subject: [PATCH 8/8] fix(e2e): restore legacy card/table scoping for home page tests Semantic getAccordion/getCard/getTableRow locators were too narrow or ambiguous for MUI home page and scaffolder review tables. Scope quick access items to the accordion root, cards via MuiCard XPath, and rows via :text-is() matching until semantic selectors cover these layouts. Co-authored-by: Cursor --- .../support/page-objects/ui-locators.ts | 21 +++++++++++++++++-- .../playwright/support/pages/home-page.ts | 16 ++++++++++---- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/e2e-tests/playwright/support/page-objects/ui-locators.ts b/e2e-tests/playwright/support/page-objects/ui-locators.ts index 976db10c53..d9e886ce5b 100644 --- a/e2e-tests/playwright/support/page-objects/ui-locators.ts +++ b/e2e-tests/playwright/support/page-objects/ui-locators.ts @@ -2,8 +2,13 @@ import { Locator, Page } from "@playwright/test"; import { SemanticSelectors } from "../selectors/semantic"; +import { UI_HELPER_ELEMENTS } from "./global-obj"; export function getCardByHeading(page: Page, heading: string | RegExp): Locator { + if (typeof heading === "string") { + /* oxlint-disable-next-line typescript/no-deprecated -- MUI cards lack region/heading roles; XPath matches production DOM */ + return page.locator(UI_HELPER_ELEMENTS.MuiCard(heading)); + } return page .locator('[role="region"], article, section') .filter({ @@ -13,6 +18,10 @@ export function getCardByHeading(page: Page, heading: string | RegExp): Locator } export function getCardByText(page: Page, text: string | RegExp): Locator { + if (typeof text === "string") { + /* oxlint-disable-next-line typescript/no-deprecated -- MUI cards lack region roles; XPath matches production DOM */ + return page.locator(UI_HELPER_ELEMENTS.MuiCardRoot(text)); + } return page .locator('[role="region"], article, section') .filter({ @@ -24,5 +33,13 @@ export function getCardByText(page: Page, text: string | RegExp): Locator { export const getTableCell = (page: Page, text?: string | RegExp): Locator => SemanticSelectors.tableCell(page, text); -export const getTableRow = (page: Page, text?: string | RegExp): Locator => - SemanticSelectors.tableRow(page, text); +export function getTableRow(page: Page, text?: string | RegExp): Locator { + if (text === undefined) { + return SemanticSelectors.tableRow(page); + } + if (typeof text === "string") { + /* oxlint-disable-next-line typescript/no-deprecated -- :text-is() avoids ambiguous hasText row matches in review tables */ + return page.locator(UI_HELPER_ELEMENTS.rowByText(text)); + } + return SemanticSelectors.tableRow(page, text); +} diff --git a/e2e-tests/playwright/support/pages/home-page.ts b/e2e-tests/playwright/support/pages/home-page.ts index 63987b7102..9d04660f62 100644 --- a/e2e-tests/playwright/support/pages/home-page.ts +++ b/e2e-tests/playwright/support/pages/home-page.ts @@ -21,11 +21,16 @@ export class HomePage { } async verifyQuickAccess(section: string, items: string | string[], expand = false) { - const sectionLocator = HOME_PAGE_COMPONENTS.getAccordion(this.page, section); - await expect(sectionLocator).toBeVisible(); + const accordionButton = HOME_PAGE_COMPONENTS.getAccordion(this.page, section); + await expect(accordionButton).toBeVisible(); + + const sectionLocator = this.page + /* oxlint-disable-next-line typescript/no-deprecated -- accordion items live outside the summary button node */ + .locator(HOME_PAGE_COMPONENTS.MuiAccordion) + .filter({ has: accordionButton }); if (expand) { - await sectionLocator.click(); + await accordionButton.click(); await expect(sectionLocator.locator('[class*="MuiAccordionDetails-root"]')).toBeVisible(); } @@ -39,7 +44,10 @@ export class HomePage { } async verifyVisitedCardContent(section: string) { - const sectionLocator = HOME_PAGE_COMPONENTS.getCard(this.page, section); + const sectionLocator = this.page + /* oxlint-disable-next-line typescript/no-deprecated -- visited cards use MuiCard-root, not region/article roles */ + .locator(HOME_PAGE_COMPONENTS.MuiCard) + .filter({ hasText: section }); await expect(sectionLocator).toBeVisible(); const itemLocator = sectionLocator.locator(`li[class*="MuiListItem-root"]`);