From cb8b279ac965e28fa478d93c83f000a768804a66 Mon Sep 17 00:00:00 2001 From: Subhash Khileri Date: Thu, 11 Dec 2025 18:08:50 +0530 Subject: [PATCH 1/2] Add helpers and pages and objects --- README.md | 278 ++++++++++- package.json | 10 + src/deployment/rhdh/deployment.ts | 11 +- src/eslint/base.config.ts | 10 + src/playwright/fixtures/test.ts | 15 + src/playwright/helpers/api-endpoints.ts | 31 ++ src/playwright/helpers/api-helper.ts | 451 +++++++++++++++++ src/playwright/helpers/common.ts | 398 +++++++++++++++ src/playwright/helpers/index.ts | 4 + src/playwright/helpers/navbar.ts | 14 + src/playwright/helpers/ui-helper.ts | 580 ++++++++++++++++++++++ src/playwright/page-objects/global-obj.ts | 28 ++ src/playwright/page-objects/page-obj.ts | 48 ++ src/playwright/pages/catalog-import.ts | 76 +++ src/playwright/pages/catalog.ts | 48 ++ src/playwright/pages/extensions.ts | 171 +++++++ src/playwright/pages/home-page.ts | 67 +++ src/playwright/pages/index.ts | 5 + src/playwright/pages/notifications.ts | 154 ++++++ yarn.lock | 153 ++++++ 20 files changed, 2539 insertions(+), 13 deletions(-) create mode 100644 src/playwright/helpers/api-endpoints.ts create mode 100644 src/playwright/helpers/api-helper.ts create mode 100644 src/playwright/helpers/common.ts create mode 100644 src/playwright/helpers/index.ts create mode 100644 src/playwright/helpers/navbar.ts create mode 100644 src/playwright/helpers/ui-helper.ts create mode 100644 src/playwright/page-objects/global-obj.ts create mode 100644 src/playwright/page-objects/page-obj.ts create mode 100644 src/playwright/pages/catalog-import.ts create mode 100644 src/playwright/pages/catalog.ts create mode 100644 src/playwright/pages/extensions.ts create mode 100644 src/playwright/pages/home-page.ts create mode 100644 src/playwright/pages/index.ts create mode 100644 src/playwright/pages/notifications.ts diff --git a/README.md b/README.md index 95ffce3..6411eff 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ A comprehensive test utility package for Red Hat Developer Hub (RHDH) end-to-end - [Playwright Configuration](#playwright-configuration) - [RHDH Deployment](#rhdh-deployment) - [Utilities](#utilities) + - [Helpers](#helpers) + - [Page Objects](#page-objects) - [ESLint Configuration](#eslint-configuration) - [TypeScript Configuration](#typescript-configuration) - [Configuration Files](#configuration-files) @@ -81,6 +83,8 @@ The package provides multiple entry points for different use cases: | `rhdh-e2e-test-utils/playwright-config` | Base Playwright configuration | | `rhdh-e2e-test-utils/rhdh` | RHDH deployment class and types | | `rhdh-e2e-test-utils/utils` | Utility functions (bash, YAML, Kubernetes) | +| `rhdh-e2e-test-utils/helpers` | UI, API, and login helper classes | +| `rhdh-e2e-test-utils/pages` | Page object classes for common RHDH pages | | `rhdh-e2e-test-utils/eslint` | ESLint configuration factory | | `rhdh-e2e-test-utils/tsconfig` | Base TypeScript configuration | @@ -130,10 +134,10 @@ test("my plugin test", async ({ page }) => { ### 4. Create Configuration Files -Create a `config/` directory with your RHDH configuration: +Create a `tests/config/` directory with your RHDH configuration: ``` -config/ +tests/config/ ├── app-config-rhdh.yaml # App configuration ├── dynamic-plugins.yaml # Dynamic plugins configuration └── rhdh-secrets.yaml # Secrets (with env var placeholders) @@ -167,6 +171,8 @@ import { test, expect } from "rhdh-e2e-test-utils/test"; | Fixture | Scope | Description | |---------|-------|-------------| | `rhdh` | worker | Shared RHDHDeployment across all tests in a worker | +| `uiHelper` | test | UIhelper instance for common UI interactions | +| `loginHelper` | test | LoginHelper instance for authentication flows | | `baseURL` | test | Automatically set to the RHDH instance URL | #### Fixture Behavior @@ -177,6 +183,7 @@ import { test, expect } from "rhdh-e2e-test-utils/test"; ```typescript import { test, expect } from "rhdh-e2e-test-utils/test"; + test.beforeAll(async ({ rhdh }) => { // Configure RHDH (creates namespace, and optional DeploymentOptions) await rhdh.configure(); @@ -188,10 +195,17 @@ test.beforeAll(async ({ rhdh }) => { await rhdh.deploy(); }); -test("example test", async ({ page, rhdh }) => { +test("example test", async ({ page, rhdh, uiHelper, loginHelper }) => { // page.goto("/") will use rhdh.rhdhUrl as base await page.goto("/"); + // Login as guest user + await loginHelper.loginAsGuest(); + + // Use UI helper for common interactions + await uiHelper.verifyHeading("Welcome"); + await uiHelper.clickButton("Get Started"); + // Access deployment info console.log(`Namespace: ${rhdh.deploymentConfig.namespace}`); console.log(`URL: ${rhdh.rhdhUrl}`); @@ -388,6 +402,222 @@ const result = envsubst("Port: ${PORT:-8080}"); const result = envsubst("API: ${API_URL}"); ``` +### Helpers + +The package provides helper classes for common testing operations. + +#### UIhelper + +A utility class for common UI interactions with Material-UI components: + +```typescript +import { UIhelper } from "rhdh-e2e-test-utils/helpers"; + +const uiHelper = new UIhelper(page); + +// Wait for page to fully load +await uiHelper.waitForLoad(); + +// Verify headings and text +await uiHelper.verifyHeading("Welcome to RHDH"); +await uiHelper.verifyText("Some content"); + +// Button interactions +await uiHelper.clickButton("Submit"); +await uiHelper.clickButtonByLabel("Close"); + +// Navigation +await uiHelper.openSidebar("Catalog"); +await uiHelper.clickTab("Overview"); + +// Table operations +await uiHelper.verifyRowsInTable(["row1", "row2"]); +await uiHelper.verifyCellsInTable(["cell1", "cell2"]); + +// MUI component interactions +await uiHelper.selectMuiBox("Kind", "Component"); +await uiHelper.fillTextInputByLabel("Name", "my-component"); +``` + +#### LoginHelper + +Handles authentication flows for different providers: + +```typescript +import { LoginHelper } from "rhdh-e2e-test-utils/helpers"; + +const loginHelper = new LoginHelper(page); + +// Guest authentication +await loginHelper.loginAsGuest(); +await loginHelper.signOut(); + +// Keycloak authentication +await loginHelper.loginAsKeycloakUser("username", "password"); + +// GitHub authentication (requires environment variables) +await loginHelper.loginAsGithubUser(); +``` + +#### APIHelper + +Provides utilities for API interactions with both GitHub and Backstage catalog: + +```typescript +import { APIHelper } from "rhdh-e2e-test-utils/helpers"; + +// GitHub API operations +await APIHelper.createGitHubRepo("owner", "repo-name"); +await APIHelper.deleteGitHubRepo("owner", "repo-name"); +const prs = await APIHelper.getGitHubPRs("owner", "repo", "open"); + +// Backstage catalog API operations +const apiHelper = new APIHelper(); +await apiHelper.setBaseUrl(rhdhUrl); +await apiHelper.setStaticToken(token); + +const users = await apiHelper.getAllCatalogUsersFromAPI(); +const groups = await apiHelper.getAllCatalogGroupsFromAPI(); +const locations = await apiHelper.getAllCatalogLocationsFromAPI(); + +// Schedule entity refresh +await apiHelper.scheduleEntityRefreshFromAPI("my-component", "component", token); +``` + +#### setupBrowser + +Utility function for setting up a shared browser context with video recording. Use this in `test.beforeAll` for serial test suites or when you want to persist the browser context across multiple tests (e.g., to avoid repeated logins): + +```typescript +import { test } from "@playwright/test"; +import { setupBrowser, LoginHelper } from "rhdh-e2e-test-utils/helpers"; +import type { Page, BrowserContext } from "@playwright/test"; + +test.describe.configure({ mode: "serial" }); + +let page: Page; +let context: BrowserContext; + +test.beforeAll(async ({ browser }, testInfo) => { + // Setup shared browser context with video recording + ({ page, context } = await setupBrowser(browser, testInfo)); + + // Login once, session persists across all tests in this suite + const loginHelper = new LoginHelper(page); + await page.goto("/"); + await loginHelper.loginAsKeycloakUser(); +}); + +test.afterAll(async () => { + await context.close(); +}); + +test("first test - already logged in", async () => { + await page.goto("/catalog"); + // No need to login again +}); + +test("second test - session persists", async () => { + await page.goto("/settings"); + // Still logged in from beforeAll +}); +``` + +### Page Objects + +Pre-built page object classes for common RHDH pages: + +```typescript +import { + CatalogPage, + HomePage, + CatalogImportPage, + ExtensionsPage, + NotificationPage, +} from "rhdh-e2e-test-utils/pages"; +``` + +#### CatalogPage + +```typescript +const catalogPage = new CatalogPage(page); + +// Navigate to catalog +await catalogPage.go(); + +// Search for entities +await catalogPage.search("my-component"); + +// Navigate to specific component +await catalogPage.goToByName("my-component"); +``` + +#### HomePage + +```typescript +const homePage = new HomePage(page); + +// Verify quick search functionality +await homePage.verifyQuickSearchBar("search-term"); + +// Verify quick access sections +await homePage.verifyQuickAccess("Favorites", "My Component"); +``` + +#### CatalogImportPage + +```typescript +const catalogImportPage = new CatalogImportPage(page); + +// Register or refresh an existing component +const wasAlreadyRegistered = await catalogImportPage.registerExistingComponent( + "https://github.com/org/repo/blob/main/catalog-info.yaml" +); + +// Analyze a component URL +await catalogImportPage.analyzeComponent("https://github.com/org/repo/blob/main/catalog-info.yaml"); + +// Inspect entity and verify YAML content +await catalogImportPage.inspectEntityAndVerifyYaml("kind: Component"); +``` + +#### ExtensionsPage + +```typescript +const extensionsPage = new ExtensionsPage(page); + +// Filter by support type +await extensionsPage.selectSupportTypeFilter("Red Hat"); + +// Verify plugin details +await extensionsPage.verifyPluginDetails({ + pluginName: "Topology", + badgeLabel: "Red Hat support", + badgeText: "Red Hat", +}); + +// Search and verify results +await extensionsPage.waitForSearchResults("catalog"); +``` + +#### NotificationPage + +```typescript +const notificationPage = new NotificationPage(page); + +// Navigate to notifications +await notificationPage.clickNotificationsNavBarItem(); + +// Check notification content +await notificationPage.notificationContains("Build completed"); + +// Manage notifications +await notificationPage.markAllNotificationsAsRead(); +await notificationPage.selectSeverity("critical"); +await notificationPage.viewSaved(); +await notificationPage.sortByNewestOnTop(); +``` + ### ESLint Configuration Pre-configured ESLint rules for Playwright tests: @@ -431,7 +661,7 @@ src/deployment/rhdh/ ### Project Configuration -Create these files in your project's `config/` directory: +Create these files in your project's `tests/config/` directory: #### app-config-rhdh.yaml @@ -523,14 +753,46 @@ test.beforeAll(async ({ rhdh }) => { namespace: "custom-test-ns", version: "1.5", method: "helm", - appConfig: "custom/app-config.yaml", - secrets: "custom/secrets.yaml", - dynamicPlugins: "custom/plugins.yaml", - valueFile: "custom/values.yaml", + appConfig: "tests/config/app-config.yaml", + secrets: "tests/config/secrets.yaml", + dynamicPlugins: "tests/config/plugins.yaml", + valueFile: "tests/config/values.yaml", }); await rhdh.deploy(); +}); +``` + +### Using Helpers and Page Objects + +```typescript +import { test, expect } from "rhdh-e2e-test-utils/test"; +import { CatalogPage } from "rhdh-e2e-test-utils/pages"; +import { APIHelper } from "rhdh-e2e-test-utils/helpers"; + +test.beforeAll(async ({ rhdh }) => { + await rhdh.deploy(); +}); + +test("catalog interaction", async ({ page, uiHelper, loginHelper }) => { + // Login + await loginHelper.loginAsKeycloakUser(); + + // Use page object for catalog operations + const catalogPage = new CatalogPage(page); + await catalogPage.go(); + await catalogPage.search("my-component"); + + // Use UI helper for assertions + await uiHelper.verifyRowsInTable(["my-component"]); +}); + +test("API operations", async ({ rhdh }) => { + // Create GitHub repo via API + await APIHelper.createGitHubRepo("my-org", "test-repo"); + // Clean up + await APIHelper.deleteGitHubRepo("my-org", "test-repo"); }); ``` diff --git a/package.json b/package.json index b715f58..29faf88 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,14 @@ "./utils": { "types": "./dist/utils/index.d.ts", "default": "./dist/utils/index.js" + }, + "./helpers": { + "types": "./dist/playwright/helpers/index.d.ts", + "default": "./dist/playwright/helpers/index.js" + }, + "./pages": { + "types": "./dist/playwright/pages/index.d.ts", + "default": "./dist/playwright/pages/index.js" } }, "files": [ @@ -58,6 +66,7 @@ "@playwright/test": "^1.57.0" }, "devDependencies": { + "@backstage/catalog-model": "1.7.5", "@playwright/test": "^1.57.0", "@types/fs-extra": "^11.0.4", "@types/js-yaml": "^4.0.9", @@ -74,6 +83,7 @@ "fs-extra": "^11.3.2", "js-yaml": "^4.1.1", "lodash.mergewith": "^4.6.2", + "otplib": "12.0.1", "prettier": "^3.7.4", "typescript": "^5.9.3", "typescript-eslint": "^8.48.1", diff --git a/src/deployment/rhdh/deployment.ts b/src/deployment/rhdh/deployment.ts index 681b110..ee5d608 100644 --- a/src/deployment/rhdh/deployment.ts +++ b/src/deployment/rhdh/deployment.ts @@ -257,22 +257,23 @@ export class RHDHDeployment { const base: DeploymentConfigBase = { version, namespace: input.namespace, - appConfig: input.appConfig ?? `config/app-config-rhdh.yaml`, - secrets: input.secrets ?? `config/rhdh-secrets.yaml`, - dynamicPlugins: input.dynamicPlugins ?? `config/dynamic-plugins.yaml`, + appConfig: input.appConfig ?? `tests/config/app-config-rhdh.yaml`, + secrets: input.secrets ?? `tests/config/rhdh-secrets.yaml`, + dynamicPlugins: + input.dynamicPlugins ?? `tests/config/dynamic-plugins.yaml`, }; if (method === "helm") { return { ...base, method, - valueFile: input.valueFile ?? `config/value_file.yaml`, + valueFile: input.valueFile ?? `tests/config/value_file.yaml`, }; } else if (method === "operator") { return { ...base, method, - subscription: input.subscription ?? `config/subscription.yaml`, + subscription: input.subscription ?? `tests/config/subscription.yaml`, }; } else { throw new Error(`Invalid RHDH installation method: ${method}`); diff --git a/src/eslint/base.config.ts b/src/eslint/base.config.ts index 809ecf6..c00395f 100644 --- a/src/eslint/base.config.ts +++ b/src/eslint/base.config.ts @@ -76,6 +76,16 @@ export function createEslintConfig(tsconfigRootDir: string): Linter.Config[] { modifiers: ["public"], format: ["camelCase"], }, + // Allow HTTP headers in object literals which require specific formats + { + selector: "objectLiteralProperty", + format: null, + filter: { + regex: + "^(Accept|Authorization|Content-Type|X-GitHub-Api-Version|X-[A-Za-z-]+)$", + match: true, + }, + }, ], // Promise handling "@typescript-eslint/no-floating-promises": "error", diff --git a/src/playwright/fixtures/test.ts b/src/playwright/fixtures/test.ts index de3dcad..ed737b4 100644 --- a/src/playwright/fixtures/test.ts +++ b/src/playwright/fixtures/test.ts @@ -1,8 +1,11 @@ import { RHDHDeployment } from "../../deployment/rhdh/index.js"; import { test as base } from "@playwright/test"; +import { LoginHelper, UIhelper } from "../helpers/index.js"; type RHDHDeploymentTestFixtures = { rhdh: RHDHDeployment; + uiHelper: UIhelper; + loginHelper: LoginHelper; }; type RHDHDeploymentWorkerFixtures = { @@ -42,6 +45,18 @@ export const test = base.extend< }, { auto: true, scope: "test" }, ], + uiHelper: [ + async ({ page }, use) => { + await use(new UIhelper(page)); + }, + { scope: "test" }, + ], + loginHelper: [ + async ({ page }, use) => { + await use(new LoginHelper(page)); + }, + { scope: "test" }, + ], baseURL: [ async ({ rhdhDeploymentWorker }, use) => { await use(rhdhDeploymentWorker.rhdhUrl); diff --git a/src/playwright/helpers/api-endpoints.ts b/src/playwright/helpers/api-endpoints.ts new file mode 100644 index 0000000..15c4e65 --- /dev/null +++ b/src/playwright/helpers/api-endpoints.ts @@ -0,0 +1,31 @@ +const baseApiUrl = "https://api.github.com"; +const perPage = 100; + +const getRepoUrl = (owner: string, repo: string) => + `${baseApiUrl}/repos/${owner}/${repo}`; +const getOrgUrl = (owner: string) => `${baseApiUrl}/orgs/${owner}`; + +const backstageShowcaseAPI = getRepoUrl("janus-idp", "backstage-showcase"); + +export const GITHUB_API_ENDPOINTS = { + pull: (owner: string, repo: string, state: "open" | "closed" | "all") => + `${getRepoUrl(owner, repo)}/pulls?per_page=${perPage}&state=${state}`, + + issues: (state: string) => + `${backstageShowcaseAPI}/issues?per_page=${perPage}&sort=updated&state=${state}`, + + workflowRuns: `${backstageShowcaseAPI}/actions/runs?per_page=${perPage}`, + + deleteRepo: getRepoUrl, + + mergePR: (owner: string, repoName: string, pullNumber: number) => + `${getRepoUrl(owner, repoName)}/pulls/${pullNumber}/merge`, + + createRepo: (owner: string) => `${getOrgUrl(owner)}/repos`, + + pullFiles: (owner: string, repoName: string, pr: number) => + `${getRepoUrl(owner, repoName)}/pulls/${pr}/files`, + + contents: (owner: string, repoName: string) => + `${getRepoUrl(owner, repoName)}/contents`, +}; diff --git a/src/playwright/helpers/api-helper.ts b/src/playwright/helpers/api-helper.ts new file mode 100644 index 0000000..104e5ac --- /dev/null +++ b/src/playwright/helpers/api-helper.ts @@ -0,0 +1,451 @@ +import type { APIResponse } from "@playwright/test"; +import { request, expect } from "@playwright/test"; +import type { GroupEntity, UserEntity } from "@backstage/catalog-model"; +import { GITHUB_API_ENDPOINTS } from "./api-endpoints.js"; + +export class APIHelper { + private static githubAPIVersion = "2022-11-28"; + private staticToken: string = ""; + private baseUrl: string = ""; + useStaticToken = false; + + static async githubRequest( + method: string, + url: string, + body?: string | object, + ): Promise { + const context = await request.newContext(); + const options: { + method: string; + headers: Record; + data?: string | object; + } = { + 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: number = 1, + response: unknown[] = [], + ): Promise { + const fullUrl = `${url}&page=${pageNo}`; + const result = await this.githubRequest("GET", fullUrl); + const body = await result.json(); + + if (!Array.isArray(body)) { + throw new Error( + `Expected array but got ${typeof body}: ${JSON.stringify(body)}`, + ); + } + + if (body.length === 0) { + return response; + } + + response = [...response, ...body]; + return await this.getGithubPaginatedRequest(url, pageNo + 1, response); + } + + static async createGitHubRepo(owner: 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 await APIHelper.getGithubPaginatedRequest(url); + } + const response = await APIHelper.githubRequest("GET", url); + return response.json(); + } + + static async getfileContentFromPR( + owner: string, + repoName: string, + pr: number, + filename: string, + ): Promise { + const response = await APIHelper.githubRequest( + "GET", + GITHUB_API_ENDPOINTS.pullFiles(owner, repoName, pr), + ); + const fileRawUrl = (await response.json()).find( + (file: { filename: string }) => file.filename === filename, + ).raw_url; + const rawFileContent = await ( + await APIHelper.githubRequest("GET", fileRawUrl) + ).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 = await response.json(); + return data.backstageIdentity.token; + } + + async getGuestAuthHeader(): Promise<{ [key: string]: string }> { + const token = await this.getGuestToken(); + const headers = { + Authorization: `Bearer ${token}`, + }; + return headers; + } + + async setStaticToken(token: string) { + this.useStaticToken = true; + this.staticToken = "Bearer " + token; + } + + async setBaseUrl(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: method, + headers: { + Accept: "application/json", + Authorization: `${staticToken}`, + }, + ...(body && { data: body }), + }; + + const response = await context.fetch(url, options); + return response; + } + + async getAllCatalogUsersFromAPI() { + const url = `${this.baseUrl}/api/catalog/entities/by-query?orderField=metadata.name%2Casc&filter=kind%3Duser`; + const token = this.useStaticToken ? this.staticToken : ""; + const response = await APIHelper.apiRequestWithStaticToken( + "GET", + url, + token, + ); + return response.json(); + } + + async getAllCatalogLocationsFromAPI() { + const url = `${this.baseUrl}/api/catalog/entities/by-query?orderField=metadata.name%2Casc&filter=kind%3Dlocation`; + const token = this.useStaticToken ? this.staticToken : ""; + const response = await APIHelper.apiRequestWithStaticToken( + "GET", + url, + token, + ); + return response.json(); + } + + async getAllCatalogGroupsFromAPI() { + const url = `${this.baseUrl}/api/catalog/entities/by-query?orderField=metadata.name%2Casc&filter=kind%3Dgroup`; + const token = this.useStaticToken ? this.staticToken : ""; + const response = await APIHelper.apiRequestWithStaticToken( + "GET", + url, + token, + ); + return response.json(); + } + + async getGroupEntityFromAPI(group: string) { + const url = `${this.baseUrl}/api/catalog/entities/by-name/group/default/${group}`; + const token = this.useStaticToken ? this.staticToken : ""; + const response = await APIHelper.apiRequestWithStaticToken( + "GET", + url, + token, + ); + return response.json(); + } + + async getCatalogUserFromAPI(user: string) { + const url = `${this.baseUrl}/api/catalog/entities/by-name/user/default/${user}`; + const token = this.useStaticToken ? this.staticToken : ""; + const response = await APIHelper.apiRequestWithStaticToken( + "GET", + url, + token, + ); + return response.json(); + } + + async deleteUserEntityFromAPI(user: string) { + const r: UserEntity = await this.getCatalogUserFromAPI(user); + if (!r.metadata || !r.metadata.uid) { + return; + } + const url = `${this.baseUrl}/api/catalog/entities/by-uid/${r.metadata.uid}`; + const token = this.useStaticToken ? this.staticToken : ""; + const response = await APIHelper.apiRequestWithStaticToken( + "DELETE", + url, + token, + ); + return response.statusText; + } + + async getCatalogGroupFromAPI(group: string) { + const url = `${this.baseUrl}/api/catalog/entities/by-name/group/default/${group}`; + const token = this.useStaticToken ? this.staticToken : ""; + const response = await APIHelper.apiRequestWithStaticToken( + "GET", + url, + token, + ); + return response.json(); + } + + async deleteGroupEntityFromAPI(group: string) { + const r: GroupEntity = 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 = await response.json(); + return data?.metadata?.uid; + } + + /** + * Deletes a location from the Backstage catalog by its UID. + * + * @param uid - The UID of the location to delete. + * @returns The status code of the delete operation. + */ + static async deleteLocationByUid(uid: 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 = await response.json(); + return data?.metadata?.uid; + } + if (response.status() === 404) { + return undefined; + } + return undefined; + } + + /** + * Deletes an entity location from the Backstage catalog by its ID. + * + * @param id - The ID of the entity to delete. + * @returns The status code of the delete operation. + */ + static async deleteEntityLocationById(id: 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 = await response.json(); + // data is expected to be an array of objects with a 'data' property + const location = (Array.isArray(data) ? data : []).find( + (entry) => entry?.data?.target === target, + ); + return location?.data?.id; + } +} diff --git a/src/playwright/helpers/common.ts b/src/playwright/helpers/common.ts new file mode 100644 index 0000000..2fcd084 --- /dev/null +++ b/src/playwright/helpers/common.ts @@ -0,0 +1,398 @@ +import { UIhelper } from "./ui-helper.js"; +import { authenticator } from "otplib"; +import { test, expect } from "@playwright/test"; +import type { Browser, Page, TestInfo } from "@playwright/test"; +import { SETTINGS_PAGE_COMPONENTS } from "../page-objects/page-obj.js"; +import { UI_HELPER_ELEMENTS } from "../page-objects/global-obj.js"; +import * as path from "path"; +import * as fs from "fs"; + +export class LoginHelper { + page: Page; + uiHelper: UIhelper; + + constructor(page: Page) { + this.page = page; + this.uiHelper = new UIhelper(page); + } + + async loginAsGuest() { + await this.page.goto("/"); + await this.uiHelper.waitForLoad(240000); + // TODO - Remove it after https://issues.redhat.com/browse/RHIDP-2043. A Dynamic plugin for Guest Authentication Provider needs to be created + this.page.on("dialog", async (dialog) => { + console.log(`Dialog message: ${dialog.message()}`); + await dialog.accept(); + }); + + await this.uiHelper.verifyHeading("Select a sign-in method"); + await this.uiHelper.clickButton("Enter"); + await this.page.waitForSelector("nav a", { timeout: 10_000 }); + } + + async signOut() { + await this.page.click(SETTINGS_PAGE_COMPONENTS.userSettingsMenu); + await this.page.click(SETTINGS_PAGE_COMPONENTS.signOut); + await this.uiHelper.verifyHeading("Select a sign-in method"); + } + + 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); + + switch (userid) { + case process.env.GH_USER_ID: + await this.page.fill("#password", process.env.GH_USER_PASS as string); + break; + case process.env.GH_USER2_ID: + await this.page.fill("#password", process.env.GH_USER2_PASS as string); + break; + default: + throw new Error("Invalid User ID"); + } + + await this.page.click('[value="Sign in"]'); + await this.page.fill("#app_totp", this.getGitHub2FAOTP(userid)); + test.setTimeout(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, + )) + ) { + await this.page.waitForTimeout(60000); + await this.page.fill("#app_totp", this.getGitHub2FAOTP(userid)); + } + + await this.page.waitForTimeout(3_000); + } + + async logintoKeycloak(userid: string, password: string) { + 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); + await popup.locator("#kc-login").click(); + resolve(); + }); + }); + } + + async loginAsKeycloakUser( + userid: string = process.env.GH_USER_ID as string, + password: string = process.env.GH_USER_PASS as string, + ) { + await this.page.goto("/"); + await this.uiHelper.waitForLoad(240000); + await this.uiHelper.clickButton("Sign In"); + await this.logintoKeycloak(userid, password); + await this.page.waitForSelector("nav a", { timeout: 10_000 }); + } + + async loginAsGithubUser(userid: string = process.env.GH_USER_ID as string) { + const sessionFileName = `authState_${userid}.json`; + + // Check if a session file for this specific user already exists + if (fs.existsSync(sessionFileName)) { + // Load and reuse existing authentication state + const cookies = JSON.parse( + fs.readFileSync(sessionFileName, "utf-8"), + ).cookies; + await this.page.context().addCookies(cookies); + console.log(`Reusing existing authentication state for user: ${userid}`); + await this.page.goto("/"); + await this.uiHelper.waitForLoad(12000); + await this.uiHelper.clickButton("Sign In"); + 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.uiHelper.waitForLoad(240000); + await this.uiHelper.clickButton("Sign In"); + await this.checkAndReauthorizeGithubApp(); + await this.page.waitForSelector("nav a", { timeout: 10_000 }); + await this.page.context().storageState({ path: sessionFileName }); + console.log(`Authentication state saved for user: ${userid}`); + } + } + + async checkAndReauthorizeGithubApp() { + await new Promise((resolve) => { + this.page.once("popup", async (popup) => { + await popup.waitForLoadState(); + + // Check for popup closure for up to 10 seconds before proceeding + for (let attempts = 0; attempts < 10 && !popup.isClosed(); attempts++) { + await this.page.waitForTimeout(1000); // Using page here because if the popup closes automatically, it throws an error during the wait + } + + const locator = popup.locator("button.js-oauth-authorize-btn"); + if (!popup.isClosed() && (await locator.isVisible())) { + await popup.locator("body").click(); + await locator.waitFor(); + await locator.click(); + } + resolve(); + }); + }); + } + + async googleSignIn(email: string) { + await new Promise((resolve) => { + this.page.once("popup", async (popup) => { + await popup.waitForLoadState(); + const locator = popup + .getByRole("link", { name: email, exact: false }) + .first(); + await popup.waitForTimeout(3000); + await locator.waitFor({ state: "visible" }); + await locator.click({ force: true }); + await popup.waitForTimeout(3000); + + await popup + .locator("[name=Passwd]") + .fill(process.env.GOOGLE_USER_PASS as string); + await popup.locator("[name=Passwd]").press("Enter"); + await popup.waitForTimeout(3500); + await popup.locator("[name=totpPin]").fill(this.getGoogle2FAOTP()); + await popup.locator("[name=totpPin]").press("Enter"); + await popup + .getByRole("button", { name: /Continue|Weiter/ }) + .click({ timeout: 60000 }); + resolve(); + }); + }); + } + + async checkAndClickOnGHloginPopup(force = false) { + const frameLocator = this.page.getByLabel("Login Required"); + try { + await frameLocator.waitFor({ state: "visible", timeout: 2000 }); + await this.clickOnGHloginPopup(); + } catch (error) { + if (force) throw error; + } + } + + getButtonSelector(label: 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 clickOnGHloginPopup() { + const isLoginRequiredVisible = await this.uiHelper.isTextVisible("Sign in"); + if (isLoginRequiredVisible) { + await this.uiHelper.clickButton("Sign in"); + await this.uiHelper.clickButton("Log in"); + await this.checkAndReauthorizeGithubApp(); + await this.page.waitForSelector(this.getLoginBtnSelector(), { + state: "detached", + }); + } else { + console.log( + '"Log in" button is not visible. Skipping login popup actions.', + ); + } + } + + getGitHub2FAOTP(userid: string): string { + const secrets: { [key: string]: string | undefined } = { + [process.env.GH_USER_ID as string]: process.env.GH_2FA_SECRET, + [process.env.GH_USER2_ID as string]: 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 as string; + return authenticator.generate(secret); + } + + async keycloakLogin(username: string, password: string) { + await this.page.goto("/"); + await this.page.waitForSelector('p:has-text("Sign in using OIDC")'); + + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.uiHelper.clickButton("Sign In"), + ]); + + await popup.waitForLoadState("domcontentloaded"); + + // Check if popup closes automatically (already logged in) + try { + await popup.waitForEvent("close", { timeout: 5000 }); + return "Already logged in"; + } catch { + // Popup didn't close, proceed with login + } + + try { + await popup.locator("#username").click(); + await popup.locator("#username").fill(username); + await popup.locator("#password").fill(password); + await popup.locator("[name=login]").click({ timeout: 5000 }); + await popup.waitForEvent("close", { timeout: 2000 }); + return "Login successful"; + } catch (e) { + const usernameError = popup.locator("id=input-error"); + if (await usernameError.isVisible()) { + await popup.close(); + return "User does not exist"; + } else { + throw e; + } + } + } + + 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 + } + + try { + await popup.locator("#login_field").click({ timeout: 5000 }); + await popup.locator("#login_field").fill(username, { timeout: 5000 }); + const cookieLocator = popup.locator("#wcpConsentBannerCtrl"); + if (await cookieLocator.isVisible()) { + await popup.click('button:has-text("Reject")', { timeout: 5000 }); + } + await popup.locator("#password").click({ timeout: 5000 }); + await popup.locator("#password").fill(password, { timeout: 5000 }); + await popup + .locator("[type='submit'][value='Sign in']:not(webauthn-status *)") + .first() + .click({ timeout: 5000 }); + const twofactorcode = authenticator.generate(twofactor); + await popup.locator("#app_totp").click({ timeout: 5000 }); + await popup.locator("#app_totp").fill(twofactorcode, { timeout: 5000 }); + + await popup.waitForEvent("close", { timeout: 20000 }); + return "Login successful"; + } catch (e) { + const authorization = popup.locator("button.js-oauth-authorize-btn"); + if (await authorization.isVisible()) { + await authorization.click(); + return "Login successful"; + } else { + throw e; + } + } + } + + async githubLogin(username: string, password: string, twofactor: string) { + await this.page.goto("/"); + await this.page.waitForSelector('p:has-text("Sign in using GitHub")'); + + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.uiHelper.clickButton("Sign In"), + ]); + + return this.handleGitHubPopupLogin(popup, username, password, twofactor); + } + + async githubLoginFromSettingsPage( + username: string, + password: string, + twofactor: string, + ) { + await this.page.goto("/settings/auth-providers"); + + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.page.getByTitle("Sign in to GitHub").click(), + this.uiHelper.clickButton("Log in"), + ]); + + return this.handleGitHubPopupLogin(popup, username, password, twofactor); + } + async microsoftAzureLogin(username: string, password: string) { + await this.page.goto("/"); + await this.page.waitForSelector('p:has-text("Sign in using Microsoft")'); + + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.uiHelper.clickButton("Sign In"), + ]); + + await popup.waitForLoadState("domcontentloaded"); + + if (popup.url().startsWith(process.env.BASE_URL as string)) { + // an active microsoft session is already logged in and the popup will automatically close + return "Already logged in"; + } else { + try { + await popup.locator("[name=loginfmt]").click(); + await popup + .locator("[name=loginfmt]") + .fill(username, { timeout: 5000 }); + await popup + .locator('[type=submit]:has-text("Next")') + .click({ timeout: 5000 }); + + await popup.locator("[name=passwd]").click(); + await popup.locator("[name=passwd]").fill(password, { timeout: 5000 }); + await popup + .locator('[type=submit]:has-text("Sign in")') + .click({ timeout: 5000 }); + await popup + .locator('[type=button]:has-text("No")') + .click({ timeout: 15000 }); + return "Login successful"; + } catch (e) { + const usernameError = popup.locator("id=usernameError"); + if (await usernameError.isVisible()) { + return "User does not exist"; + } else { + throw e; + } + } + } + } +} + +export async function setupBrowser(browser: Browser, testInfo: TestInfo) { + const context = await browser.newContext({ + recordVideo: { + dir: `test-results/${path + .parse(testInfo.file) + .name.replace(".spec", "")}/${testInfo.titlePath[1]}`, + size: { width: 1920, height: 1080 }, + }, + }); + const page = await context.newPage(); + return { page, context }; +} diff --git a/src/playwright/helpers/index.ts b/src/playwright/helpers/index.ts new file mode 100644 index 0000000..4f4bb99 --- /dev/null +++ b/src/playwright/helpers/index.ts @@ -0,0 +1,4 @@ +export { GITHUB_API_ENDPOINTS } from "./api-endpoints.js"; +export { APIHelper } from "./api-helper.js"; +export { LoginHelper, setupBrowser } from "./common.js"; +export { UIhelper } from "./ui-helper.js"; diff --git a/src/playwright/helpers/navbar.ts b/src/playwright/helpers/navbar.ts new file mode 100644 index 0000000..651984e --- /dev/null +++ b/src/playwright/helpers/navbar.ts @@ -0,0 +1,14 @@ +export type SidebarTabs = + | "Catalog" + | "Settings" + | "My Group" + | "Home" + | "Self-service" + | "Learning Paths" + | "Extensions" + | "Bulk import" + | "Docs" + | "Clusters" + | "Tech Radar" + | "Notifications" + | "Orchestrator"; diff --git a/src/playwright/helpers/ui-helper.ts b/src/playwright/helpers/ui-helper.ts new file mode 100644 index 0000000..211237a --- /dev/null +++ b/src/playwright/helpers/ui-helper.ts @@ -0,0 +1,580 @@ +import type { Locator, Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { + UI_HELPER_ELEMENTS, + WAIT_OBJECTS, +} from "../page-objects/global-obj.js"; +import type { SidebarTabs } from "./navbar.js"; +import { SEARCH_OBJECTS_COMPONENTS } from "../page-objects/page-obj.js"; + +export class UIhelper { + private page: Page; + + constructor(page: Page) { + this.page = page; + } + + async waitForLoad(timeout = 120000) { + for (const item of Object.values(WAIT_OBJECTS)) { + await this.page.waitForSelector(item, { + state: "hidden", + timeout: timeout, + }); + } + } + + 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 selector = `${UI_HELPER_ELEMENTS.MuiButtonLabel}`; + const button = this.page + .locator(selector) + .getByText(label, { exact: options.exact }) + .first(); + + if (options?.force) { + await button.click({ force: true }); + } else { + await button.click(); + } + return button; + } + + async clickBtnByTitleIfNotPressed(title: string) { + const button = this.page.locator(`button[title="${title}"]`); + const isPressed = await button.getAttribute("aria-pressed"); + + if (isPressed === "false") { + 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 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) { + await buttonElement.click({ force: true }); + } else { + await buttonElement.click(); + } + } + + async clickButtonByLabel(label: string | RegExp) { + await this.page.getByRole("button", { name: label }).first().click(); + } + + async clickLink(options: string | { href: string } | { ariaLabel: string }) { + let linkLocator: Locator; + + if (typeof options === "string") { + linkLocator = this.page.locator("a").filter({ hasText: options }).first(); + } else if ("href" in options) { + linkLocator = this.page.locator(`a[href="${options.href}"]`).first(); + } else { + linkLocator = this.page + .locator(`div[aria-label='${options.ariaLabel}'] a`) + .first(); + } + + await linkLocator.waitFor({ state: "visible" }); + await linkLocator.click(); + } + + async openProfileDropdown() { + const header = this.page.locator("nav[id='global-header']"); + await expect(header).toBeVisible(); + await header + .locator("[data-testid='KeyboardArrowDownOutlinedIcon']") + .click(); + } + + async goToPageUrl(url: string, heading?: string) { + await this.page.goto(url); + await expect(this.page).toHaveURL(url); + if (heading) { + await this.verifyHeading(heading); + } + } + + 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 + .locator("a") + .getByText(arg, { exact: options.exact }) + .first(); + + notVisibleCheck = options?.notVisible ?? false; + } else { + linkLocator = this.page.locator(`div[aria-label="${arg.label}"] a`); + notVisibleCheck = false; + } + + if (notVisibleCheck) { + await expect(linkLocator).toBeHidden(); + } else { + await expect(linkLocator).toBeVisible(); + } + } + + 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 await this.isElementVisible(locator); + } + + async isBtnVisible(text: string): Promise { + const locator = `button:has-text("${text}")`; + return await this.isElementVisible(locator); + } + + async isTextVisible(text: string, timeout = 10000): Promise { + const locator = `:has-text("${text}")`; + return await 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 openSidebar(navBarText: SidebarTabs) { + 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("Catalog"); + await this.selectMuiBox("Kind", kind); + await expect(async () => { + await this.clickByDataTestId("user-picker-all"); + await this.page.waitForTimeout(1_500); + await this.verifyHeading(new RegExp(`all ${kind}`, "i")); + }).toPass({ + intervals: [3_000], + timeout: 20_000, + }); + } + + async openSidebarButton(navBarButtonLabel: 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) { + await this.page.click(`div[aria-label="${label}"]`); + const optionSelector = `li[role="option"]:has-text("${value}")`; + await this.page.waitForSelector(optionSelector); + await this.page.click(optionSelector); + } + + async verifyRowsInTable( + rowTexts: (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) { + await this.verifyTextInLocator("", text, exact); + } + + private async verifyTextInLocator( + locator: string, + text: string | RegExp, + exact: boolean, + ) { + const elementLocator = locator + ? this.page.locator(locator).getByText(text, { exact }).first() + : this.page.getByText(text, { exact }).first(); + + await elementLocator.waitFor({ state: "visible" }); + await elementLocator.waitFor({ state: "attached" }); + + try { + await elementLocator.scrollIntoViewIfNeeded(); + } catch (error) { + console.warn( + `Warning: Could not scroll element into view. Error: ${error instanceof Error ? error.message : String(error)}`, + ); + } + await expect(elementLocator).toBeVisible(); + } + + async verifyTextInSelector(selector: 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 verifyColumnHeading( + rowTexts: string[] | RegExp[], + exact: boolean = true, + ) { + for (const rowText of rowTexts) { + const rowLocator = this.page + .locator(`tr>th`) + .getByText(rowText, { exact: exact }) + .first(); + await rowLocator.waitFor({ state: "visible" }); + await rowLocator.scrollIntoViewIfNeeded(); + await expect(rowLocator).toBeVisible(); + } + } + + async verifyHeading(heading: string | RegExp, timeout: number = 20000) { + const headingLocator = this.page + .locator("h1, h2, h3, h4, h5, h6") + .filter({ hasText: heading }) + .first(); + + await headingLocator.waitFor({ state: "visible", timeout: timeout }); + await expect(headingLocator).toBeVisible(); + } + + async verifyParagraph(paragraph: string) { + const headingLocator = this.page + .locator("p") + .filter({ hasText: 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(); + } + } + } + + async verifyButtonURL( + label: string | RegExp, + url: string | RegExp, + options: { locator?: string; exact?: boolean } = { + locator: "", + exact: true, + }, + ) { + // To verify the button URL if it is in a specific locator + const baseLocator = + !options.locator || options.locator === "" + ? this.page + : this.page.locator(options.locator); + + const buttonUrl = await baseLocator + .getByRole("button", { name: label, exact: options.exact }) + .first() + .getAttribute("href"); + + expect(buttonUrl).toContain(url); + } + + /** + * Verifies that a table row, identified by unique text, contains specific cell texts. + * @param {string} uniqueRowText - The unique text present in one of the cells within the row. This is used to identify the specific row. + * @param {Array} cellTexts - An array of cell texts or regular expressions to match against the cells within the identified row. + * @example + * // Example usage to verify that a row containing "Developer-hub" has cells with the texts "service" and "active": + * await verifyRowInTableByUniqueText('Developer-hub', ['service', 'active']); + */ + + async verifyRowInTableByUniqueText( + uniqueRowText: 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.locator("td").filter({ hasText: cellText }).first(), + ).toBeVisible(); + } + } + + /** + * Clicks on a link within a table row that contains a unique text and matches a link's text. + * @param {string} uniqueRowText - The unique text present in one of the cells within the row. This is used to identify the specific row. + * @param {string | RegExp} linkText - The text of the link, can be a string or a regular expression. + * @param {boolean} [exact=true] - Whether to match the link text exactly. By default, this is set to true. + */ + async clickOnLinkInTableByUniqueText( + uniqueRowText: string, + linkText: string | RegExp, + exact: boolean = true, + ) { + const row = this.page.locator(UI_HELPER_ELEMENTS.rowByText(uniqueRowText)); + await row.waitFor(); + await row + .locator("a") + .getByText(linkText, { exact: exact }) + .first() + .click(); + } + + /** + * Clicks on a button within a table row that contains a unique text and matches a button's label or aria-label. + * @param {string} uniqueRowText - The unique text present in one of the cells within the row. This is used to identify the specific row. + * @param {string | RegExp} textOrLabel - The text of the button or the `aria-label` attribute, can be a string or a regular expression. + */ + async clickOnButtonInTableByUniqueText( + uniqueRowText: 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)) + .locator("a") + .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); + } + + async verifyTableIsEmpty() { + const rowSelector = `table tbody tr:not(:has(td[colspan]))`; + const rowCount = await this.page.locator(rowSelector).count(); + expect(rowCount).toEqual(0); + } + + async verifyAlertErrorMessage(message: string | RegExp) { + const alert = this.page.getByRole("alert"); + await alert.waitFor(); + await expect(alert).toHaveText(message); + } + + async verifyTextInTooltip(text: string | RegExp) { + const tooltip = this.page.getByRole("tooltip").getByText(text); + await expect(tooltip).toBeVisible(); + } +} diff --git a/src/playwright/page-objects/global-obj.ts b/src/playwright/page-objects/global-obj.ts new file mode 100644 index 0000000..c147f49 --- /dev/null +++ b/src/playwright/page-objects/global-obj.ts @@ -0,0 +1,28 @@ +export const WAIT_OBJECTS = { + MuiLinearProgress: 'div[class*="MuiLinearProgress-root"]', + MuiCircularProgress: '[class*="MuiCircularProgress-root"]', +}; + +export const UI_HELPER_ELEMENTS = { + MuiButtonLabel: + 'span[class^="MuiButton-label"],button[class*="MuiButton-root"]', + MuiToggleButtonLabel: 'span[class^="MuiToggleButton-label"]', + MuiBoxLabel: 'div[class*="MuiBox-root"] label', + MuiTableHead: 'th[class*="MuiTableCell-root"]', + MuiTableCell: 'td[class*="MuiTableCell-root"]', + MuiTableRow: 'tr[class*="MuiTableRow-root"]', + MuiTypographyColorPrimary: ".MuiTypography-colorPrimary", + MuiSwitchColorPrimary: ".MuiSwitch-colorPrimary", + MuiButtonTextPrimary: ".MuiButton-textPrimary", + MuiCard: (cardHeading: string) => + `//div[contains(@class,'MuiCardHeader-root') and descendant::*[text()='${cardHeading}']]/..`, + MuiCardRoot: (cardText: string) => + `//div[contains(@class,'MuiCard-root')][descendant::text()[contains(., '${cardText}')]]`, + MuiTable: "table.MuiTable-root", + MuiCardHeader: 'div[class*="MuiCardHeader-root"]', + MuiInputBase: 'div[class*="MuiInputBase-root"]', + MuiTypography: 'span[class*="MuiTypography-root"]', + MuiAlert: 'div[class*="MuiAlert-message"]', + tabs: '[role="tab"]', + rowByText: (text: string) => `tr:has(:text-is("${text}"))`, +}; diff --git a/src/playwright/page-objects/page-obj.ts b/src/playwright/page-objects/page-obj.ts new file mode 100644 index 0000000..7dafa6a --- /dev/null +++ b/src/playwright/page-objects/page-obj.ts @@ -0,0 +1,48 @@ +export const HOME_PAGE_COMPONENTS = { + MuiAccordion: 'div[class*="MuiAccordion-root-"]', + MuiCard: 'div[class*="MuiCard-root-"]', +}; + +export const SEARCH_OBJECTS_COMPONENTS = { + ariaLabelSearch: 'input[aria-label="Search"]', + placeholderSearch: 'input[placeholder="Search"]', +}; + +export const CATALOG_IMPORT_COMPONENTS = { + componentURL: 'input[name="url"]', +}; + +export const KUBERNETES_COMPONENTS = { + MuiAccordion: 'div[class*="MuiAccordion-root-"]', + statusOk: 'span[aria-label="Status ok"]', + podLogs: 'label[aria-label="get logs"]', + MuiSnackbarContent: 'div[class*="MuiSnackbarContent-message-"]', +}; + +export const BACKSTAGE_SHOWCASE_COMPONENTS = { + tableNextPage: 'button[aria-label="Next Page"]', + tablePreviousPage: 'button[aria-label="Previous Page"]', + tableLastPage: 'button[aria-label="Last Page"]', + tableFirstPage: 'button[aria-label="First Page"]', + tableRows: 'table[class*="MuiTable-root-"] tbody tr', + tablePageSelectBox: 'div[class*="MuiTablePagination-input"]', +}; + +export const SETTINGS_PAGE_COMPONENTS = { + userSettingsMenu: 'button[data-testid="user-settings-menu"]', + signOut: 'li[data-testid="sign-out"]', +}; + +export const ROLES_PAGE_COMPONENTS = { + editRole: (name: string) => `button[data-testid="edit-role-${name}"]`, + deleteRole: (name: string) => `button[data-testid="delete-role-${name}"]`, +}; + +export const DELETE_ROLE_COMPONENTS = { + roleName: 'input[name="delete-role"]', +}; + +export const ROLE_OVERVIEW_COMPONENTS_TEST_ID = { + updatePolicies: "update-policies", + updateMembers: "update-members", +}; diff --git a/src/playwright/pages/catalog-import.ts b/src/playwright/pages/catalog-import.ts new file mode 100644 index 0000000..01c8570 --- /dev/null +++ b/src/playwright/pages/catalog-import.ts @@ -0,0 +1,76 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { UIhelper } from "../helpers/ui-helper.js"; +import { CATALOG_IMPORT_COMPONENTS } from "../page-objects/page-obj.js"; + +export class CatalogImportPage { + private page: Page; + private uiHelper: UIhelper; + + constructor(page: Page) { + this.page = page; + this.uiHelper = new UIhelper(page); + } + + /** + * Fills the component URL input and clicks the "Analyze" button. + * Waits until the analyze button is no longer visible (processing done). + * + * @param url - The URL of the component to analyze + */ + private async analyzeAndWait(url: string): Promise { + await this.page.fill(CATALOG_IMPORT_COMPONENTS.componentURL, url); + await expect(await this.uiHelper.clickButton("Analyze")).not.toBeVisible({ + timeout: 25_000, + }); + } + + /** + * Returns true if the component is already registered + * (i.e., "Refresh" button is visible instead of "Import"). + * + * @returns boolean indicating if the component is already registered + */ + async isComponentAlreadyRegistered(): Promise { + return await this.uiHelper.isBtnVisible("Refresh"); + } + + /** + * Registers an existing component if it has not been registered yet. + * If already registered, clicks the "Refresh" button instead. + * + * @param url - The component URL to register + * @param clickViewComponent - Whether to click "View Component" after import + */ + async registerExistingComponent( + url: string, + clickViewComponent: boolean = true, + ) { + await this.analyzeAndWait(url); + const isComponentAlreadyRegistered = + await this.isComponentAlreadyRegistered(); + if (isComponentAlreadyRegistered) { + await this.uiHelper.clickButton("Refresh"); + expect(await this.uiHelper.isBtnVisible("Register another")).toBeTruthy(); + } else { + await this.uiHelper.clickButton("Import"); + if (clickViewComponent) { + await this.uiHelper.clickButton("View Component"); + } + } + return isComponentAlreadyRegistered; + } + + async analyzeComponent(url: string) { + await this.page.fill(CATALOG_IMPORT_COMPONENTS.componentURL, url); + await this.uiHelper.clickButton("Analyze"); + } + + async inspectEntityAndVerifyYaml(text: string) { + await this.page.getByTitle("More").click(); + await this.page.getByRole("menuitem").getByText("Inspect entity").click(); + await this.uiHelper.clickTab("Raw YAML"); + await expect(this.page.getByTestId("code-snippet")).toContainText(text); + await this.uiHelper.clickButton("Close"); + } +} diff --git a/src/playwright/pages/catalog.ts b/src/playwright/pages/catalog.ts new file mode 100644 index 0000000..a130f97 --- /dev/null +++ b/src/playwright/pages/catalog.ts @@ -0,0 +1,48 @@ +import type { Locator, Page } from "@playwright/test"; +import { UIhelper } from "../helpers/ui-helper.js"; + +//${BASE_URL}/catalog page +export class CatalogPage { + private page: Page; + private uiHelper: UIhelper; + private searchField: Locator; + + constructor(page: Page) { + this.page = page; + this.uiHelper = new UIhelper(page); + this.searchField = page.locator("#input-with-icon-adornment"); + } + + async go() { + await this.uiHelper.openSidebar("Catalog"); + } + + async goToByName(name: string) { + await this.uiHelper.openCatalogSidebar("Component"); + await this.uiHelper.clickLink(name); + } + + async goToBackstageJanusProjectCITab() { + await this.goToBackstageJanusProject(); + await this.uiHelper.clickTab("CI"); + await this.page.waitForSelector('h2:text("Pipeline Runs")'); + await this.uiHelper.verifyHeading("Pipeline Runs"); + } + + async goToBackstageJanusProject() { + await this.goToByName("backstage-janus"); + } + + async search(s: string) { + await this.searchField.clear(); + const searchResponse = this.page.waitForResponse( + new RegExp(`${process.env.BASE_URL}/api/catalog/entities/by-query/*`), + ); + await this.searchField.fill(s); + await searchResponse; + } + + async tableRow(content: string) { + return this.page.locator(`tr >> a >> text="${content}"`); + } +} diff --git a/src/playwright/pages/extensions.ts b/src/playwright/pages/extensions.ts new file mode 100644 index 0000000..d9afda5 --- /dev/null +++ b/src/playwright/pages/extensions.ts @@ -0,0 +1,171 @@ +import type { Page, Locator } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { UIhelper } from "../helpers/ui-helper.js"; + +export class ExtensionsPage { + private page: Page; + public badge: Locator; + private uiHelper: UIhelper; + + private commonHeadings = [ + "Versions", + "Author", + "Tags", + "Category", + "Publisher", + "Support Provider", + ]; + private tableHeaders = [ + "Package name", + "Version", + "Role", + "Backstage compatibility version", + "Status", + ]; + + constructor(page: Page) { + this.page = page; + this.badge = this.page.getByTestId("TaskAltIcon"); + this.uiHelper = new UIhelper(page); + } + + async clickReadMoreByPluginTitle(pluginTitle: string) { + const allCards = this.page.locator(".v5-MuiPaper-outlined"); + const targetCard = allCards.filter({ hasText: pluginTitle }); + await targetCard.getByRole("link", { name: "Read more" }).click(); + } + + async selectDropdown(name: string) { + await this.page + .getByLabel(name) + .getByRole("button", { name: "Open" }) + .click(); + } + + async toggleOption(name: string) { + await this.page + .getByRole("option", { name: name }) + .getByRole("checkbox") + .click(); + } + + async clickAway() { + await this.page.locator("#menu- div").first().click(); + } + + async selectSupportTypeFilter(supportType: string) { + await this.selectDropdown("Support type"); + await this.toggleOption(supportType); + await this.page.keyboard.press("Escape"); + } + + async resetSupportTypeFilter(supportType: string) { + await this.selectDropdown("Support type"); + await this.toggleOption(supportType); + await this.page.keyboard.press("Escape"); + } + + async verifyMultipleHeadings(headings: string[] = this.commonHeadings) { + for (const heading of headings) { + console.log(`Verifying heading: ${heading}`); + await this.uiHelper.verifyHeading(heading); + } + } + + async waitForSearchResults(searchText: string) { + await expect( + this.page.locator(".v5-MuiPaper-outlined").first(), + ).toContainText(searchText, { timeout: 10000 }); + } + + async verifyPluginDetails({ + pluginName, + badgeLabel, + badgeText, + headings = this.commonHeadings, + includeTable = true, + includeAbout = false, + }: { + pluginName: string; + badgeLabel: string; + badgeText: string; + headings?: string[]; + includeTable?: boolean; + includeAbout?: boolean; + }) { + await this.clickReadMoreByPluginTitle(pluginName); + await expect( + this.page.getByLabel(badgeLabel).getByText(badgeText), + ).toBeVisible(); + + if (includeAbout) { + await this.uiHelper.verifyText("About"); + } + + await this.verifyMultipleHeadings(headings); + + if (includeTable) { + await this.uiHelper.verifyTableHeadingAndRows(this.tableHeaders); + } + + await this.page + .getByRole("button", { + name: "close", + }) + .click(); + } + + async verifySupportTypeBadge({ + supportType, + pluginName, + badgeLabel, + badgeText, + tooltipText, + searchTerm, + headings = this.commonHeadings, + includeTable = true, + includeAbout = false, + }: { + supportType: string; + pluginName?: string; + badgeLabel: string; + badgeText: string; + tooltipText: string; + searchTerm?: string; + headings?: string[]; + includeTable?: boolean; + includeAbout?: boolean; + }) { + await this.selectSupportTypeFilter(supportType); + + if (searchTerm) { + await this.uiHelper.searchInputPlaceholder(searchTerm); + await this.waitForSearchResults(searchTerm); + } + + if (pluginName) { + await this.verifyPluginDetails({ + pluginName, + badgeLabel, + badgeText, + headings, + includeTable, + includeAbout, + }); + } else { + await expect(this.page.getByLabel(badgeLabel).first()).toBeVisible(); + await expect(this.badge.first()).toBeVisible(); + await this.badge.first().hover(); + await this.uiHelper.verifyTextInTooltip(tooltipText); + } + + await this.resetSupportTypeFilter(supportType); + } + + async verifyKeyValueRowElements(rowTitle: string, rowValue: string) { + const rowLocator = this.page.locator(".v5-MuiTableRow-root"); + await expect(rowLocator.filter({ hasText: rowTitle })).toContainText( + rowValue, + ); + } +} diff --git a/src/playwright/pages/home-page.ts b/src/playwright/pages/home-page.ts new file mode 100644 index 0000000..bfc3c05 --- /dev/null +++ b/src/playwright/pages/home-page.ts @@ -0,0 +1,67 @@ +import { + HOME_PAGE_COMPONENTS, + SEARCH_OBJECTS_COMPONENTS, +} from "../page-objects/page-obj.js"; +import { UIhelper } from "../helpers/ui-helper.js"; +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +export class HomePage { + private page: Page; + private uiHelper: UIhelper; + + constructor(page: Page) { + this.page = page; + this.uiHelper = new UIhelper(page); + } + async verifyQuickSearchBar(text: string) { + const searchBar = this.page.locator( + SEARCH_OBJECTS_COMPONENTS.ariaLabelSearch, + ); + await searchBar.waitFor(); + await searchBar.fill(""); + await searchBar.type(text + "\n"); // '\n' simulates pressing the Enter key + await this.uiHelper.verifyLink(text); + } + + async verifyQuickAccess( + section: string, + quickAccessItem: string, + expand = false, + ) { + await this.page.waitForSelector(HOME_PAGE_COMPONENTS.MuiAccordion, { + state: "visible", + }); + + const sectionLocator = this.page + .locator(HOME_PAGE_COMPONENTS.MuiAccordion) + .filter({ hasText: section }); + + if (expand) { + await sectionLocator.click(); + await this.page.waitForTimeout(500); + } + + const itemLocator = sectionLocator + .locator(`a div[class*="MuiListItemText-root"]`) + .filter({ hasText: quickAccessItem }); + + await itemLocator.waitFor({ state: "visible" }); + + const isVisible = itemLocator; + await expect(isVisible).toBeVisible(); + } + + async verifyVisitedCardContent(section: string) { + await this.page.waitForSelector(HOME_PAGE_COMPONENTS.MuiCard, { + state: "visible", + }); + + const sectionLocator = this.page + .locator(HOME_PAGE_COMPONENTS.MuiCard) + .filter({ hasText: section }); + + const itemLocator = sectionLocator.locator(`li[class*="MuiListItem-root"]`); + expect(await itemLocator.count()).toBeGreaterThanOrEqual(0); + } +} diff --git a/src/playwright/pages/index.ts b/src/playwright/pages/index.ts new file mode 100644 index 0000000..57d6ec1 --- /dev/null +++ b/src/playwright/pages/index.ts @@ -0,0 +1,5 @@ +export { CatalogImportPage } from "./catalog-import.js"; +export { CatalogPage } from "./catalog.js"; +export { ExtensionsPage } from "./extensions.js"; +export { HomePage } from "./home-page.js"; +export { NotificationPage } from "./notifications.js"; diff --git a/src/playwright/pages/notifications.ts b/src/playwright/pages/notifications.ts new file mode 100644 index 0000000..e431ed1 --- /dev/null +++ b/src/playwright/pages/notifications.ts @@ -0,0 +1,154 @@ +import { expect, type Page } from "@playwright/test"; +import { UIhelper } from "../helpers/ui-helper.js"; + +export class NotificationPage { + private readonly page: Page; + private readonly uiHelper: UIhelper; + + constructor(page: Page) { + this.page = page; + this.uiHelper = new UIhelper(page); + } + + async clickNotificationsNavBarItem() { + await this.uiHelper.openSidebar("Notifications"); + await expect( + this.page.getByTestId("loading-indicator").getByRole("img"), + ).toHaveCount(0); + } + + async notificationContains(text: string | RegExp) { + await this.page.getByLabel(/.*rows/).click(); + // always expand the notifications table to show as many notifications as possible + await this.page.getByRole("option", { name: "20" }).click(); + await expect( + this.page.getByTestId("loading-indicator").getByRole("img"), + ).toHaveCount(0); + const row = this.page.locator(`tr`, { hasText: text }).first(); + await expect(row).toHaveCount(1); + } + + async clickNotificationHeadingLink(text: string | RegExp) { + await this.page + .getByRole("cell", { name: text, exact: true }) + .first() + .getByRole("heading") + .click(); + } + async markAllNotificationsAsRead() { + const markAllNotificationsAsReadIsVisible = await this.page + .getByTitle("Mark all read") + .getByRole("button") + .isVisible(); + console.log(markAllNotificationsAsReadIsVisible); + // If button isn't visible there are no records in the notification table + if (markAllNotificationsAsReadIsVisible.toString() != "false") { + await this.page.getByTitle("Mark all read").getByRole("button").click(); + await this.page.getByRole("button", { name: "MARK ALL" }).click(); + await expect( + this.page.getByTestId("loading-indicator").getByRole("img"), + ).toHaveCount(0); + await expect(this.page.getByText("No records to display")).toBeVisible(); + } + } + + async selectAllNotifications() { + await this.page.getByRole("checkbox").first().click(); + } + + async selectNotification(nth = 1) { + await this.page.getByRole("checkbox").nth(nth).click(); + } + + async selectSeverity(severity = "") { + await this.page.getByLabel("Severity").click(); + await this.page.getByRole("option", { name: severity }).click(); + await expect( + this.page.getByRole("table").filter({ hasText: "Rows per page" }), + ).toBeVisible(); + await expect( + this.page.getByTestId("loading-indicator").getByRole("img"), + ).toHaveCount(0); + } + + async saveSelected() { + await this.page + .locator("thead") + .getByTitle("Save selected for later") + .getByRole("button") + .click(); + await expect( + this.page.getByTestId("loading-indicator").getByRole("img"), + ).toHaveCount(0); + } + + async saveAllSelected() { + await this.page + .locator("thead") + .getByTitle("Save selected for later") + .getByRole("button") + .click(); + await expect( + this.page.getByTestId("loading-indicator").getByRole("img"), + ).toHaveCount(0); + } + + async viewSaved() { + await this.page.getByLabel("View").click(); + await this.page.getByRole("option", { name: "Saved" }).click(); + await expect( + this.page.getByTestId("loading-indicator").getByRole("img"), + ).toHaveCount(0); + } + + async markLastNotificationAsRead() { + const row = this.page.locator("td:nth-child(3) > div").first(); + await row.getByRole("button").nth(1).click(); + } + + async markNotificationAsRead(text: string) { + const row = this.page.locator(`tr:has-text("${text}")`); + await row.getByRole("button").nth(1).click(); + } + + async markLastNotificationAsUnRead() { + const row = this.page.locator("td:nth-child(3) > div").first(); + await row.getByRole("button").nth(1).click(); + } + + async viewRead() { + await this.page.getByLabel("View").click(); + await this.page + .getByRole("option", { name: "Read notifications", exact: true }) + .click(); + await expect( + this.page.getByTestId("loading-indicator").getByRole("img"), + ).toHaveCount(0); + } + + async viewUnRead() { + await this.page.getByLabel("View").click(); + await this.page + .getByRole("option", { name: "Unread notifications", exact: true }) + .click(); + await expect( + this.page.getByTestId("loading-indicator").getByRole("img"), + ).toHaveCount(0); + } + + async sortByOldestOnTop() { + await this.page.getByLabel("Sort by").click(); + await this.page.getByRole("option", { name: "Oldest on top" }).click(); + await expect( + this.page.getByTestId("loading-indicator").getByRole("img"), + ).toHaveCount(0); + } + + async sortByNewestOnTop() { + await this.page.getByLabel("Sort by").click(); + await this.page.getByRole("option", { name: "Newest on top" }).click(); + await expect( + this.page.getByTestId("loading-indicator").getByRole("img"), + ).toHaveCount(0); + } +} diff --git a/yarn.lock b/yarn.lock index 17c9c05..ef22e16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,35 @@ __metadata: version: 6 cacheKey: 8 +"@backstage/catalog-model@npm:1.7.5": + version: 1.7.5 + resolution: "@backstage/catalog-model@npm:1.7.5" + dependencies: + "@backstage/errors": ^1.2.7 + "@backstage/types": ^1.2.1 + ajv: ^8.10.0 + lodash: ^4.17.21 + checksum: 3f8d646268f31020f61a28139906f706b58b6dcf4ea51c4f8475c3a4a4437855406e12dd8cdb0792a7807d491d3a93971f2eb382919667806063a21844396df2 + languageName: node + linkType: hard + +"@backstage/errors@npm:^1.2.7": + version: 1.2.7 + resolution: "@backstage/errors@npm:1.2.7" + dependencies: + "@backstage/types": ^1.2.1 + serialize-error: ^8.0.1 + checksum: e0b5be754e59a11330a580fc2973e441883b9022248ad77e06aa9e4e0705c83e43cb96cbf405336650c1724aa16cac2eaf5ffb64142654b599e46a4888ca353d + languageName: node + linkType: hard + +"@backstage/types@npm:^1.2.1": + version: 1.2.2 + resolution: "@backstage/types@npm:1.2.2" + checksum: 12aa576ab6d25db6709b12e25ae69887c4e886f71d5db527c0bbc019d761140fcbc3438b42a32328c4f255514dd23a776c3357f7c21965919ded058d453c67a2 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.7.0, @eslint-community/eslint-utils@npm:^4.8.0": version: 4.9.0 resolution: "@eslint-community/eslint-utils@npm:4.9.0" @@ -213,6 +242,54 @@ __metadata: languageName: node linkType: hard +"@otplib/core@npm:^12.0.1": + version: 12.0.1 + resolution: "@otplib/core@npm:12.0.1" + checksum: b3c34bc20b31bc3f49cc0dc3c0eb070491c0101e8c1efa83cec48ca94158bd736aaca8187df667fc0c4a239d4ac52076bc44084bee04a50c80c3630caf77affa + languageName: node + linkType: hard + +"@otplib/plugin-crypto@npm:^12.0.1": + version: 12.0.1 + resolution: "@otplib/plugin-crypto@npm:12.0.1" + dependencies: + "@otplib/core": ^12.0.1 + checksum: 6867c74ee8aca6c2db9670362cf51e44f3648602c39318bf537421242e33f0012a172acd43bbed9a21d706e535dc4c66aff965380673391e9fd74cf685b5b13a + languageName: node + linkType: hard + +"@otplib/plugin-thirty-two@npm:^12.0.1": + version: 12.0.1 + resolution: "@otplib/plugin-thirty-two@npm:12.0.1" + dependencies: + "@otplib/core": ^12.0.1 + thirty-two: ^1.0.2 + checksum: 920099e40d3e8c2941291c84c70064c2d86d0d1ed17230d650445d5463340e406bc413ddf2e40c374ddc4ee988ef1e3facacab9b5248b1ff361fd13df52bf88f + languageName: node + linkType: hard + +"@otplib/preset-default@npm:^12.0.1": + version: 12.0.1 + resolution: "@otplib/preset-default@npm:12.0.1" + dependencies: + "@otplib/core": ^12.0.1 + "@otplib/plugin-crypto": ^12.0.1 + "@otplib/plugin-thirty-two": ^12.0.1 + checksum: 8133231384f6277f77eb8e42ef83bc32a8b01059bef147d1c358d9e9bfd292e1c239f581fe008367a48489dd68952b7ac0948e6c41412fc06079da2c91b71d16 + languageName: node + linkType: hard + +"@otplib/preset-v11@npm:^12.0.1": + version: 12.0.1 + resolution: "@otplib/preset-v11@npm:12.0.1" + dependencies: + "@otplib/core": ^12.0.1 + "@otplib/plugin-crypto": ^12.0.1 + "@otplib/plugin-thirty-two": ^12.0.1 + checksum: 367cb09397e617c21ec748d54e920ab43f1c5dfba70cbfd88edf73aecca399cf0c09fefe32518f79c7ee8a06e7058d14b200da378cc7d46af3cac4e22a153e2f + languageName: node + linkType: hard + "@playwright/test@npm:^1.57.0": version: 1.57.0 resolution: "@playwright/test@npm:1.57.0" @@ -488,6 +565,18 @@ __metadata: languageName: node linkType: hard +"ajv@npm:^8.10.0": + version: 8.17.1 + resolution: "ajv@npm:8.17.1" + dependencies: + fast-deep-equal: ^3.1.3 + fast-uri: ^3.0.1 + json-schema-traverse: ^1.0.0 + require-from-string: ^2.0.2 + checksum: 1797bf242cfffbaf3b870d13565bd1716b73f214bb7ada9a497063aada210200da36e3ed40237285f3255acc4feeae91b1fb183625331bad27da95973f7253d9 + languageName: node + linkType: hard + "ansi-align@npm:^3.0.1": version: 3.0.1 resolution: "ansi-align@npm:3.0.1" @@ -1115,6 +1204,13 @@ __metadata: languageName: node linkType: hard +"fast-uri@npm:^3.0.1": + version: 3.1.0 + resolution: "fast-uri@npm:3.1.0" + checksum: daab0efd3548cc53d0db38ecc764d125773f8bd70c34552ff21abdc6530f26fa4cb1771f944222ca5e61a0a1a85d01a104848ff88c61736de445d97bd616ea7e + languageName: node + linkType: hard + "fdir@npm:^6.5.0": version: 6.5.0 resolution: "fdir@npm:6.5.0" @@ -1536,6 +1632,13 @@ __metadata: languageName: node linkType: hard +"json-schema-traverse@npm:^1.0.0": + version: 1.0.0 + resolution: "json-schema-traverse@npm:1.0.0" + checksum: 02f2f466cdb0362558b2f1fd5e15cce82ef55d60cd7f8fa828cf35ba74330f8d767fcae5c5c2adb7851fa811766c694b9405810879bc4e1ddd78a7c0e03658ad + languageName: node + linkType: hard + "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" @@ -1612,6 +1715,13 @@ __metadata: languageName: node linkType: hard +"lodash@npm:^4.17.21": + version: 4.17.21 + resolution: "lodash@npm:4.17.21" + checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7 + languageName: node + linkType: hard + "lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1": version: 11.2.4 resolution: "lru-cache@npm:11.2.4" @@ -1880,6 +1990,17 @@ __metadata: languageName: node linkType: hard +"otplib@npm:12.0.1": + version: 12.0.1 + resolution: "otplib@npm:12.0.1" + dependencies: + "@otplib/core": ^12.0.1 + "@otplib/preset-default": ^12.0.1 + "@otplib/preset-v11": ^12.0.1 + checksum: 4a1b91cf1b8e920b50ad4bac2ef2a89126630c62daf68e9b32ff15106b2551db905d3b979955cf5f8f114da0a8883cec3d636901d65e793c1745bb4174e2a572 + languageName: node + linkType: hard + "p-limit@npm:^3.0.2": version: 3.1.0 resolution: "p-limit@npm:3.1.0" @@ -2026,6 +2147,13 @@ __metadata: languageName: node linkType: hard +"require-from-string@npm:^2.0.2": + version: 2.0.2 + resolution: "require-from-string@npm:2.0.2" + checksum: a03ef6895445f33a4015300c426699bc66b2b044ba7b670aa238610381b56d3f07c686251740d575e22f4c87531ba662d06937508f0f3c0f1ddc04db3130560b + languageName: node + linkType: hard + "resolve-from@npm:^4.0.0": version: 4.0.0 resolution: "resolve-from@npm:4.0.0" @@ -2051,6 +2179,7 @@ __metadata: version: 0.0.0-use.local resolution: "rhdh-e2e-test-utils@workspace:." dependencies: + "@backstage/catalog-model": 1.7.5 "@eslint/js": ^9.39.1 "@kubernetes/client-node": ^1.4.0 "@playwright/test": ^1.57.0 @@ -2065,6 +2194,7 @@ __metadata: fs-extra: ^11.3.2 js-yaml: ^4.1.1 lodash.mergewith: ^4.6.2 + otplib: 12.0.1 prettier: ^3.7.4 typescript: ^5.9.3 typescript-eslint: ^8.48.1 @@ -2090,6 +2220,15 @@ __metadata: languageName: node linkType: hard +"serialize-error@npm:^8.0.1": + version: 8.1.0 + resolution: "serialize-error@npm:8.1.0" + dependencies: + type-fest: ^0.20.2 + checksum: 2eef236d50edd2d7926e602c14fb500dc3a125ee52e9f08f67033181b8e0be5d1122498bdf7c23c80683cddcad083a27974e9e7111ce23165f4d3bcdd6d65102 + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -2267,6 +2406,13 @@ __metadata: languageName: node linkType: hard +"thirty-two@npm:^1.0.2": + version: 1.0.2 + resolution: "thirty-two@npm:1.0.2" + checksum: f6700b31d16ef942fdc0d14daed8a2f69ea8b60b0e85db8b83adf58d84bbeafe95a17d343ab55efaae571bb5148b62fc0ee12b04781323bf7af7d7e9693eec76 + languageName: node + linkType: hard + "tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15": version: 0.2.15 resolution: "tinyglobby@npm:0.2.15" @@ -2311,6 +2457,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.20.2": + version: 0.20.2 + resolution: "type-fest@npm:0.20.2" + checksum: 4fb3272df21ad1c552486f8a2f8e115c09a521ad7a8db3d56d53718d0c907b62c6e9141ba5f584af3f6830d0872c521357e512381f24f7c44acae583ad517d73 + languageName: node + linkType: hard + "type-fest@npm:^4.21.0": version: 4.41.0 resolution: "type-fest@npm:4.41.0" From ff54db61e24c5b7f43083ea5730a7b3a44292cd0 Mon Sep 17 00:00:00 2001 From: Subhash Khileri Date: Fri, 12 Dec 2025 14:06:00 +0530 Subject: [PATCH 2/2] Add helpers and pages and objects --- package.json | 1 + src/playwright/helpers/accessibility.ts | 48 ++++++ yarn.lock | 186 +++++++++++++----------- 3 files changed, 152 insertions(+), 83 deletions(-) create mode 100644 src/playwright/helpers/accessibility.ts diff --git a/package.json b/package.json index 29faf88..d00bfab 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@types/node": "^24.10.1" }, "dependencies": { + "@axe-core/playwright": "^4.11.0", "@eslint/js": "^9.39.1", "@kubernetes/client-node": "^1.4.0", "boxen": "^8.0.1", diff --git a/src/playwright/helpers/accessibility.ts b/src/playwright/helpers/accessibility.ts new file mode 100644 index 0000000..d62f380 --- /dev/null +++ b/src/playwright/helpers/accessibility.ts @@ -0,0 +1,48 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; + +export interface AccessibilityTestOptions { + /** Custom name for the attached results file. Defaults to "accessibility-scan-results.violations.json" */ + attachName?: string; + /** Whether to assert that there are no violations. Defaults to true */ + assertNoViolations?: boolean; + /** WCAG tags to test against. Defaults to ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] */ + wcagTags?: string[]; + /** Rules to disable during the scan. Defaults to ["color-contrast"] */ + disabledRules?: string[]; +} + +const DEFAULT_OPTIONS: Required = { + attachName: "accessibility-scan-results.violations.json", + assertNoViolations: true, + wcagTags: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"], + disabledRules: ["color-contrast"], +}; + +export async function runAccessibilityTests( + page: Page, + options: AccessibilityTestOptions = {}, +) { + const config = { ...DEFAULT_OPTIONS, ...options }; + const testInfo = test.info(); + + const accessibilityScanResults = await new AxeBuilder({ page }) + .withTags(config.wcagTags) + .disableRules(config.disabledRules) + .analyze(); + + await testInfo.attach(config.attachName, { + body: JSON.stringify(accessibilityScanResults.violations, null, 2), + contentType: "application/json", + }); + + if (config.assertNoViolations) { + expect( + accessibilityScanResults.violations, + `Found ${accessibilityScanResults.violations.length} accessibility violation(s)`, + ).toHaveLength(0); + } + + return accessibilityScanResults; +} diff --git a/yarn.lock b/yarn.lock index ef22e16..8017201 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,17 @@ __metadata: version: 6 cacheKey: 8 +"@axe-core/playwright@npm:^4.11.0": + version: 4.11.0 + resolution: "@axe-core/playwright@npm:4.11.0" + dependencies: + axe-core: ~4.11.0 + peerDependencies: + playwright-core: ">= 1.0.0" + checksum: 036d13cb73f9c3bdcff039caa8a3f4ae9c2fffeb41855053dd78f72d98d1635b0d7eec38ff5087beaa5e15c99e060a771d1b449f08df58694f02781d54aa155c + languageName: node + linkType: hard + "@backstage/catalog-model@npm:1.7.5": version: 1.7.5 resolution: "@backstage/catalog-model@npm:1.7.5" @@ -367,12 +378,21 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:^24.0.0, @types/node@npm:^24.10.1": - version: 24.10.1 - resolution: "@types/node@npm:24.10.1" +"@types/node@npm:*": + version: 25.0.1 + resolution: "@types/node@npm:25.0.1" dependencies: undici-types: ~7.16.0 - checksum: c2f370ae7a97c04991e0eee6b57e588a2abef0814a5f6e41fda5a9200cf02ae6654fad51c8372ee203ae4134bab80f5bf441c586f7f50e0fda3ba422f35eb3c0 + checksum: f9dc7f9b083c1044b5f687db4e577417b670701625d2b6743e6be212d73fda2660073226339bdf55dee519256dafa91116f3d29deb59f9341147640c76465d5f + languageName: node + linkType: hard + +"@types/node@npm:^24.0.0, @types/node@npm:^24.10.1": + version: 24.10.3 + resolution: "@types/node@npm:24.10.3" + dependencies: + undici-types: ~7.16.0 + checksum: 823345eafe5d38a98389a76481bdcfe277286b6fdb4c82c12dc549822f11159956061b75caa4d689e9164c641068a44c1237b190f42da7ed40f62af046e67852 languageName: node linkType: hard @@ -385,106 +405,105 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.48.1": - version: 8.48.1 - resolution: "@typescript-eslint/eslint-plugin@npm:8.48.1" +"@typescript-eslint/eslint-plugin@npm:8.49.0": + version: 8.49.0 + resolution: "@typescript-eslint/eslint-plugin@npm:8.49.0" dependencies: "@eslint-community/regexpp": ^4.10.0 - "@typescript-eslint/scope-manager": 8.48.1 - "@typescript-eslint/type-utils": 8.48.1 - "@typescript-eslint/utils": 8.48.1 - "@typescript-eslint/visitor-keys": 8.48.1 - graphemer: ^1.4.0 + "@typescript-eslint/scope-manager": 8.49.0 + "@typescript-eslint/type-utils": 8.49.0 + "@typescript-eslint/utils": 8.49.0 + "@typescript-eslint/visitor-keys": 8.49.0 ignore: ^7.0.0 natural-compare: ^1.4.0 ts-api-utils: ^2.1.0 peerDependencies: - "@typescript-eslint/parser": ^8.48.1 + "@typescript-eslint/parser": ^8.49.0 eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: cf5c0629396d315d7792a61913fa4f3e5580d2873ce3d4f8eb377e6ca57815a83bd8b38cab8e297028ee9246be14979d16423228cbbf8fb61472d6f6a664b13d + checksum: 0bae18dda8e8c86d8da311c382642e4e321e708ca7bad1ae86e43981b1679e99e7d9bd4e32d4874e8016cbe2e39f5a255a71f16cc2c64ec3471b23161e51afec languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.48.1": - version: 8.48.1 - resolution: "@typescript-eslint/parser@npm:8.48.1" +"@typescript-eslint/parser@npm:8.49.0": + version: 8.49.0 + resolution: "@typescript-eslint/parser@npm:8.49.0" dependencies: - "@typescript-eslint/scope-manager": 8.48.1 - "@typescript-eslint/types": 8.48.1 - "@typescript-eslint/typescript-estree": 8.48.1 - "@typescript-eslint/visitor-keys": 8.48.1 + "@typescript-eslint/scope-manager": 8.49.0 + "@typescript-eslint/types": 8.49.0 + "@typescript-eslint/typescript-estree": 8.49.0 + "@typescript-eslint/visitor-keys": 8.49.0 debug: ^4.3.4 peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: ba5734b59334fdfa178d3397e9931cfc01bf0b14a4b9935ef81072aef315c03d301a128eb1530f15a1f4c6cb83b4083cb36ab96e6f77fe6a589dac058d41e86e + checksum: 27a157372fec09d72b9d3b266ca18cc6d4db040df6d507c5c9d30f97375e0be373d5fde9d02bcd997e40f21738edcc7a2e51d5a56e3cdd600147637bc96d920b languageName: node linkType: hard -"@typescript-eslint/project-service@npm:8.48.1": - version: 8.48.1 - resolution: "@typescript-eslint/project-service@npm:8.48.1" +"@typescript-eslint/project-service@npm:8.49.0": + version: 8.49.0 + resolution: "@typescript-eslint/project-service@npm:8.49.0" dependencies: - "@typescript-eslint/tsconfig-utils": ^8.48.1 - "@typescript-eslint/types": ^8.48.1 + "@typescript-eslint/tsconfig-utils": ^8.49.0 + "@typescript-eslint/types": ^8.49.0 debug: ^4.3.4 peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: fd99c025e7223217c558a211ec8e0b6b4097b70836f51481e1e55e659eb88be76113db8239ac3626f96b384d4261d487ad6743c5cf8d5949ebf1bab072fd6055 + checksum: 378cd7e6982820aa0bb1dfe78a8cf133dc8192ad68b4e2a3ed1615a1a1b4542a1a20da08de6f5dee2a5804192aeceabe06e6c16a0453a8aaa43e495527e6af6a languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.48.1": - version: 8.48.1 - resolution: "@typescript-eslint/scope-manager@npm:8.48.1" +"@typescript-eslint/scope-manager@npm:8.49.0": + version: 8.49.0 + resolution: "@typescript-eslint/scope-manager@npm:8.49.0" dependencies: - "@typescript-eslint/types": 8.48.1 - "@typescript-eslint/visitor-keys": 8.48.1 - checksum: 62a52b83e4ea387c3d651f92e439678ec823fd1a4fe859b936b626d13e7c20960557d1dd5410d78542cc539f4588897b2e0a337051d97808cbe59b4fd51d44ea + "@typescript-eslint/types": 8.49.0 + "@typescript-eslint/visitor-keys": 8.49.0 + checksum: 85aae146729547df03a2ffdb4e447a10023e7c71b426a2a5d7eb3b2a82ec1bbd8ba214d619363994c500a4cf742fbb3f3743723aa13784649e0b9e909ab4529f languageName: node linkType: hard -"@typescript-eslint/tsconfig-utils@npm:8.48.1, @typescript-eslint/tsconfig-utils@npm:^8.48.1": - version: 8.48.1 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.48.1" +"@typescript-eslint/tsconfig-utils@npm:8.49.0, @typescript-eslint/tsconfig-utils@npm:^8.49.0": + version: 8.49.0 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.49.0" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 5c24e2dbe0f3771701f34a6614ca24bc4551e10c9d1426da66048477a00b2b017b47bc301e8d6b7c0eb0d27d6b8a073b137a31d22553015fdf03c61c1cc865e4 + checksum: be26283df8cf05a3a8d17596ac52e51ec27017f27ec5588e2fa3b804c31758864732a24e1ab777ac3e3567dda9b55de5b18d318b6a6e56025baa4f117f371804 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.48.1": - version: 8.48.1 - resolution: "@typescript-eslint/type-utils@npm:8.48.1" +"@typescript-eslint/type-utils@npm:8.49.0": + version: 8.49.0 + resolution: "@typescript-eslint/type-utils@npm:8.49.0" dependencies: - "@typescript-eslint/types": 8.48.1 - "@typescript-eslint/typescript-estree": 8.48.1 - "@typescript-eslint/utils": 8.48.1 + "@typescript-eslint/types": 8.49.0 + "@typescript-eslint/typescript-estree": 8.49.0 + "@typescript-eslint/utils": 8.49.0 debug: ^4.3.4 ts-api-utils: ^2.1.0 peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: d9bb428512c5b892fcce63eefae45e5982ab10a935cbc0eb2163ca5127634bf9ba47715b4a1c0735a067f2973e80cc363612e9fdfdcd5cae31262cd539ec8d02 + checksum: ce5795464be57b0a1cf5970103547a148e8971fe7cf1aafb9a62b40251c670fd1b03535edfc4622c520112705cd6ee5efd88124a7432d2fbbcfc5be54fbf131f languageName: node linkType: hard -"@typescript-eslint/types@npm:8.48.1, @typescript-eslint/types@npm:^8.48.1": - version: 8.48.1 - resolution: "@typescript-eslint/types@npm:8.48.1" - checksum: 19e5f902bd1e0a51f43faef6ea0a2b88292283e8eee58237657876b8ad908d66645ac50fc37a0967e4f1f2799b11cec47d03c977b059b8542dcb12e16b7a9354 +"@typescript-eslint/types@npm:8.49.0, @typescript-eslint/types@npm:^8.49.0": + version: 8.49.0 + resolution: "@typescript-eslint/types@npm:8.49.0" + checksum: e604e27f9ff7dd4c7ae0060db5f506338b64cc302563841e729f4da7730a1e94176db8ae1f1c4c0c0c8df5086f127408dc050f27595a36d412f60ed0e09f5a64 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.48.1": - version: 8.48.1 - resolution: "@typescript-eslint/typescript-estree@npm:8.48.1" +"@typescript-eslint/typescript-estree@npm:8.49.0": + version: 8.49.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.49.0" dependencies: - "@typescript-eslint/project-service": 8.48.1 - "@typescript-eslint/tsconfig-utils": 8.48.1 - "@typescript-eslint/types": 8.48.1 - "@typescript-eslint/visitor-keys": 8.48.1 + "@typescript-eslint/project-service": 8.49.0 + "@typescript-eslint/tsconfig-utils": 8.49.0 + "@typescript-eslint/types": 8.49.0 + "@typescript-eslint/visitor-keys": 8.49.0 debug: ^4.3.4 minimatch: ^9.0.4 semver: ^7.6.0 @@ -492,32 +511,32 @@ __metadata: ts-api-utils: ^2.1.0 peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 1ff0b1ad4a71d6f8e81e5ca8d65333e8e9b53499f1e2b5a0295cbda062eebce0dcb021b1aac9b31c74096d26429f4b2109414d39d4ca4b531ee31f2c9e7895ec + checksum: a03545eefdf2487172602930fdd27c8810dc775bdfa4d9c3a45651c5f5465c5e1fc652f318c61ece7f4f35425231961434e96d4ffca84f10149fca111e1fc520 languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.48.1": - version: 8.48.1 - resolution: "@typescript-eslint/utils@npm:8.48.1" +"@typescript-eslint/utils@npm:8.49.0": + version: 8.49.0 + resolution: "@typescript-eslint/utils@npm:8.49.0" dependencies: "@eslint-community/eslint-utils": ^4.7.0 - "@typescript-eslint/scope-manager": 8.48.1 - "@typescript-eslint/types": 8.48.1 - "@typescript-eslint/typescript-estree": 8.48.1 + "@typescript-eslint/scope-manager": 8.49.0 + "@typescript-eslint/types": 8.49.0 + "@typescript-eslint/typescript-estree": 8.49.0 peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 5fcf70d05a087c8c449c231b165d825101b832d48569ebde33c4efd3451f38ef084a46019e5c91fc7d1b34638cdd18f4564890132bb13495f6eed9420b949563 + checksum: be1bdf2e4a8bb56bb0c39ba8b8a5f1fc187fb17a53af0ef4d50be95914027076dfac385b54d969fdaa2a42fa8a95f31d105457a3768875054a5507ebe6f6257a languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.48.1": - version: 8.48.1 - resolution: "@typescript-eslint/visitor-keys@npm:8.48.1" +"@typescript-eslint/visitor-keys@npm:8.49.0": + version: 8.49.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.49.0" dependencies: - "@typescript-eslint/types": 8.48.1 + "@typescript-eslint/types": 8.49.0 eslint-visitor-keys: ^4.2.1 - checksum: b93cc791ee3a9d47f2b66a5047b3912db358f75a752a19431df78e0263f795105fc224d073e6450a99593770a64fba47e063ade6525bb785817131e06978fd15 + checksum: 446d6345d9702bcdf8713a47561ea52657bbec1c8170b1559d9462e1d815b122adff35f1cc778ecb94f4459d51ac7aac7cafe9ec8d8319b2c7d7984a0edee6ba languageName: node linkType: hard @@ -644,6 +663,13 @@ __metadata: languageName: node linkType: hard +"axe-core@npm:~4.11.0": + version: 4.11.0 + resolution: "axe-core@npm:4.11.0" + checksum: 57b0d7206d4dd63a1127b8ad6e173417857a3de43717cbb03561e75e2ff85765a228a7588fde4c828dbf179ef25f263ad98535656701e78def8f29a9524be866 + languageName: node + linkType: hard + "b4a@npm:^1.6.4": version: 1.7.3 resolution: "b4a@npm:1.7.3" @@ -1420,13 +1446,6 @@ __metadata: languageName: node linkType: hard -"graphemer@npm:^1.4.0": - version: 1.4.0 - resolution: "graphemer@npm:1.4.0" - checksum: bab8f0be9b568857c7bec9fda95a89f87b783546d02951c40c33f84d05bb7da3fd10f863a9beb901463669b6583173a8c8cc6d6b306ea2b9b9d5d3d943c3a673 - languageName: node - linkType: hard - "has-flag@npm:^4.0.0": version: 4.0.0 resolution: "has-flag@npm:4.0.0" @@ -2179,6 +2198,7 @@ __metadata: version: 0.0.0-use.local resolution: "rhdh-e2e-test-utils@workspace:." dependencies: + "@axe-core/playwright": ^4.11.0 "@backstage/catalog-model": 1.7.5 "@eslint/js": ^9.39.1 "@kubernetes/client-node": ^1.4.0 @@ -2472,17 +2492,17 @@ __metadata: linkType: hard "typescript-eslint@npm:^8.48.1": - version: 8.48.1 - resolution: "typescript-eslint@npm:8.48.1" + version: 8.49.0 + resolution: "typescript-eslint@npm:8.49.0" dependencies: - "@typescript-eslint/eslint-plugin": 8.48.1 - "@typescript-eslint/parser": 8.48.1 - "@typescript-eslint/typescript-estree": 8.48.1 - "@typescript-eslint/utils": 8.48.1 + "@typescript-eslint/eslint-plugin": 8.49.0 + "@typescript-eslint/parser": 8.49.0 + "@typescript-eslint/typescript-estree": 8.49.0 + "@typescript-eslint/utils": 8.49.0 peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 0de61459435fc7466d6f62e944268da31c5a477692f9cdc9583982c5a6d6efd38ac86ec9f94dd1c806cc30842a97b8d4bda0458bd3c1070c660dc68666f9012b + checksum: fd91cffcf3c5de73a9ead2253dcb8516ed664fc9179d26c019e6be53f4d4429e280dd5c783c68789a4a2db34712e569468a6c9c7613fc918a310687ca53b91b1 languageName: node linkType: hard