diff --git a/e2e-tests/oxlint.config.ts b/e2e-tests/oxlint.config.ts index 102f60fa49..648437e905 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", { @@ -61,12 +64,57 @@ 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", + "typescript/no-misused-promises": "off", }, }, { + // 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", + "eslint/max-lines-per-function": "off", + }, + }, + { + // 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/**/*.ts", + "playwright/support/**/*.ts", + "playwright/data/**/*.ts", + "playwright/e2e/**/*.ts", + ], + rules: { + "eslint/max-lines": "off", + "eslint/max-lines-per-function": "off", + "eslint/max-depth": "off", + }, + }, + { + // 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 @@ -107,6 +155,7 @@ export default defineConfig({ "verifyTextInSelector", "verifyPartialTextInSelector", "loginAsGuest", + "restartDeployment", "waitForTitle", ], }, 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..af68819ab1 --- /dev/null +++ b/e2e-tests/playwright/data/rbac-constants-roles.ts @@ -0,0 +1,56 @@ +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..bec084c7a0 100644 --- a/e2e-tests/playwright/e2e/audit-log/log-utils.ts +++ b/e2e-tests/playwright/e2e/audit-log/log-utils.ts @@ -106,7 +106,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) { @@ -152,7 +152,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) { @@ -203,7 +203,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 { @@ -253,10 +253,10 @@ 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) { + if (token === "" || server === "") { throw new Error("Environment variables K8S_CLUSTER_TOKEN and K8S_CLUSTER_URL must be set."); } @@ -290,11 +290,15 @@ 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); 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..f917bb5024 100644 --- a/e2e-tests/playwright/e2e/auth-providers/github.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/github.spec.ts @@ -15,6 +15,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 +83,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,7 +132,7 @@ 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}`); }); @@ -240,8 +245,10 @@ test.describe("Configure Github Provider", async () => { 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(); @@ -258,10 +265,9 @@ test.describe("Configure Github Provider", async () => { test.setTimeout(300 * 1000); await expect - .poll( - async () => 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"]), @@ -306,7 +312,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..3925d504eb 100644 --- a/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts @@ -15,6 +15,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,7 +79,8 @@ 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}`); @@ -96,7 +98,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); } @@ -131,7 +137,7 @@ 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}`); }); @@ -149,8 +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( - async () => - deployment.checkUserIsIngestedInCatalog(["user1", "user2", "user3", "Administrator"]), + () => deployment.checkUserIsIngestedInCatalog(["user1", "user2", "user3", "Administrator"]), { timeout: 120_000 }, ) .toBe(true); @@ -199,7 +204,7 @@ 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"); diff --git a/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts b/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts index a89b01c162..c5c0a42b61 100644 --- a/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts @@ -86,7 +86,11 @@ 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); } @@ -164,7 +168,7 @@ 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}`); }); @@ -234,7 +238,8 @@ test.describe("Configure LDAP Provider", () => { deployment.setAppConfigProperty("auth.providers.oidc.production.signIn.resolvers", [ { resolver: "oidcLdapUuidMatchingAnnotation", - ldapUuidKey: "sub", // match sub claim as required by OIDC spec + // match sub claim as required by OIDC spec + ldapUuidKey: "sub", }, ]); 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..1b359e4e54 100644 --- a/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts @@ -19,6 +19,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,7 +92,11 @@ 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); } @@ -128,7 +133,7 @@ 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}`); }); @@ -273,8 +278,10 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { 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(); @@ -378,10 +385,8 @@ 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"); - deployment.setAppConfigProperty( - "auth.autologout.idleTimeoutMinutes", - 0.5, // minimum allowed value is 0.5 minutes - ); + // minimum allowed value is 0.5 minutes + deployment.setAppConfigProperty("auth.autologout.idleTimeoutMinutes", 0.5); deployment.setAppConfigProperty("auth.autologout.promptBeforeIdleSeconds", 5); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); @@ -408,10 +413,8 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { test(`Enable autologout and user stays logged in after clicking "Don't log me out"`, async () => { deployment.setAppConfigProperty("auth.autologout.enabled", "true"); - deployment.setAppConfigProperty( - "auth.autologout.idleTimeoutMinutes", - 0.5, // minimum allowed value is 0.5 minutes - ); + // minimum allowed value is 0.5 minutes + deployment.setAppConfigProperty("auth.autologout.idleTimeoutMinutes", 0.5); deployment.setAppConfigProperty("auth.autologout.promptBeforeIdleSeconds", 5); await deployment.updateAllConfigs(); await deployment.waitForConfigReconciled(); 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..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,4 +1,4 @@ -import { test, expect } from "@support/coverage/test"; +import { test } from "@support/coverage/test"; import { Common } from "../../utils/common"; import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; @@ -45,7 +45,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); - if (!azureCerts) { + 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", ); @@ -87,8 +87,7 @@ 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 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-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..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,4 +1,4 @@ -import { test, expect } from "@support/coverage/test"; +import { test } from "@support/coverage/test"; import { Common } from "../../utils/common"; import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; @@ -45,7 +45,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", ); @@ -87,8 +87,7 @@ 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 kubeClient.restartDeployment(deploymentName, namespace); }); 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..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 @@ -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 { @@ -92,7 +92,7 @@ const defaultConnectionOptions: Partial = { keepAliveInitialDelayMillis: 10000, }; -export async function connectWithSslFallback(config: ClientConfig): Promise { +export function connectWithSslFallback(config: ClientConfig): Promise { return connectWithRetry({ ...defaultConnectionOptions, ...config }); } @@ -110,15 +110,15 @@ export function getSchemaModeEnv(): SchemaModeEnv { 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({ @@ -170,11 +170,11 @@ export async function setupSchemaModeDatabase( ): Promise { const { dbHost, dbAdminUser, dbAdminPassword, dbName, dbUser, dbPassword } = config; - if (dbName !== "postgres") { + 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}`); - } else { - console.log(`✓ Using default postgres database`); } await adminClient 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..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 @@ -87,7 +87,7 @@ 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`; } @@ -135,12 +135,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 @@ -174,66 +174,65 @@ export class SchemaModeTestSetup { deploymentName, this.namespace, ); - const containers = deployment.body.spec?.template?.spec?.containers || []; + 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[backstageIdx]; - 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)); + + if (missingVars.length === 0) { + console.log("POSTGRES_* env vars already present in deployment"); + return; + } - console.log(`Adding env vars to deployment: ${missingVars.join(", ")}`); - const patch: { op: string; path: string; value?: unknown }[] = []; + console.log(`Adding env vars to deployment: ${missingVars.join(", ")}`); + const patch: { op: string; path: string; value?: unknown }[] = []; - if (!backstageContainer.env || backstageContainer.env.length === 0) { - patch.push({ - op: "add", - path: `/spec/template/spec/containers/${backstageIdx}/env`, - value: [], - }); - } + 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 { @@ -250,14 +249,14 @@ 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) { + 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 = @@ -312,8 +311,9 @@ export class SchemaModeTestSetup { routeName, )) as { body?: { spec?: { host?: string } } }; - if (route?.body?.spec?.host) { - const url = `https://${route.body.spec.host}`; + const routeHost = route.body?.spec?.host; + if (routeHost !== undefined && routeHost !== "") { + const url = `https://${routeHost}`; console.log(`Found RHDH URL: ${url}`); return url; } 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..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 @@ -77,8 +77,8 @@ 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 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; @@ -87,14 +87,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,9 +120,6 @@ 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`); portForwardProcess = await startPortForward(pfNamespace, pfResource); 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..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 @@ -29,7 +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"), // Default repoOwner janus-qe + repoOwner: Buffer.from(process.env.GITHUB_ORG ?? "amFudXMtcWU=", "base64").toString("utf8"), }; test.beforeAll(async ({ browser }, 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..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 @@ -29,7 +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"), // Default repoOwner janus-qe + repoOwner: Buffer.from(process.env.GITHUB_ORG ?? "amFudXMtcWU=", "base64").toString("utf8"), }; test.beforeAll(async ({ browser }, testInfo) => { @@ -49,10 +49,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/e2e/plugins/user-settings-info-card.spec.ts b/e2e-tests/playwright/e2e/plugins/user-settings-info-card.spec.ts index 66d485e8a6..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 @@ -4,7 +4,7 @@ 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.beforeAll(() => { test.info().annotations.push({ type: "component", description: "plugins", 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..d41631ac5d 100644 --- a/e2e-tests/playwright/support/api/github-structures.ts +++ b/e2e-tests/playwright/support/api/github-structures.ts @@ -8,7 +8,7 @@ export class GetOrganizationResponse { 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..cd5c604a83 100644 --- a/e2e-tests/playwright/support/api/github.ts +++ b/e2e-tests/playwright/support/api/github.ts @@ -4,7 +4,7 @@ import { JANUS_ORG } from "../../utils/constants"; // https://docs.github.com/en/rest?apiVersion=2022-11-28 export default class GithubApi { - public async getReposFromOrg(org = JANUS_ORG) { + public getReposFromOrg(org = JANUS_ORG) { return APIHelper.getGithubPaginatedRequest(GITHUB_API_ENDPOINTS.orgRepos(org)); } @@ -15,7 +15,7 @@ export default class GithubApi { ); 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..ed57fbb544 100644 --- a/e2e-tests/playwright/support/api/rbac-api.ts +++ b/e2e-tests/playwright/support/api/rbac-api.ts @@ -11,11 +11,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,55 +34,49 @@ 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 */, - 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 }, }); } - 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 +85,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 +116,9 @@ 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/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 9a409e271c..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(); @@ -99,7 +99,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 })); @@ -130,7 +130,7 @@ 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), }; /** 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..d9e886ce5b --- /dev/null +++ b/e2e-tests/playwright/support/page-objects/ui-locators.ts @@ -0,0 +1,45 @@ +/* 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"; +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({ + has: page.getByRole("heading", { name: heading }), + }) + .first(); +} + +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({ + hasText: text, + }) + .first(); +} + +export const getTableCell = (page: Page, text?: string | RegExp): Locator => + SemanticSelectors.tableCell(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/backstage-showcase.ts b/e2e-tests/playwright/support/pages/backstage-showcase.ts new file mode 100644 index 0000000000..7a047dc571 --- /dev/null +++ b/e2e-tests/playwright/support/pages/backstage-showcase.ts @@ -0,0 +1,64 @@ +import { Page, expect } from "@playwright/test"; + +import { APIHelper } from "../../utils/api-helper"; +import { UIhelper } from "../../utils/ui-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/"; + await expect(this.page.locator(`a[href="${url}"]`)).toBeVisible(); + } + + 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..228535037a 100644 --- a/e2e-tests/playwright/support/pages/catalog-import.ts +++ b/e2e-tests/playwright/support/pages/catalog-import.ts @@ -1,9 +1,8 @@ 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"; const t = getTranslations(); const lang = getCurrentLanguage(); @@ -40,7 +39,7 @@ export class CatalogImport { * * @returns boolean indicating if the component is already registered */ - async isComponentAlreadyRegistered(): Promise { + isComponentAlreadyRegistered(): Promise { return this.uiHelper.isBtnVisible(t["catalog-import"][lang]["stepReviewLocation.refresh"]); } @@ -86,64 +85,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..9d04660f62 100644 --- a/e2e-tests/playwright/support/pages/home-page.ts +++ b/e2e-tests/playwright/support/pages/home-page.ts @@ -13,24 +13,24 @@ 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 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({ hasText: section }); + .filter({ has: accordionButton }); if (expand) { - await sectionLocator.click(); + await accordionButton.click(); await expect(sectionLocator.locator('[class*="MuiAccordionDetails-root"]')).toBeVisible(); } @@ -44,13 +44,11 @@ export class HomePage { } async verifyVisitedCardContent(section: string) { - await this.page.waitForSelector(HOME_PAGE_COMPONENTS.MuiCard, { - state: "visible", - }); - 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"]`); 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..893c7e0677 100644 --- a/e2e-tests/playwright/support/pages/rbac.ts +++ b/e2e-tests/playwright/support/pages/rbac.ts @@ -12,30 +12,29 @@ 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() { diff --git a/e2e-tests/playwright/support/selectors/semantic-selectors.ts b/e2e-tests/playwright/support/selectors/semantic-selectors.ts deleted file mode 100644 index 113c6e6963..0000000000 --- a/e2e-tests/playwright/support/selectors/semantic-selectors.ts +++ /dev/null @@ -1,459 +0,0 @@ -/* oxlint-disable typescript/no-extraneous-class -- grouped semantic locator helpers */ -import { Page, Locator } from "@playwright/test"; - -/** - * Semantic Selectors - Playwright Best Practices - * - * This class provides semantic locator methods following Playwright best practices. - * Prefer these methods over CSS class selectors for more stable and maintainable tests. - * - * Priority Order: - * 1. Role-based selectors (getByRole) - Preferred - * 2. Label/Placeholder selectors (getByLabel, getByPlaceholder) - * 3. Test ID selectors (getByTestId) - When semantic options not available - * 4. CSS selectors (locator) - Last resort only - * - * @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; - }); - } -} diff --git a/e2e-tests/playwright/support/selectors/semantic/accessibility.ts b/e2e-tests/playwright/support/selectors/semantic/accessibility.ts new file mode 100644 index 0000000000..a8db3bd61f --- /dev/null +++ b/e2e-tests/playwright/support/selectors/semantic/accessibility.ts @@ -0,0 +1,54 @@ +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/index.ts b/e2e-tests/playwright/support/selectors/semantic/index.ts new file mode 100644 index 0000000000..0e7a6e9de5 --- /dev/null +++ b/e2e-tests/playwright/support/selectors/semantic/index.ts @@ -0,0 +1,25 @@ +import { semanticSelectorsAccessibility } from "./accessibility"; +import { semanticSelectorsStructure } from "./structure"; + +/** + * Semantic Selectors - 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: + * 1. Role-based selectors (getByRole) - Preferred + * 2. Label/Placeholder selectors (getByLabel, getByPlaceholder) + * 3. Test ID selectors (getByTestId) - When semantic options not available + * 4. CSS selectors (locator) - Last resort only + * + * @see https://playwright.dev/docs/locators + * @see .cursor/rules/playwright-locators.mdc + */ +export const SemanticSelectors = { + ...semanticSelectorsAccessibility, + ...semanticSelectorsStructure, +}; + +export { findTableCell, findTableCellByColumn } from "./table-helpers"; +export { WaitStrategies } from "./wait-strategies"; diff --git a/e2e-tests/playwright/support/selectors/semantic/structure.ts b/e2e-tests/playwright/support/selectors/semantic/structure.ts new file mode 100644 index 0000000000..8a026de12a --- /dev/null +++ b/e2e-tests/playwright/support/selectors/semantic/structure.ts @@ -0,0 +1,80 @@ +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/table-helpers.ts b/e2e-tests/playwright/support/selectors/semantic/table-helpers.ts new file mode 100644 index 0000000000..ba13151d83 --- /dev/null +++ b/e2e-tests/playwright/support/selectors/semantic/table-helpers.ts @@ -0,0 +1,18 @@ +import { Page, Locator } from "@playwright/test"; + +import { semanticSelectorsAccessibility } from "./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/wait-strategies.ts b/e2e-tests/playwright/support/selectors/semantic/wait-strategies.ts new file mode 100644 index 0000000000..892838fe23 --- /dev/null +++ b/e2e-tests/playwright/support/selectors/semantic/wait-strategies.ts @@ -0,0 +1,20 @@ +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.ts b/e2e-tests/playwright/utils/api-helper.ts deleted file mode 100644 index 393997a25d..0000000000 --- a/e2e-tests/playwright/utils/api-helper.ts +++ /dev/null @@ -1,511 +0,0 @@ -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; -} - -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; - } - - async getGuestToken(): Promise { - const context = await request.newContext(); - const response = await context.post("/api/auth/guest/refresh"); - expect(response.status()).toBe(200); - const data: unknown = await parseJsonResponse(response); - if (!isGuestTokenResponse(data)) { - throw new Error("Guest token not found in response body"); - } - return data.backstageIdentity.token; - } - - async getGuestAuthHeader(): Promise<{ [key: string]: string }> { - const token = await this.getGuestToken(); - const headers = { - Authorization: `Bearer ${token}`, - }; - return headers; - } - - async UseStaticToken(token: string) { - this.useStaticToken = true; - this.staticToken = "Bearer " + token; - } - - async UseBaseUrl(url: string) { - this.baseUrl = url; - } - - static async APIRequestWithStaticToken( - method: string, - url: string, - staticToken: string, - body?: string | object, - ): Promise { - const context = await request.newContext(); - const options: { - method: string; - headers: { - Accept: string; - Authorization: string; - }; - data?: string | object; - } = { - method: method, - headers: { - Accept: "application/json", - Authorization: staticToken, - }, - }; - - if (body) { - options.data = body; - } - - const response = await context.fetch(url, options); - return response; - } - - 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); - 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); - 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); - 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); - 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 body: unknown = await parseJsonResponse(response); - if (!isUserEntity(body)) { - throw new TypeError(`Invalid catalog user response for ${user}`); - } - return body; - } - - async deleteUserEntityFromAPI(user: string): Promise { - const r = await this.getCatalogUserFromAPI(user); - if (!r.metadata?.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); - 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 body: unknown = await parseJsonResponse(response); - if (!isGroupEntity(body)) { - throw new TypeError(`Invalid catalog group response for ${group}`); - } - return body; - } - - 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); - return response.statusText(); - } - - 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, - ); - return location?.data?.id; - } -} 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..1650fef5d4 --- /dev/null +++ b/e2e-tests/playwright/utils/api-helper/catalog.ts @@ -0,0 +1,95 @@ +import { request } from "@playwright/test"; + +import { + type CatalogLocationEntry, + isCatalogLocationEntry, + isEntityMetadataResponse, + parseJsonResponse, +} from "./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..6f4d067437 --- /dev/null +++ b/e2e-tests/playwright/utils/api-helper/github.ts @@ -0,0 +1,163 @@ +import { request, type APIResponse, expect } from "@playwright/test"; + +import { GITHUB_API_ENDPOINTS } from "../api-endpoints"; +import { + type GitHubPullRequestFile, + isGitHubPullRequestFile, + parseJsonResponse, + toUnknownArray, +} from "./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..346e25a6f4 --- /dev/null +++ b/e2e-tests/playwright/utils/api-helper/guards.ts @@ -0,0 +1,82 @@ +import { type GroupEntity, type UserEntity } from "@backstage/catalog-model"; +import { type APIResponse } from "@playwright/test"; + +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/index.ts b/e2e-tests/playwright/utils/api-helper/index.ts new file mode 100644 index 0000000000..5afaefd3f4 --- /dev/null +++ b/e2e-tests/playwright/utils/api-helper/index.ts @@ -0,0 +1,161 @@ +import { type GroupEntity, type UserEntity } from "@backstage/catalog-model"; +import { request, type APIResponse, expect } from "@playwright/test"; + +import * as catalogApi from "./catalog"; +import * as githubApi from "./github"; +import { isGuestTokenResponse, isGroupEntity, isUserEntity, parseJsonResponse } from "./guards"; + +export class APIHelper { + private staticToken = ""; + private baseUrl = ""; + useStaticToken = false; + + 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(); + const response = await context.post("/api/auth/guest/refresh"); + expect(response.status()).toBe(200); + const data: unknown = await parseJsonResponse(response); + if (!isGuestTokenResponse(data)) { + throw new Error("Guest token not found in response body"); + } + return data.backstageIdentity.token; + } + + async getGuestAuthHeader(): Promise<{ [key: string]: string }> { + const token = await this.getGuestToken(); + const headers = { + Authorization: `Bearer ${token}`, + }; + return headers; + } + + UseStaticToken(token: string): void { + this.useStaticToken = true; + this.staticToken = "Bearer " + token; + } + + UseBaseUrl(url: string): void { + this.baseUrl = url; + } + + static async APIRequestWithStaticToken( + method: string, + url: string, + staticToken: string, + body?: string | object, + ): Promise { + const context = await request.newContext(); + const options: { + method: string; + headers: { + Accept: string; + Authorization: string; + }; + data?: string | object; + } = { + method: method, + headers: { + Accept: "application/json", + Authorization: staticToken, + }, + }; + + if (body !== undefined) { + options.data = body; + } + + const response = await context.fetch(url, options); + 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 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()); + 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()); + 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()); + 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 body: unknown = await parseJsonResponse(response); + if (!isUserEntity(body)) { + throw new TypeError(`Invalid catalog user response for ${user}`); + } + return body; + } + + async deleteUserEntityFromAPI(user: string): Promise { + const r = await this.getCatalogUserFromAPI(user); + const uid = r.metadata?.uid; + if (uid === undefined || uid === "") { + return undefined; + } + 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 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}`); + } + return body; + } + + 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()); + return response.statusText(); + } + + 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(); + } +} diff --git a/e2e-tests/playwright/utils/authentication-providers/gitlab-helper.ts b/e2e-tests/playwright/utils/authentication-providers/gitlab-helper.ts index 33e377e784..0e6a10a697 100644 --- a/e2e-tests/playwright/utils/authentication-providers/gitlab-helper.ts +++ b/e2e-tests/playwright/utils/authentication-providers/gitlab-helper.ts @@ -43,10 +43,63 @@ 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 +117,14 @@ 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] 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; @@ -174,7 +191,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 +199,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.ts b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper/index.ts similarity index 64% rename from e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts rename to e2e-tests/playwright/utils/authentication-providers/msgraph-helper/index.ts index 6315d34379..8296025452 100644 --- a/e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts +++ b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper/index.ts @@ -1,17 +1,13 @@ // 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 { 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 { getErrorMessage, hasStatusCode } from "../errors"; +import { hasStatusCode } from "../../errors"; +import { allowPublicIpInNsg, getNetworkSecurityGroup, getNetworkSecurityGroupRule } from "./nsg"; interface AzureApplicationWeb { redirectUris?: string[]; @@ -57,15 +53,13 @@ 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) { + if (this.appClient === undefined) { const authProvider = new TokenCredentialAuthenticationProvider(this.clientSecretCredential, { scopes: ["https://graph.microsoft.com/.default"], }); @@ -77,26 +71,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 { @@ -409,31 +399,16 @@ 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 { @@ -459,31 +434,15 @@ 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 +454,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/msgraph-helper/nsg.ts b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper/nsg.ts new file mode 100644 index 0000000000..7bab0dbae4 --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper/nsg.ts @@ -0,0 +1,259 @@ +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/rhdh-deployment.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts deleted file mode 100644 index 2416cb501f..0000000000 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts +++ /dev/null @@ -1,1698 +0,0 @@ -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 { 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 = { - apiVersion: "", - kind: "", - metadata: { name: "" }, - spec: {}, - }; - private configReconcileBaselineGeneration: number | undefined; - - constructor( - namespace: string, - appConfigMap: string, - rbacConfigMap: string, - dynamicPluginsConfigMap: string, - secretName: string, - ) { - if (!process.env.ISRUNNINGLOCAL || process.env.ISRUNNINGLOCAL === "false") { - this.kc = new k8s.KubeConfig(); - this.kc.loadFromDefault(); - this.k8sApi = this.kc.makeApiClient(k8s.CoreV1Api); - this.appsV1Api = this.kc.makeApiClient(k8s.AppsV1Api); - } - this.namespace = namespace; - this.appConfigMap = appConfigMap; - this.rbacConfigMap = rbacConfigMap; - this.dynamicPluginsConfigMap = dynamicPluginsConfigMap; - this.secretName = secretName; - this.isRunningLocal = process.env.ISRUNNINGLOCAL === "true"; - } - - async addSecretData(key: string, value: string): Promise { - if (value.length === 0) { - throw new Error("Value cannot be empty"); - } - if (key.length === 0) { - throw new Error("Key cannot be empty"); - } - if (this.isRunningLocal) { - process.env[key] = value; - } - this.secretData[key] = Buffer.from(value).toString("base64"); - return this; - } - - async 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; - } - - 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; - } - } - - 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; - } - } - - setConfigProperty(config: Record, path: string, value: unknown): RHDHDeployment { - const parts = path.split("."); - let current: Record = config; - - for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i]; - if (part === undefined) { - throw new Error(`Invalid config path: ${path}`); - } - if (!(part in current)) { - current[part] = {}; - } - if (!isRecord(current[part])) { - current[part] = {}; - } - const next = current[part]; - if (!isRecord(next)) { - throw new Error(`Invalid config path: ${path}`); - } - current = next; - } - - const lastPart = parts.at(-1); - if (lastPart === undefined) { - throw new Error(`Invalid config path: ${path}`); - } - current[lastPart] = value; - - return this; - } - - getConfig>(config: T): T { - return config; - } - - setAppConfigProperty(path: string, value: unknown): RHDHDeployment { - return this.setConfigProperty(this.appConfig, path, value); - } - - getAppConfig(): YamlConfig { - return this.getConfig(this.appConfig); - } - - setDynamicPluginsConfigProperty(path: string, value: unknown): RHDHDeployment { - return this.setConfigProperty(this.dynamicPluginsConfig, path, value); - } - - getDynamicPluginsConfig(): DynamicPluginsConfig { - return this.dynamicPluginsConfig; - } - - 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; - } - - 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); - 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; - } - - 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); - 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); - return this; - } - - async deleteConfigMap(): Promise { - await this.k8sApi.deleteNamespacedConfigMap(this.appConfigMap, this.namespace); - 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); - 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); - 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); - 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; - } - - 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`); - 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 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 loadRbacConfig(): Promise { - const configPath = join(currentDirName, "yamls", "rbac-policy.csv"); - this.rbacConfig = await fs.readFile(configPath, "utf8"); // Load CSV content directly - 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, - }); - 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, - }); - return this; - } - - appendRbacLine(newLine: string): RHDHDeployment { - this.rbacConfig += `\n${newLine}`; - return this; - } - - replaceInRbacConfig(regex: RegExp, replacement: string): RHDHDeployment { - this.rbacConfig = this.rbacConfig.replace(regex, replacement); - return this; - } - - 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; - } - - 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), - }); - 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), - }); - 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`); - } - - 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; - } - } - - 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."); - } - } - - async followPodLogs( - searchString: RegExp, - podName?: string, - podLabels?: Record, - timeoutMs: number = 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 }, - ); - } - } - - 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; - } - - 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, - ); - } - - 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}`; - } - - async computeBackstageBackendUrl() { - if (this.isRunningLocal) { - return `http://localhost:7007`; - } - return this.computeBackstageUrl(); - } - - async loadAllConfigs(): Promise { - // Load base config if defined - if (this.appConfigMap) { - await this.loadBaseConfig(); - } - - // Load dynamic plugins config if defined - if (this.dynamicPluginsConfigMap) { - await this.loadDynamicPluginsConfig(); - } - - // Load RBAC config if defined - if (this.rbacConfigMap) { - await this.loadRbacConfig(); - } - - // Load Backstage CR - await this.loadBackstageCR(); - - return this; - } - - async checkBaseUrlReachable(): Promise { - const baseUrl = await this.computeBackstageUrl(); - try { - const response = await fetch(baseUrl, { method: "HEAD" }); - return response.status === 200; - } catch (error: unknown) { - console.log(`Error: ${getErrorMessage(error)}`); - return false; - } - } - - async expectBaseUrlReachable(): Promise { - const isReachable = await this.checkBaseUrlReachable(); - 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"}.`, - ); - } - 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; - } - - 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; - } - - 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; - } - - 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; - } - - 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"); - - return this; - } - - async createAllConfigs(): Promise { - await this.createAppConfig(); - await this.createDynamicPluginsConfig(); - await this.createRbacConfig(); - return this; - } - - async updateAllConfigs(): Promise { - if (!this.isRunningLocal) { - this.configReconcileBaselineGeneration = await this.getDeploymentGeneration(); - } - await this.updateAppConfig(); - await this.updateDynamicPluginsConfig(); - await this.updateRbacConfig(); - - return this; - } - - async restartLocalDeployment(): Promise { - if (this.isRunningLocal) { - console.log("Restarting local deployment..."); - await this.killRunningProcess(); - - await this.createBackstageDeployment(); - } - return this; - } - - async generateStaticToken(): Promise { - const token = uuidv4(); - await this.addSecretData("STATIC_TOKEN", token); - this.staticToken = token; - return this; - } - - getCurrentStaticToken(): string { - return this.staticToken; - } - - async setOIDCResolver( - resolver: string, - dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, - ): Promise { - this.setAppConfigProperty("auth.providers.oidc.production.signIn.resolvers", [ - { - resolver: resolver, - dangerouslyAllowSignInWithoutUserInCatalog: dangerouslyAllowSignInWithoutUserInCatalog, - }, - ]); - return this; - } - - async setMicrosoftResolver( - resolver: string, - dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, - ): Promise { - this.setAppConfigProperty("auth.providers.microsoft.production.signIn.resolvers", [ - { - resolver: resolver, - dangerouslyAllowSignInWithoutUserInCatalog: dangerouslyAllowSignInWithoutUserInCatalog, - }, - ]); - return this; - } - - async setGithubResolver( - resolver: string, - dangerouslyAllowSignInWithoutUserInCatalog: boolean = 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, - ); - - 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; - } - - async setGitlabResolver( - resolver: string, - dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, - ): Promise { - this.setAppConfigProperty("auth.providers.gitlab.production.signIn.resolvers", [ - { - resolver: resolver, - dangerouslyAllowSignInWithoutUserInCatalog: dangerouslyAllowSignInWithoutUserInCatalog, - }, - ]); - return this; - } - - async waitForSynced(): Promise { - const synced = await this.followLogs(syncedLogRegex, 120000); - expect(synced).toBe(true); - await sleep(2000); - return this; - } - - parseGroupMemberFromEntity(group: GroupEntity): string[] { - if (!group.relations) { - return []; - } - return group.relations - .filter((r) => r.type === "hasMember") - .map((r) => r.targetRef.split("/")[1]); - } - - parseGroupChildrenFromEntity(group: GroupEntity): string[] { - if (!group.relations) { - return []; - } - return group.relations - .filter((r) => r.type === "parentOf") - .map((r) => r.targetRef.split("/")[1]); - } - - 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)}`, - ); - 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)}`, - ); - 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}`, - ); - 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}`, - ); - return parents.includes(parent); - } - - async 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 actualValue === expectedValue; - } -} - -export default RHDHDeployment; 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..7a7a0e1d4b --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/auth.ts @@ -0,0 +1,372 @@ +import { expect } from "@playwright/test"; +import * as yaml from "yaml"; + +import { RHDHDeploymentState } from "./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..1670abdb94 --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/catalog.ts @@ -0,0 +1,151 @@ +import { GroupEntity } from "@backstage/catalog-model"; + +import { APIHelper } from "../../api-helper"; +import { + getCatalogGroups, + getCatalogUsers, + isGroupEntity, + isUserEntity, + RHDHDeploymentState, +} from "./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/index.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/index.ts new file mode 100644 index 0000000000..9cad3cb804 --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/index.ts @@ -0,0 +1,533 @@ +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, + enableLDAPLoginWithIngestion, + enableMicrosoftLoginWithIngestion, + enableOIDCLoginWithIngestion, + enablePingFederateOIDCLogin, + printDynamicPluginsConfig, + setDynamicPluginEnabled as setDynamicPluginEnabledImpl, + setGithubResolver as setGithubResolverImpl, + setGitlabResolver as setGitlabResolverImpl, + setMicrosoftResolver as setMicrosoftResolverImpl, + setOIDCResolver as setOIDCResolverImpl, +} from "./auth"; +import { + checkGroupIsChildOfGroup, + checkGroupIsIngestedInCatalog, + checkGroupIsParentOfGroup, + checkUserHasAnnotation, + checkUserIsIngestedInCatalog, + checkUserIsInGroup, + parseGroupChildrenFromEntity, + parseGroupMemberFromEntity, + parseGroupParentFromEntity, +} from "./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 "./k8s"; +import { + followLocalLogs as followLocalLogsImpl, + followLogs as followLogsImpl, + followPodLogs as followPodLogsImpl, + waitForSynced as waitForSyncedImpl, +} from "./logs"; +import { + BackstageCr, + DynamicPluginsConfig, + isRecord, + isRunningLocalMode, + RHDHDeploymentState, + shouldUseKubernetesClient, + YamlConfig, +} from "./types"; +import { + deleteNamespaceIfExists as deleteNamespaceIfExistsImpl, + getDeploymentGeneration as getDeploymentGenerationImpl, + waitForConfigReconciled as waitForConfigReconciledImpl, + waitForDeploymentReady as waitForDeploymentReadyImpl, + waitForNamespaceActive as waitForNamespaceActiveImpl, +} from "./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: {}, + }; + configReconcileBaselineGeneration: number | undefined; + + constructor( + namespace: string, + appConfigMap: string, + rbacConfigMap: string, + dynamicPluginsConfigMap: string, + secretName: string, + ) { + if (shouldUseKubernetesClient()) { + this.kc = new k8s.KubeConfig(); + this.kc.loadFromDefault(); + this.k8sApi = this.kc.makeApiClient(k8s.CoreV1Api); + this.appsV1Api = this.kc.makeApiClient(k8s.AppsV1Api); + } + this.namespace = namespace; + this.appConfigMap = appConfigMap; + this.rbacConfigMap = rbacConfigMap; + this.dynamicPluginsConfigMap = dynamicPluginsConfigMap; + this.secretName = secretName; + this.isRunningLocal = isRunningLocalMode(); + } + + addSecretData(key: string, value: string): Promise { + if (value.length === 0) { + throw new Error("Value cannot be empty"); + } + if (key.length === 0) { + throw new Error("Key cannot be empty"); + } + if (this.isRunningLocal) { + process.env[key] = value; + } + this.secretData[key] = Buffer.from(value).toString("base64"); + return Promise.resolve(this); + } + + 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 Promise.resolve(this); + } + + async createNamespace(): Promise { + await createNamespaceImpl(this); + return this; + } + + async deleteNamespaceIfExists(timeoutMs = 60000): Promise { + await deleteNamespaceIfExistsImpl(this, timeoutMs); + return this; + } + + setConfigProperty(config: Record, path: string, value: unknown): RHDHDeployment { + const parts = path.split("."); + let current: Record = config; + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (part === undefined) { + throw new Error(`Invalid config path: ${path}`); + } + if (!(part in current)) { + current[part] = {}; + } + if (!isRecord(current[part])) { + current[part] = {}; + } + const next = current[part]; + if (!isRecord(next)) { + throw new Error(`Invalid config path: ${path}`); + } + current = next; + } + + const lastPart = parts.at(-1); + if (lastPart === undefined) { + throw new Error(`Invalid config path: ${path}`); + } + current[lastPart] = value; + return this; + } + + getConfig>(config: T): T { + return config; + } + + setAppConfigProperty(path: string, value: unknown): RHDHDeployment { + return this.setConfigProperty(this.appConfig, path, value); + } + + getAppConfig(): YamlConfig { + return this.getConfig(this.appConfig); + } + + setDynamicPluginsConfigProperty(path: string, value: unknown): RHDHDeployment { + return this.setConfigProperty(this.dynamicPluginsConfig, path, value); + } + + getDynamicPluginsConfig(): DynamicPluginsConfig { + return this.dynamicPluginsConfig; + } + + async loadBaseConfig(): Promise { + await loadBaseConfigImpl(this); + return this; + } + + async applyCustomResource(resource: BackstageCr): Promise { + await applyCustomResource(this, resource); + return this; + } + + readYamlToJson(filePath: string): Promise { + return readYamlToJson(filePath); + } + + async createAppConfig(): Promise { + await createAppConfigImpl(this); + return this; + } + + async updateAppConfig(): Promise { + await updateAppConfigImpl(this); + return this; + } + + async deleteConfigMap(): Promise { + await deleteConfigMapImpl(this); + return this; + } + + async createSecret(): Promise { + await createSecretImpl(this); + return this; + } + + async updateSecret(): Promise { + await updateSecretImpl(this); + return this; + } + + async deleteSecret(): Promise { + await deleteSecretImpl(this); + return this; + } + + getDeploymentGeneration(): Promise { + return getDeploymentGenerationImpl(this); + } + + async waitForConfigReconciled(timeoutMs = 60000): Promise { + await waitForConfigReconciledImpl(this, timeoutMs); + return this; + } + + async waitForDeploymentReady(timeoutMs = 600000): Promise { + await waitForDeploymentReadyImpl(this, timeoutMs); + return this; + } + + async waitForNamespaceActive(timeoutMs = 30000): Promise { + await waitForNamespaceActiveImpl(this, timeoutMs); + return this; + } + + async loadRbacConfig(): Promise { + await loadRbacConfigImpl(this); + return this; + } + + async createRbacConfig(): Promise { + await createRbacConfigImpl(this); + return this; + } + + async updateRbacConfig(): Promise { + await updateRbacConfigImpl(this); + return this; + } + + appendRbacLine(newLine: string): RHDHDeployment { + this.rbacConfig += `\n${newLine}`; + return this; + } + + replaceInRbacConfig(regex: RegExp, replacement: string): RHDHDeployment { + this.rbacConfig = this.rbacConfig.replace(regex, replacement); + return this; + } + + async loadDynamicPluginsConfig(): Promise { + await loadDynamicPluginsConfigImpl(this); + return this; + } + + async createDynamicPluginsConfig(): Promise { + await createDynamicPluginsConfigImpl( + this, + (path, value) => { + this.setAppConfigProperty(path, value); + }, + updateAppConfigImpl, + ); + return this; + } + + async updateDynamicPluginsConfig(): Promise { + await updateDynamicPluginsConfigImpl(this); + return this; + } + + loadBackstageCR(): Promise { + return loadBackstageCRImpl(this); + } + + async createBackstageDeployment(): Promise { + await createBackstageDeploymentImpl(this); + return this; + } + + async killRunningProcess(): Promise { + await killRunningProcessImpl(this, () => this.computeBackstageUrl()); + } + + followPodLogs( + searchString: RegExp, + podName?: string, + podLabels?: Record, + timeoutMs = 300000, + ): Promise { + return followPodLogsImpl(this, searchString, podName, podLabels, timeoutMs); + } + + followLocalLogs(searchString: RegExp, timeoutMs = 30000): Promise { + return followLocalLogsImpl(this, searchString, timeoutMs); + } + + followLogs(searchString: RegExp, timeoutMs = 300000): Promise { + return followLogsImpl(this, searchString, timeoutMs); + } + + computeBackstageUrl(): Promise { + return Promise.resolve(computeBackstageUrlImpl(this)); + } + + computeBackstageBackendUrl(): Promise { + return Promise.resolve(computeBackstageBackendUrlImpl(this)); + } + + async loadAllConfigs(): Promise { + if (this.appConfigMap !== "") { + await this.loadBaseConfig(); + } + if (this.dynamicPluginsConfigMap !== "") { + await this.loadDynamicPluginsConfig(); + } + if (this.rbacConfigMap !== "") { + await this.loadRbacConfig(); + } + await this.loadBackstageCR(); + return this; + } + + async checkBaseUrlReachable(): Promise { + const baseUrl = await this.computeBackstageUrl(); + try { + 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; + } + } + + async expectBaseUrlReachable(): Promise { + const isReachable = await this.checkBaseUrlReachable(); + expect(isReachable).toBe(true); + } + + setDynamicPluginEnabled(pluginName: string, enabled: boolean): RHDHDeployment { + setDynamicPluginEnabledImpl(this, pluginName, enabled); + return this; + } + + printDynamicPluginsConfig(): void { + printDynamicPluginsConfig(this); + } + + enableOIDCLoginWithIngestion(): Promise { + enableOIDCLoginWithIngestion(this); + return Promise.resolve(this); + } + + enablePingFederateOIDCLogin(): Promise { + enablePingFederateOIDCLogin(this); + return Promise.resolve(this); + } + + enableLDAPLoginWithIngestion(): Promise { + enableLDAPLoginWithIngestion(this); + return Promise.resolve(this); + } + + enableMicrosoftLoginWithIngestion(): Promise { + enableMicrosoftLoginWithIngestion(this); + return Promise.resolve(this); + } + + enableGithubLoginWithIngestion(): Promise { + enableGithubLoginWithIngestion(this, this.isRunningLocal); + return Promise.resolve(this); + } + + async createAllConfigs(): Promise { + await this.createAppConfig(); + await this.createDynamicPluginsConfig(); + await this.createRbacConfig(); + return this; + } + + async updateAllConfigs(): Promise { + if (!this.isRunningLocal) { + this.configReconcileBaselineGeneration = await this.getDeploymentGeneration(); + } + await this.updateAppConfig(); + await this.updateDynamicPluginsConfig(); + await this.updateRbacConfig(); + return this; + } + + async restartLocalDeployment(): Promise { + if (this.isRunningLocal) { + console.log("Restarting local deployment..."); + await this.killRunningProcess(); + await this.createBackstageDeployment(); + } + return this; + } + + generateStaticToken(): Promise { + const token = uuidv4(); + this.staticToken = token; + return this.addSecretData("STATIC_TOKEN", token); + } + + getCurrentStaticToken(): string { + return this.staticToken; + } + + setOIDCResolver( + resolver: string, + dangerouslyAllowSignInWithoutUserInCatalog = false, + ): Promise { + setOIDCResolverImpl(this, resolver, dangerouslyAllowSignInWithoutUserInCatalog); + return Promise.resolve(this); + } + + setMicrosoftResolver( + resolver: string, + dangerouslyAllowSignInWithoutUserInCatalog = false, + ): Promise { + setMicrosoftResolverImpl(this, resolver, dangerouslyAllowSignInWithoutUserInCatalog); + return Promise.resolve(this); + } + + setGithubResolver( + resolver: string, + dangerouslyAllowSignInWithoutUserInCatalog = false, + ): Promise { + setGithubResolverImpl(this, resolver, dangerouslyAllowSignInWithoutUserInCatalog); + return Promise.resolve(this); + } + + enableGitlabLoginWithIngestion(): Promise { + enableGitlabLoginWithIngestion(this); + return Promise.resolve(this); + } + + setGitlabResolver( + resolver: string, + dangerouslyAllowSignInWithoutUserInCatalog = false, + ): Promise { + setGitlabResolverImpl(this, resolver, dangerouslyAllowSignInWithoutUserInCatalog); + return Promise.resolve(this); + } + + async waitForSynced(): Promise { + await waitForSyncedImpl(this); + return this; + } + + parseGroupMemberFromEntity = parseGroupMemberFromEntity; + parseGroupChildrenFromEntity = parseGroupChildrenFromEntity; + parseGroupParentFromEntity = parseGroupParentFromEntity; + + checkUserIsIngestedInCatalog(users: string[]): Promise { + return checkUserIsIngestedInCatalog(this, users, () => this.computeBackstageBackendUrl()); + } + + checkGroupIsIngestedInCatalog(groups: string[]): Promise { + return checkGroupIsIngestedInCatalog(this, groups, () => this.computeBackstageBackendUrl()); + } + + checkUserIsInGroup(user: string, group: string): Promise { + return checkUserIsInGroup(this, user, group, () => this.computeBackstageBackendUrl()); + } + + checkGroupIsParentOfGroup(parent: string, child: string): Promise { + return checkGroupIsParentOfGroup(this, parent, child, () => this.computeBackstageBackendUrl()); + } + + checkGroupIsChildOfGroup(child: string, parent: string): Promise { + return checkGroupIsChildOfGroup(this, child, parent, () => this.computeBackstageBackendUrl()); + } + + checkUserHasAnnotation( + user: string, + annotationKey: string, + expectedValue: string, + ): Promise { + return checkUserHasAnnotation(this, user, annotationKey, expectedValue, () => + this.computeBackstageBackendUrl(), + ); + } +} + +export default RHDHDeployment; 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..238ecea354 --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/k8s.ts @@ -0,0 +1,404 @@ +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 * as yaml from "yaml"; + +import { hasErrorResponse } from "../../errors"; +import { + BackstageCr, + currentDirName, + isBackstageCr, + isDynamicPluginsConfig, + isRecord, + RHDHDeploymentState, + rootDirName, +} from "./types"; +import { ensureBackstageCRIsAvailable, waitForDeploymentReady } from "./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..fbb025785d --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/logs.ts @@ -0,0 +1,205 @@ +import stream from "stream"; + +import * as k8s from "@kubernetes/client-node"; + +import { getErrorMessage, hasErrorResponse } from "../../errors"; +import { RHDHDeploymentState, sleep, syncedLogRegex } from "./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..90e8275a43 --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/types.ts @@ -0,0 +1,121 @@ +import { ChildProcess } from "child_process"; +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 { + 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..def11e0955 --- /dev/null +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/wait.ts @@ -0,0 +1,349 @@ +import * as k8s from "@kubernetes/client-node"; + +import { getErrorMessage, hasErrorResponse } from "../../errors"; +import { BackstageCr, RHDHDeploymentState, sleep } from "./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/common.ts b/e2e-tests/playwright/utils/common.ts deleted file mode 100644 index c987738437..0000000000 --- a/e2e-tests/playwright/utils/common.ts +++ /dev/null @@ -1,645 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; - -import { test, Browser, Cookie, expect, Page, TestInfo, Locator } from "@playwright/test"; -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 { SETTINGS_PAGE_COMPONENTS } from "../support/page-objects/page-obj"; -import { getErrorMessage } from "./errors"; -import { UIhelper } from "./ui-helper"; - -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; -} - -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); - // TODO - Remove it after https://issues.redhat.com/browse/RHIDP-2043. A Dynamic plugin for Guest Authentication Provider needs to be created - this.page.on("dialog", async (dialog) => { - console.log(`Dialog message: ${dialog.message()}`); - await dialog.accept(); - }); - - await this.uiHelper.verifyHeading(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 item of Object.values(WAIT_OBJECTS)) { - await this.page.waitForSelector(item, { - 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) { - 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); - // 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; - } - } - 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`; - - // 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")); - 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 { - // 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.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) { - secrets[ghUserId] = process.env.GH_2FA_SECRET; - } - if (ghUser2Id) { - secrets[ghUser2Id] = process.env.GH_USER2_2FA_SECRET; - } - - const secret = secrets[userid]; - if (!secret) { - throw new Error("Invalid User ID"); - } - - return authenticator.generate(secret); - } - - getGoogle2FAOTP(): string { - const secret = process.env.GOOGLE_2FA_SECRET; - if (!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"]), - ]); - - // 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 */ - } - - 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 this.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 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 */ - } - - 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 this.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"]), - ]); - - // 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 */ - } - - 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"]), - ]); - - // 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 */ - } -} - -// 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/common/auth-popup.ts b/e2e-tests/playwright/utils/common/auth-popup.ts new file mode 100644 index 0000000000..1468312cd4 --- /dev/null +++ b/e2e-tests/playwright/utils/common/auth-popup.ts @@ -0,0 +1,259 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import { authenticator } from "otplib"; + +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..42b59dc978 --- /dev/null +++ b/e2e-tests/playwright/utils/common/browser.ts @@ -0,0 +1,53 @@ +import * as path from "path"; + +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); + 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/index.ts b/e2e-tests/playwright/utils/common/index.ts new file mode 100644 index 0000000000..be52055712 --- /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 { SETTINGS_PAGE_COMPONENTS } from "../../support/page-objects/page-obj"; +import { getErrorMessage } from "../errors"; +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/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..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"; @@ -29,7 +28,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.ts b/e2e-tests/playwright/utils/kube-client.ts deleted file mode 100644 index adbed047e3..0000000000 --- a/e2e-tests/playwright/utils/kube-client.ts +++ /dev/null @@ -1,1291 +0,0 @@ -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`; -} - -export class KubeClient { - coreV1Api: k8s.CoreV1Api; - appsApi: k8s.AppsV1Api; - customObjectsApi: k8s.CustomObjectsApi; - kc: k8s.KubeConfig; - - constructor() { - try { - this.kc = new k8s.KubeConfig(); - this.kc.loadFromOptions({ - clusters: [ - { - name: "my-openshift-cluster", - server: process.env.K8S_CLUSTER_URL ?? "", - skipTLSVerify: true, - }, - ], - users: [ - { - name: "ci-user", - token: process.env.K8S_CLUSTER_TOKEN ?? "", - }, - ], - contexts: [ - { - name: "default-context", - user: "ci-user", - cluster: "my-openshift-cluster", - }, - ], - currentContext: "default-context", - }); - - this.appsApi = this.kc.makeApiClient(k8s.AppsV1Api); - this.coreV1Api = this.kc.makeApiClient(k8s.CoreV1Api); - this.customObjectsApi = this.kc.makeApiClient(k8s.CustomObjectsApi); - } catch (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), - ); - throw e; - } - } - - async listConfigMaps(namespace: string) { - try { - 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), - ); - 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; - } - } - - async getNamespaceByName(name: string): Promise { - try { - return (await this.coreV1Api.readNamespace(name)).body; - } catch (e) { - console.log(`Error getting namespace ${name}: ${getKubeApiErrorMessage(e)}`); - throw e; - } - } - - async 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; - } - } - } - } - - async getSecret(secretName: string, namespace: string) { - try { - 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), - ); - throw e; - } - } - - async updateConfigMap(configmapName: string, namespace: string, patch: object) { - try { - console.log("updateConfigMap called"); - console.log("Namespace: ", namespace); - console.log("ConfigMap: ", configmapName); - const options = { - headers: { "Content-type": k8s.PatchUtils.PATCH_FORMAT_JSON_PATCH }, - }; - console.log(`Updating configmap ${configmapName} in namespace ${namespace}`); - await this.coreV1Api.patchNamespacedConfigMap( - configmapName, - namespace, - patch, - undefined, - undefined, - undefined, - undefined, - undefined, - options, - ); - } catch (e) { - console.log(`Error updating configmap: ${getKubeApiErrorMessage(e)}`); - throw e; - } - } - - 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, - }); - } - } - - async updateSecret(secretName: string, namespace: string, patch: object) { - try { - const options = { - headers: { - "Content-type": k8s.PatchUtils.PATCH_FORMAT_JSON_MERGE_PATCH, - }, - }; - console.log(`Updating secret ${secretName} in namespace ${namespace}`); - await this.coreV1Api.patchNamespacedSecret( - secretName, - namespace, - patch, - undefined, - undefined, - undefined, - undefined, - undefined, - options, - ); - } catch (e) { - console.log(getKubeApiErrorMessage(e)); - throw e; - } - } - - async createCongifmap(namespace: string, body: V1ConfigMap) { - try { - const configMapName = body.metadata?.name; - if (!configMapName) { - throw new Error("ConfigMap metadata.name is required"); - } - console.log(`Creating configmap ${configMapName} in namespace ${namespace}`); - return await this.coreV1Api.createNamespacedConfigMap(namespace, body); - } catch (err) { - console.log(getKubeApiErrorMessage(err)); - throw err; - } - } - - async deleteNamespaceAndWait(namespace: string) { - const watch = new k8s.Watch(this.kc); - try { - await this.coreV1Api.deleteNamespace(namespace); - console.log(`Namespace '${namespace}' deletion initiated.`); - - await new Promise((resolve, reject) => { - void watch.watch( - `/api/v1/namespaces?watch=true&fieldSelector=metadata.name=${namespace}`, - {}, - (type) => { - if (type === "DELETED") { - console.log(`Namespace '${namespace}' has been deleted.`); - resolve(); - } - }, - (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); - } - }, - ); - }); - } catch (err) { - console.log( - `Error deleting or waiting for namespace deletion: ${getKubeApiErrorMessage(err)}`, - ); - throw err; - } - } - - async createNamespaceIfNotExists(namespace: string) { - const nsList = await this.coreV1Api.listNamespace(); - const ns = nsList.body.items - .map((item) => item.metadata?.name) - .filter((name): name is string => name !== undefined); - if (ns.includes(namespace)) { - console.log(`Delete and re-create namespace ${namespace}`); - try { - await this.deleteNamespaceAndWait(namespace); - } catch (err) { - console.log(`Error deleting namespace ${namespace}: ${getKubeApiErrorMessage(err)}`); - throw err; - } - } - - try { - const createNamespaceRes = await this.coreV1Api.createNamespace({ - metadata: { - name: namespace, - }, - }); - const createdName = createNamespaceRes.body.metadata?.name; - console.log(`Created namespace ${createdName ?? namespace}`); - } catch (err) { - console.log(getKubeApiErrorMessage(err)); - throw err; - } - } - - async createSecret(secret: k8s.V1Secret, namespace: string) { - try { - console.log( - `Creating secret ${secret.metadata?.name ?? "unknown"} in namespace ${namespace}`, - ); - await this.coreV1Api.createNamespacedSecret(namespace, secret); - } catch (err) { - console.log(getKubeApiErrorMessage(err)); - throw err; - } - } - - /** - * 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 { - const secretName = secret.metadata?.name; - if (!secretName) { - throw new Error("Secret metadata.name is required"); - } - - try { - 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}`); - await this.createSecret(secret, namespace); - console.log(`Secret ${secretName} created in namespace ${namespace}`); - } else { - throw err; - } - } - } - - /** - * Check if pods are in a failure state (CrashLoopBackOff, ImagePullBackOff, etc.) - * Returns a failure reason if found, null otherwise - */ - async 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 - } - } - - async waitForDeploymentReady( - deploymentName: string, - namespace: string, - expectedReplicas: number, - timeout: number = 300000, // 5 minutes - checkInterval: number = 10000, // 10 seconds - labelSelector?: string, // Optional label selector for pods - ) { - 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).`, - ); - } - - 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 }, - ); - } - } - - /** - * Resolves the pod label selector from a deployment's spec.selector.matchLabels. - */ - private async 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(","); - } - - /** - * 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)}`, - ); - } - } - - 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)}`, - ); - } - } - - 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)}`); - } - } - - 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)}`, - ); - } - } - - 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)}`, - ); - } - } - - 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)}`, - ); - } - } - - async getServiceByLabel(namespace: string, labelSelector: string): Promise { - try { - const response = await this.coreV1Api.listNamespacedService( - namespace, - undefined, - undefined, - undefined, - undefined, - labelSelector, - ); - return response.body.items; - } catch (error) { - console.error( - `Error fetching services with label ${labelSelector}: ${getKubeApiErrorMessage(error)}`, - ); - throw error; - } - } - - async execPodCommand( - podName: string, - namespace: string, - containerName: string, - command: string[], - timeout: number = 60000, // 1 minute - ): 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 }, - ); - } - } -} 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..078e71395a --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client/configmap.ts @@ -0,0 +1,168 @@ +import * as k8s from "@kubernetes/client-node"; +import * as yaml from "js-yaml"; + +import { hasErrorResponse } from "../errors"; +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")); +} + +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..d7ad531ae6 --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client/deployment/restart.ts @@ -0,0 +1,75 @@ +import { getKubeApiErrorMessage, sleep } from "../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..3a76f369ae --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client/deployment/scale.ts @@ -0,0 +1,89 @@ +import * as k8s from "@kubernetes/client-node"; + +import { getErrorStatusCode, getKubeApiErrorMessage, sleep } from "../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..8a98e17abc --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client/deployment/wait.ts @@ -0,0 +1,174 @@ +import * as k8s from "@kubernetes/client-node"; + +import { getKubeApiErrorMessage, PodFailureResult, sleep } from "../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..76b050d500 --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client/diagnostics/events.ts @@ -0,0 +1,183 @@ +import * as k8s from "@kubernetes/client-node"; + +import { + DEFAULT_BACKSTAGE_LABEL_SELECTOR, + formatEventTimestamp, + getEventSortTimestamp, + getKubeApiErrorMessage, + podNameOrUnknown, +} from "../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..8b18d8cbff --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client/diagnostics/pods.ts @@ -0,0 +1,205 @@ +import * as k8s from "@kubernetes/client-node"; + +import { + DEFAULT_BACKSTAGE_LABEL_SELECTOR, + formatContainerStartedAt, + getKubeApiErrorMessage, + podNameOrUnknown, +} from "../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..42eb76b83d --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client/diagnostics/replicasets.ts @@ -0,0 +1,106 @@ +import * as k8s from "@kubernetes/client-node"; + +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 + 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..e46892f44d --- /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 "./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..b201471e71 --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client/helpers.ts @@ -0,0 +1,158 @@ +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/index.ts b/e2e-tests/playwright/utils/kube-client/index.ts new file mode 100644 index 0000000000..0c8ae85e22 --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client/index.ts @@ -0,0 +1,408 @@ +import * as k8s from "@kubernetes/client-node"; +import { V1ConfigMap } from "@kubernetes/client-node"; + +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 "./diagnostics/pods"; +import { logReplicaSetStatusImpl } from "./diagnostics/replicasets"; +import { execPodCommandImpl } from "./exec"; +import { + formatKubeErrorLog, + getErrorStatusCode, + getKubeApiErrorMessage, + getRhdhDeploymentName, + PodFailureResult, + rejectAsError, +} from "./helpers"; +import { checkPodFailureStatesImpl } from "./pod-failure"; + +export { getRhdhDeploymentName }; +export type { PodFailureResult }; + +export class KubeClient { + coreV1Api: k8s.CoreV1Api; + appsApi: k8s.AppsV1Api; + customObjectsApi: k8s.CustomObjectsApi; + kc: k8s.KubeConfig; + + constructor() { + try { + this.kc = new k8s.KubeConfig(); + this.kc.loadFromOptions({ + clusters: [ + { + name: "my-openshift-cluster", + server: process.env.K8S_CLUSTER_URL ?? "", + skipTLSVerify: true, + }, + ], + users: [ + { + name: "ci-user", + token: process.env.K8S_CLUSTER_TOKEN ?? "", + }, + ], + contexts: [ + { + name: "default-context", + user: "ci-user", + cluster: "my-openshift-cluster", + }, + ], + currentContext: "default-context", + }); + + this.appsApi = this.kc.makeApiClient(k8s.AppsV1Api); + this.coreV1Api = this.kc.makeApiClient(k8s.CoreV1Api); + this.customObjectsApi = this.kc.makeApiClient(k8s.CustomObjectsApi); + } catch (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(formatKubeErrorLog(e)); + throw e; + } + } + + async listConfigMaps(namespace: string) { + try { + console.log(`Listing configmaps in namespace ${namespace}`); + return await this.coreV1Api.listNamespacedConfigMap(namespace); + } catch (e) { + console.error(formatKubeErrorLog(e)); + throw e; + } + } + + 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)}`); + throw e; + } + } + + scaleDeployment( + deploymentName: string, + namespace: string, + replicas: number, + maxRetries: number = 3, + ) { + return scaleDeploymentImpl(this.appsApi, deploymentName, namespace, replicas, maxRetries); + } + + async getSecret(secretName: string, namespace: string) { + try { + console.log(`Getting secret ${secretName} from namespace ${namespace}`); + return await this.coreV1Api.readNamespacedSecret(secretName, namespace); + } catch (e) { + console.log(formatKubeErrorLog(e)); + throw e; + } + } + + async updateConfigMap(configmapName: string, namespace: string, patch: object) { + try { + console.log("updateConfigMap called"); + console.log("Namespace: ", namespace); + console.log("ConfigMap: ", configmapName); + const options = { + headers: { "Content-type": k8s.PatchUtils.PATCH_FORMAT_JSON_PATCH }, + }; + console.log(`Updating configmap ${configmapName} in namespace ${namespace}`); + await this.coreV1Api.patchNamespacedConfigMap( + configmapName, + namespace, + patch, + undefined, + undefined, + undefined, + undefined, + undefined, + options, + ); + } catch (e) { + console.log(`Error updating configmap: ${getKubeApiErrorMessage(e)}`); + throw e; + } + } + + 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) { + try { + const options = { + headers: { + "Content-type": k8s.PatchUtils.PATCH_FORMAT_JSON_MERGE_PATCH, + }, + }; + console.log(`Updating secret ${secretName} in namespace ${namespace}`); + await this.coreV1Api.patchNamespacedSecret( + secretName, + namespace, + patch, + undefined, + undefined, + undefined, + undefined, + undefined, + options, + ); + } catch (e) { + console.log(getKubeApiErrorMessage(e)); + throw e; + } + } + + async createCongifmap(namespace: string, body: V1ConfigMap) { + try { + const configMapName = body.metadata?.name; + if (configMapName === undefined || configMapName === "") { + throw new Error("ConfigMap metadata.name is required"); + } + console.log(`Creating configmap ${configMapName} in namespace ${namespace}`); + return await this.coreV1Api.createNamespacedConfigMap(namespace, body); + } catch (err) { + console.log(getKubeApiErrorMessage(err)); + throw err; + } + } + + async deleteNamespaceAndWait(namespace: string) { + const watch = new k8s.Watch(this.kc); + try { + await this.coreV1Api.deleteNamespace(namespace); + console.log(`Namespace '${namespace}' deletion initiated.`); + + await new Promise((resolve, reject) => { + void watch.watch( + `/api/v1/namespaces?watch=true&fieldSelector=metadata.name=${namespace}`, + {}, + (type) => { + if (type === "DELETED") { + console.log(`Namespace '${namespace}' has been deleted.`); + resolve(); + } + }, + (err: unknown) => { + if (hasStatusCode(err) && err.statusCode === 404) { + console.log(`Namespace '${namespace}' is already deleted.`); + resolve(); + } else { + rejectAsError(reject, err); + } + }, + ); + }); + } catch (err) { + console.log( + `Error deleting or waiting for namespace deletion: ${getKubeApiErrorMessage(err)}`, + ); + throw err; + } + } + + async createNamespaceIfNotExists(namespace: string) { + const nsList = await this.coreV1Api.listNamespace(); + const ns = nsList.body.items + .map((item) => item.metadata?.name) + .filter((name): name is string => name !== undefined); + if (ns.includes(namespace)) { + console.log(`Delete and re-create namespace ${namespace}`); + try { + await this.deleteNamespaceAndWait(namespace); + } catch (err) { + console.log(`Error deleting namespace ${namespace}: ${getKubeApiErrorMessage(err)}`); + throw err; + } + } + + try { + const createNamespaceRes = await this.coreV1Api.createNamespace({ + metadata: { + name: namespace, + }, + }); + const createdName = createNamespaceRes.body.metadata?.name; + console.log(`Created namespace ${createdName ?? namespace}`); + } catch (err) { + console.log(getKubeApiErrorMessage(err)); + throw err; + } + } + + async createSecret(secret: k8s.V1Secret, namespace: string) { + try { + console.log( + `Creating secret ${secret.metadata?.name ?? "unknown"} in namespace ${namespace}`, + ); + await this.coreV1Api.createNamespacedSecret(namespace, secret); + } catch (err) { + console.log(getKubeApiErrorMessage(err)); + throw err; + } + } + + 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 body = existing.body; + 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}`); + await this.createSecret(secret, namespace); + console.log(`Secret ${secretName} created in namespace ${namespace}`); + } else { + throw err; + } + } + } + + checkPodFailureStates( + namespace: string, + labelSelector: string, + ): Promise { + return checkPodFailureStatesImpl(this.coreV1Api, namespace, labelSelector); + } + + waitForDeploymentReady( + deploymentName: string, + namespace: string, + expectedReplicas: number, + timeout: number = 300000, + checkInterval: number = 10000, + labelSelector?: string, + ) { + 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, + ); + } + + 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, + ); + } + + private getDeploymentPodSelector(deploymentName: string, namespace: string): Promise { + return getDeploymentPodSelectorImpl(this.appsApi, deploymentName, namespace); + } + + logPodConditionsForDeployment(deploymentName: string, namespace: string) { + return logPodConditionsForDeploymentImpl( + (ns, selector) => this.logPodConditions(ns, selector), + (name, ns) => this.getDeploymentPodSelector(name, ns), + deploymentName, + namespace, + ); + } + + logPodConditions(namespace: string, labelSelector: string) { + return logPodConditionsImpl(this.coreV1Api, namespace, labelSelector); + } + + logPodContainerLogs(namespace: string, labelSelector?: string, containerName?: string) { + return logPodContainerLogsImpl(this.coreV1Api, namespace, labelSelector, containerName); + } + + logPodEvents(namespace: string, labelSelector?: string) { + return logPodEventsImpl(this.coreV1Api, namespace, labelSelector); + } + + logDeploymentEvents(deploymentName: string, namespace: string) { + return logDeploymentEventsImpl(this.coreV1Api, deploymentName, namespace); + } + + logReplicaSetStatus(deploymentName: string, namespace: string) { + return logReplicaSetStatusImpl(this.coreV1Api, this.appsApi, deploymentName, namespace); + } + + async getServiceByLabel(namespace: string, labelSelector: string): Promise { + try { + const response = await this.coreV1Api.listNamespacedService( + namespace, + undefined, + undefined, + undefined, + undefined, + labelSelector, + ); + return response.body.items; + } catch (error) { + console.error( + `Error fetching services with label ${labelSelector}: ${getKubeApiErrorMessage(error)}`, + ); + throw error; + } + } + + execPodCommand( + podName: string, + namespace: string, + containerName: string, + command: string[], + timeout: number = 60000, + ): Promise<{ stdout: string; stderr: string }> { + return execPodCommandImpl(this.kc, podName, namespace, containerName, command, timeout); + } +} 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..e62146a941 --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client/pod-failure.ts @@ -0,0 +1,203 @@ +import * as k8s from "@kubernetes/client-node"; + +import { getKubeApiErrorMessage, PodFailureResult, podNameOrUnknown } from "./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/postgres-config.ts b/e2e-tests/playwright/utils/postgres-config.ts index eb5b8d8fbf..61fececc4d 100644 --- a/e2e-tests/playwright/utils/postgres-config.ts +++ b/e2e-tests/playwright/utils/postgres-config.ts @@ -21,7 +21,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"); } /** @@ -30,7 +30,7 @@ function unescapeNewlines(value: string): string { * @returns Certificate content with escaped newlines converted, or null if file doesn't exist */ export function readCertificateFile(filePath: string | undefined): string | null { - if (!filePath) { + if (filePath === undefined || filePath === "") { return null; } if (!existsSync(filePath)) { @@ -76,18 +76,18 @@ 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"), + 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) { + 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 +100,87 @@ 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 +191,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 +205,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,50 +220,7 @@ 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; - } - } - } - - if (success) { - succeeded.push(db); - } else { - failed.push(db); - } - } + const { succeeded, failed } = await dropUserDatabases(client, databases); console.log(`Database cleanup completed: ${succeeded.length} dropped, ${failed.length} failed`); if (succeeded.length > 0) { diff --git a/e2e-tests/playwright/utils/ui-helper.ts b/e2e-tests/playwright/utils/ui-helper.ts deleted file mode 100644 index 972d421da0..0000000000 --- a/e2e-tests/playwright/utils/ui-helper.ts +++ /dev/null @@ -1,836 +0,0 @@ -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(); - } -} 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..cac7e08c57 --- /dev/null +++ b/e2e-tests/playwright/utils/ui-helper/class.ts @@ -0,0 +1,310 @@ +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/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"; 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..637c847bb9 --- /dev/null +++ b/e2e-tests/playwright/utils/ui-helper/interaction.ts @@ -0,0 +1,166 @@ +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..abd9a1fbd3 --- /dev/null +++ b/e2e-tests/playwright/utils/ui-helper/misc.ts @@ -0,0 +1,167 @@ +import { expect, Page } from "@playwright/test"; + +import { getCurrentLanguage } from "../../e2e/localization/locale"; +import { getCardByHeading } from "../../support/page-objects/ui-locators"; +import { clickButtonByLabel, clickByDataTestId, clickLink } from "./interaction"; +import { openSidebar, selectMuiBox } from "./navigation"; +import { verifyCellsInTable } from "./table"; +import { verifyAlertErrorMessage, verifyHeading, verifyRowsInTable } from "./verification"; + +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..27690adad0 --- /dev/null +++ b/e2e-tests/playwright/utils/ui-helper/navigation.ts @@ -0,0 +1,119 @@ +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..d9f2f0cb58 --- /dev/null +++ b/e2e-tests/playwright/utils/ui-helper/table.ts @@ -0,0 +1,125 @@ +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..ffb25312e2 --- /dev/null +++ b/e2e-tests/playwright/utils/ui-helper/verification.ts @@ -0,0 +1,192 @@ +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..8aac3d8199 --- /dev/null +++ b/e2e-tests/playwright/utils/ui-helper/visibility.ts @@ -0,0 +1,35 @@ +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); +}