From 915e2989ed268cad75f34ebde024cc67ee423543 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Mon, 6 Jul 2026 06:55:08 -0400 Subject: [PATCH] feat(helpers): add BUI-aware waits and shared catalog/notification helpers - Extend WAIT_OBJECTS; add waitForAppReady() and fix openSidebar click - Add getSessionAuthToken, CatalogApiHelper, and RhdhNotificationsApi - Update NotificationPage with additive MUI + BUI selectors - Add unit tests and docs for all of the above - Bump version to 2.2.0 Assisted-By: Cursor Desktop rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED --- docs/.vitepress/config.ts | 18 +- docs/api/helpers/auth-api-helper.md | 25 ++ docs/api/helpers/catalog-api-helper.md | 85 ++++++ docs/api/helpers/notifications-api-helper.md | 82 ++++++ docs/api/helpers/ui-helper.md | 10 +- docs/api/index.md | 5 + docs/api/pages/notification-page.md | 32 ++- docs/changelog.md | 17 +- docs/guide/helpers/auth-api-helper.md | 28 ++ docs/guide/helpers/catalog-api-helper.md | 98 +++++++ docs/guide/helpers/index.md | 60 +++- .../guide/helpers/notifications-api-helper.md | 107 +++++++ docs/guide/helpers/ui-helper.md | 11 +- docs/guide/page-objects/notification-page.md | 127 +++++---- docs/overlay/reference/patterns.md | 78 +++++ docs/overlay/reference/troubleshooting.md | 13 +- package.json | 2 +- src/playwright/helpers/auth-token.ts | 50 ++++ .../helpers/catalog-api-helper.test.ts | 101 +++++++ src/playwright/helpers/catalog-api-helper.ts | 108 +++++++ src/playwright/helpers/index.ts | 11 + .../helpers/notifications-api-helper.ts | 69 +++++ src/playwright/helpers/ui-helper.ts | 7 +- .../page-objects/global-obj.test.ts | 16 ++ src/playwright/page-objects/global-obj.ts | 4 + src/playwright/pages/notifications.ts | 268 +++++++++++++----- 26 files changed, 1273 insertions(+), 159 deletions(-) create mode 100644 docs/api/helpers/catalog-api-helper.md create mode 100644 docs/api/helpers/notifications-api-helper.md create mode 100644 docs/guide/helpers/catalog-api-helper.md create mode 100644 docs/guide/helpers/notifications-api-helper.md create mode 100644 src/playwright/helpers/auth-token.ts create mode 100644 src/playwright/helpers/catalog-api-helper.test.ts create mode 100644 src/playwright/helpers/catalog-api-helper.ts create mode 100644 src/playwright/helpers/notifications-api-helper.ts create mode 100644 src/playwright/page-objects/global-obj.test.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 711d29e..ebdd03e 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -33,7 +33,7 @@ export default defineConfig({ { text: "Examples", link: "/examples/" }, { text: "Overlay Testing", link: "/overlay/" }, { - text: "v1.1.39", + text: "v2.2.0", items: [{ text: "Changelog", link: "/changelog" }], }, ], @@ -121,6 +121,14 @@ export default defineConfig({ { text: "LoginHelper", link: "/guide/helpers/login-helper" }, { text: "APIHelper", link: "/guide/helpers/api-helper" }, { text: "AuthApiHelper", link: "/guide/helpers/auth-api-helper" }, + { + text: "CatalogApiHelper", + link: "/guide/helpers/catalog-api-helper", + }, + { + text: "RhdhNotificationsApi", + link: "/guide/helpers/notifications-api-helper", + }, { text: "RbacApiHelper", link: "/guide/helpers/rbac-api-helper" }, ], }, @@ -227,6 +235,14 @@ export default defineConfig({ { text: "LoginHelper", link: "/api/helpers/login-helper" }, { text: "APIHelper", link: "/api/helpers/api-helper" }, { text: "AuthApiHelper", link: "/api/helpers/auth-api-helper" }, + { + text: "CatalogApiHelper", + link: "/api/helpers/catalog-api-helper", + }, + { + text: "RhdhNotificationsApi", + link: "/api/helpers/notifications-api-helper", + }, { text: "RbacApiHelper", link: "/api/helpers/rbac-api-helper" }, ], }, diff --git a/docs/api/helpers/auth-api-helper.md b/docs/api/helpers/auth-api-helper.md index 9a77bfa..0f4299a 100644 --- a/docs/api/helpers/auth-api-helper.md +++ b/docs/api/helpers/auth-api-helper.md @@ -51,3 +51,28 @@ const token = await authApiHelper.getToken(); // Custom provider const token = await authApiHelper.getToken('github'); ``` + +## `getSessionAuthToken()` + +```typescript +async function getSessionAuthToken( + page: Page, + uiHelper: UIhelper, + baseUrl: string, +): Promise +``` + +Polls `AuthApiHelper.getToken()` until a non-empty token is returned. Navigates to `baseUrl` and calls `uiHelper.waitForAppReady()` when the first attempt fails. + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `page` | `Page` | Authenticated Playwright page | +| `uiHelper` | `UIhelper` | Used for `waitForAppReady()` after navigation | +| `baseUrl` | `string` | RHDH base URL (e.g. `process.env.RHDH_BASE_URL`) | + +**Returns** `Promise` — Backstage identity bearer token. + +## Related Pages + +- [AuthApiHelper Guide](/guide/helpers/auth-api-helper) — usage guide +- [CatalogApiHelper](/api/helpers/catalog-api-helper) — typical consumer of session tokens diff --git a/docs/api/helpers/catalog-api-helper.md b/docs/api/helpers/catalog-api-helper.md new file mode 100644 index 0000000..59e9950 --- /dev/null +++ b/docs/api/helpers/catalog-api-helper.md @@ -0,0 +1,85 @@ +# CatalogApiHelper API + +Static helper for RHDH catalog entity lookups via the REST API. + +## Import + +```typescript +import { CatalogApiHelper } from "@red-hat-developer-hub/e2e-test-utils/helpers"; +``` + +## Methods + +### `entityExists()` + +```typescript +static async entityExists( + baseUrl: string, + token: string, + kind: string, + name: string, + namespace?: string, +): Promise +``` + +Returns `true` if the entity exists. Returns `false` on HTTP 404. Rethrows other HTTP errors. + +### `getEntity()` + +```typescript +static async getEntity( + baseUrl: string, + token: string, + kind: string, + name: string, + namespace?: string, +): Promise +``` + +Fetches `GET /api/catalog/entities/by-name/{kind}/{namespace}/{name}`. + +### `getEntityDescription()` + +```typescript +static async getEntityDescription( + baseUrl: string, + token: string, + kind: string, + name: string, + namespace?: string, +): Promise +``` + +Returns `metadata.description` from the entity JSON. + +### `getGroupEntity()` + +```typescript +static async getGroupEntity( + baseUrl: string, + token: string, + groupName: string, +): Promise +``` + +### `getGroupMembers()` + +```typescript +static async getGroupMembers( + baseUrl: string, + token: string, + groupName: string, +): Promise +``` + +### `dispose()` + +```typescript +static async dispose(): Promise +``` + +Disposes the internal Playwright `APIRequestContext`. + +## Related Pages + +- [CatalogApiHelper Guide](/guide/helpers/catalog-api-helper) — usage guide and polling patterns diff --git a/docs/api/helpers/notifications-api-helper.md b/docs/api/helpers/notifications-api-helper.md new file mode 100644 index 0000000..7fb8c6b --- /dev/null +++ b/docs/api/helpers/notifications-api-helper.md @@ -0,0 +1,82 @@ +# RhdhNotificationsApi API + +Typed REST helper for the RHDH notifications API. + +## Import + +```typescript +import { + RhdhNotificationsApi, + type NotificationRequest, + type NotificationPayload, + type NotificationRecipients, + type NotificationSeverity, +} from "@red-hat-developer-hub/e2e-test-utils/helpers"; +``` + +## Factory + +### `build()` + +```typescript +static async build(token: string): Promise +``` + +Creates an API client using `${RHDH_BASE_URL}/api/` as the base URL. + +## Methods + +### `createNotification()` + +```typescript +async createNotification( + notification: NotificationRequest, +): Promise +``` + +`POST /api/notifications` + +### `markAllNotificationsAsRead()` + +```typescript +async markAllNotificationsAsRead(): Promise +``` + +`PATCH /api/notifications` with `{ ids: [], read: true }`. + +## Types + +### `NotificationSeverity` + +```typescript +type NotificationSeverity = "critical" | "high" | "normal" | "low"; +``` + +### `NotificationRequest` + +```typescript +interface NotificationRequest { + recipients: NotificationRecipients; + payload: NotificationPayload; +} +``` + +### `BroadcastRecipients` + +```typescript +type BroadcastRecipients = { type: "broadcast" }; +``` + +### `EntityRecipients` + +```typescript +type EntityRecipients = { + type: "entity"; + entityRef: string | string[]; + excludeEntityRef?: string | string[]; +}; +``` + +## Related Pages + +- [RhdhNotificationsApi Guide](/guide/helpers/notifications-api-helper) — usage examples diff --git a/docs/api/helpers/ui-helper.md b/docs/api/helpers/ui-helper.md index 53d8efb..15eb6ce 100644 --- a/docs/api/helpers/ui-helper.md +++ b/docs/api/helpers/ui-helper.md @@ -1,6 +1,6 @@ # UIhelper API -UI interaction helper for Material-UI components. +UI interaction helper for RHDH components (MUI and BUI). ## Import @@ -22,7 +22,13 @@ new UIhelper(page: Page) ```typescript async waitForLoad(timeout?: number): Promise ``` -Wait for page to fully load. +Wait for MUI and BUI loading indicators to clear. Default timeout: `120000` ms. + +#### `waitForAppReady()` +```typescript +async waitForAppReady(timeout?: number): Promise +``` +Alias for `waitForLoad()`. #### `dismissQuickstartIfVisible()` ```typescript diff --git a/docs/api/index.md b/docs/api/index.md index ff07dd9..70de4de 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -35,6 +35,11 @@ Complete API documentation for all exports from `@red-hat-developer-hub/e2e-test - [UIhelper](/api/helpers/ui-helper) - UI interaction methods - [LoginHelper](/api/helpers/login-helper) - Authentication methods - [APIHelper](/api/helpers/api-helper) - API interaction methods +- [AuthApiHelper](/api/helpers/auth-api-helper) - Backstage identity tokens +- [`getSessionAuthToken`](/api/helpers/auth-api-helper#getsessionauthtoken) - Resilient token retrieval +- [CatalogApiHelper](/api/helpers/catalog-api-helper) - Catalog REST polling +- [RhdhNotificationsApi](/api/helpers/notifications-api-helper) - Notifications REST API +- [RbacApiHelper](/api/helpers/rbac-api-helper) - RBAC policy management ### Page Objects diff --git a/docs/api/pages/notification-page.md b/docs/api/pages/notification-page.md index 88ba666..6e17eac 100644 --- a/docs/api/pages/notification-page.md +++ b/docs/api/pages/notification-page.md @@ -11,26 +11,29 @@ import { NotificationPage } from "@red-hat-developer-hub/e2e-test-utils/pages"; ## Constructor ```typescript -new NotificationPage(page: Page) +new NotificationPage(page: Page, uiHelper?: UIhelper) ``` -Creates a new NotificationPage instance with an internal `UIhelper`. +Creates a new NotificationPage instance. When `uiHelper` is omitted, an internal `UIhelper` is created. | Parameter | Type | Description | |-----------|------|-------------| | `page` | `Page` | Playwright Page object | +| `uiHelper` | `UIhelper` | Optional fixture UIhelper for shared wait/navigation behavior | + +Supports both MUI (legacy) and BUI (new frontend system) selectors. ## Methods ### Navigation -#### `clickNotificationsNavBarItem()` +#### `navigateToNotifications()` ```typescript -async clickNotificationsNavBarItem(): Promise +async navigateToNotifications(): Promise ``` -Navigate to the notifications page via the sidebar and wait for it to load. +Navigate to the notifications page via the sidebar (with `/notifications` fallback), dismiss toasts, and wait until the page is ready. ### Notification Selection @@ -42,17 +45,20 @@ async selectAllNotifications(): Promise Select all notifications using the header checkbox. -#### `selectNotification(nth?)` +#### `selectNotification(textOrNth?)` ```typescript -async selectNotification(nth?: number): Promise +async selectNotification( + textOrNth?: string | RegExp | number, +): Promise ``` -Select a specific notification by index. +Select a notification by row title or by checkbox index. -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `nth` | `number` | `1` | Zero-based index of the notification to select | +| Parameter | Type | Description | +|-----------|------|-------------| +| `textOrNth` | `string \| RegExp` | Row title to select (preferred) | +| `textOrNth` | `number` | Zero-based checkbox index (legacy) | ### Notification Content @@ -208,7 +214,7 @@ test("manage notifications", async ({ page }) => { const notificationPage = new NotificationPage(page); // Navigate to notifications - await notificationPage.clickNotificationsNavBarItem(); + await notificationPage.navigateToNotifications(); // Check for a specific notification await notificationPage.notificationContains("Build completed successfully"); @@ -234,7 +240,7 @@ test("manage notifications", async ({ page }) => { test("clear all notifications", async ({ page }) => { const notificationPage = new NotificationPage(page); - await notificationPage.clickNotificationsNavBarItem(); + await notificationPage.navigateToNotifications(); await notificationPage.markAllNotificationsAsRead(); }); ``` diff --git a/docs/changelog.md b/docs/changelog.md index 9b727a8..a708729 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,7 +2,22 @@ All notable changes to this project will be documented in this file. -## [2.1.1] - Current +## [2.2.0] - Current + +### Added + +- **`UIhelper.waitForAppReady()`**: Waits for MUI and BUI loading indicators to clear (extends `WAIT_OBJECTS` with BUI progress/spinner selectors). +- **`getSessionAuthToken()`**: Retrieves a bearer token from the logged-in browser session without unnecessary navigation. +- **`CatalogApiHelper`**: Static catalog API helper (`entityExists`, `getEntity`, `getEntityDescription`, `getGroupMembers`) for event-driven discovery polling. +- **`RhdhNotificationsApi`**: Typed notifications REST helper with valid broadcast/entity recipient shapes and lowercase severity values. + +### Changed + +- **`NotificationPage`**: Additive MUI + BUI support — tries legacy selectors (`loading-indicator`, `Severity`, `getByTitle`) and BUI selectors (`Min severity`, `aria-label` bulk actions, row checkboxes). Optional `uiHelper` constructor argument. `selectNotification` accepts a row title (`string`/`RegExp`) or legacy checkbox index (`number`). Renamed `clickNotificationsNavBarItem()` to `navigateToNotifications()`. +- **`CatalogApiHelper.getGroupEntity()`**: Delegates to `getEntity()` instead of duplicating fetch logic. +- **`UIhelper.openSidebar`**: Uses a real `.click()` instead of `dispatchEvent("click")` for more reliable navigation. + +## [2.1.1] ### Changed diff --git a/docs/guide/helpers/auth-api-helper.md b/docs/guide/helpers/auth-api-helper.md index e7b6aa0..095999b 100644 --- a/docs/guide/helpers/auth-api-helper.md +++ b/docs/guide/helpers/auth-api-helper.md @@ -75,7 +75,35 @@ try { ## Related Pages +- [getSessionAuthToken](#getsessionauthtoken) — resilient token retrieval after login - [RbacApiHelper](/guide/helpers/rbac-api-helper) — uses tokens obtained from `AuthApiHelper` - [LoginHelper](/guide/helpers/login-helper) — authenticates the browser session before calling `getToken` +- [CatalogApiHelper](/guide/helpers/catalog-api-helper) — catalog REST queries with a bearer token - [APIHelper](/guide/helpers/api-helper) — catalog and GitHub API operations - [Common Patterns](/overlay/reference/patterns#fetching-a-token-to-use-with-rbacapihelper) — example of using `getToken` with `RbacApiHelper` + +## `getSessionAuthToken(page, uiHelper, baseUrl)` + +Standalone function that retrieves a Backstage bearer token from the logged-in browser session. Unlike calling `AuthApiHelper.getToken()` directly, it polls until a token is available and navigates to `baseUrl` only when the first attempt fails. + +```typescript +import { + AuthApiHelper, + getSessionAuthToken, +} from "@red-hat-developer-hub/e2e-test-utils/helpers"; + +test.beforeEach(async ({ page, uiHelper, loginHelper }) => { + await loginHelper.loginAsKeycloakUser(); +}); + +test("catalog API", async ({ page, uiHelper }) => { + const token = await getSessionAuthToken( + page, + uiHelper, + process.env.RHDH_BASE_URL!, + ); + // use token with CatalogApiHelper, RbacApiHelper, etc. +}); +``` + +Use this in discovery tests that need a stable token after Keycloak login without duplicating retry logic in each workspace. diff --git a/docs/guide/helpers/catalog-api-helper.md b/docs/guide/helpers/catalog-api-helper.md new file mode 100644 index 0000000..ea03878 --- /dev/null +++ b/docs/guide/helpers/catalog-api-helper.md @@ -0,0 +1,98 @@ +# CatalogApiHelper + +The `CatalogApiHelper` class provides static methods for querying the RHDH catalog REST API. It is designed for event-driven discovery tests that poll until entities appear, update, or are removed — without reloading the UI on every attempt. + +## Importing + +```typescript +import { CatalogApiHelper } from "@red-hat-developer-hub/e2e-test-utils/helpers"; +``` + +## Prerequisites + +- `RHDH_BASE_URL` or an explicit `baseUrl` argument pointing at the deployed instance +- A Backstage bearer token from [`getSessionAuthToken()`](/guide/helpers/auth-api-helper#getsessionauthtoken) or [`AuthApiHelper.getToken()`](/guide/helpers/auth-api-helper) + +## Methods + +### `entityExists(baseUrl, token, kind, name, namespace?)` + +Returns `true` when the entity exists, `false` on HTTP 404, and rethrows other errors. + +```typescript +const exists = await CatalogApiHelper.entityExists( + rhdhBaseUrl, + token, + "component", + "my-service", +); +``` + +### `getEntity(baseUrl, token, kind, name, namespace?)` + +Fetches the full catalog entity JSON. + +### `getEntityDescription(baseUrl, token, kind, name, namespace?)` + +Reads `metadata.description` from a catalog entity. Useful for verifying webhook-driven updates. + +### `getGroupMembers(baseUrl, token, groupName)` + +Returns member usernames from a `Group` entity's `hasMember` relations. + +### `dispose()` + +Disposes the shared Playwright request context. Call in `afterAll` when tests finish. + +## Polling Example + +```typescript +import { test, expect } from "@red-hat-developer-hub/e2e-test-utils/test"; +import { + CatalogApiHelper, + getSessionAuthToken, +} from "@red-hat-developer-hub/e2e-test-utils/helpers"; + +test.describe("Catalog ingest", () => { + let catalogToken: string; + + test.beforeEach(async ({ page, uiHelper, loginHelper }) => { + await loginHelper.loginAsKeycloakUser(); + if (!catalogToken) { + catalogToken = await getSessionAuthToken( + page, + uiHelper, + process.env.RHDH_BASE_URL!, + ); + } + }); + + test.afterAll(async () => { + await CatalogApiHelper.dispose(); + }); + + test("entity appears after webhook", async () => { + // ... trigger external event ... + + await expect + .poll( + () => + CatalogApiHelper.entityExists( + process.env.RHDH_BASE_URL!, + catalogToken, + "component", + "my-service", + ), + { timeout: 120_000, intervals: [5_000] }, + ) + .toBe(true); + }); +}); +``` + +## Related Pages + +- [getSessionAuthToken](/guide/helpers/auth-api-helper#getsessionauthtoken) — obtain a bearer token after login +- [AuthApiHelper](/guide/helpers/auth-api-helper) — lower-level token retrieval +- [APIHelper](/guide/helpers/api-helper) — GitHub and legacy catalog instance methods +- [Common Patterns](/overlay/reference/patterns#catalog-event-polling) — overlay polling examples diff --git a/docs/guide/helpers/index.md b/docs/guide/helpers/index.md index 56fdd55..bd3c06e 100644 --- a/docs/guide/helpers/index.md +++ b/docs/guide/helpers/index.md @@ -37,6 +37,7 @@ Provides methods for interacting with Material-UI components in RHDH: ```typescript // Wait and verify await uiHelper.waitForLoad(); +await uiHelper.waitForAppReady(); await uiHelper.verifyHeading("Catalog"); await uiHelper.verifyText("Welcome to RHDH"); @@ -116,6 +117,58 @@ const token = await authApiHelper.getToken("github"); [Learn more about AuthApiHelper →](/guide/helpers/auth-api-helper) +## CatalogApiHelper + +Static catalog REST helper for event-driven discovery tests: + +```typescript +import { + CatalogApiHelper, + getSessionAuthToken, +} from "@red-hat-developer-hub/e2e-test-utils/helpers"; + +const token = await getSessionAuthToken(page, uiHelper, process.env.RHDH_BASE_URL!); + +const exists = await CatalogApiHelper.entityExists( + process.env.RHDH_BASE_URL!, + token, + "component", + "my-service", +); + +const description = await CatalogApiHelper.getEntityDescription( + process.env.RHDH_BASE_URL!, + token, + "component", + "my-service", +); + +await CatalogApiHelper.dispose(); +``` + +[Learn more about CatalogApiHelper →](/guide/helpers/catalog-api-helper) + +## RhdhNotificationsApi + +Typed REST helper for creating and updating notifications: + +```typescript +import { RhdhNotificationsApi } from "@red-hat-developer-hub/e2e-test-utils/helpers"; + +const api = await RhdhNotificationsApi.build("test-token"); +await api.createNotification({ + recipients: { type: "broadcast" }, + payload: { + title: "Test", + description: "From E2E", + severity: "normal", + topic: "e2e", + }, +}); +``` + +[Learn more about RhdhNotificationsApi →](/guide/helpers/notifications-api-helper) + ## RbacApiHelper Manages RBAC roles and policies via the RHDH Permission API. Built with an async factory method that requires a Backstage identity token: @@ -173,10 +226,13 @@ test("first test", async () => { | Scenario | Helper | |----------|--------| | Click buttons, verify text | UIhelper | +| Wait for MUI/BUI loading to finish | UIhelper (`waitForAppReady`) | | Login/logout operations | LoginHelper | | Create GitHub repos | APIHelper | -| Query Backstage catalog | APIHelper | +| Query Backstage catalog (instance API) | APIHelper | +| Poll catalog entities by REST | CatalogApiHelper | +| Create notifications via API | RhdhNotificationsApi | | Interact with tables | UIhelper | | Fill forms | UIhelper | -| Retrieve a Backstage identity token | AuthApiHelper | +| Retrieve a Backstage identity token | AuthApiHelper or `getSessionAuthToken` | | Clean up RBAC roles/policies after tests | RbacApiHelper | diff --git a/docs/guide/helpers/notifications-api-helper.md b/docs/guide/helpers/notifications-api-helper.md new file mode 100644 index 0000000..71716c7 --- /dev/null +++ b/docs/guide/helpers/notifications-api-helper.md @@ -0,0 +1,107 @@ +# RhdhNotificationsApi + +The `RhdhNotificationsApi` class creates and updates notifications via the RHDH notifications REST API. It uses typed payloads that match the backend OpenAPI schema (lowercase severity values, valid broadcast/entity recipient shapes). + +## Importing + +```typescript +import { + RhdhNotificationsApi, + type NotificationRequest, + type NotificationSeverity, +} from "@red-hat-developer-hub/e2e-test-utils/helpers"; +``` + +## Setup + +`RHDH_BASE_URL` must be set. The helper uses `test-token` or any bearer token your deployment accepts for API calls. + +```typescript +const notificationsApi = await RhdhNotificationsApi.build("test-token"); +``` + +## Creating Notifications + +### Broadcast notification + +```typescript +const response = await notificationsApi.createNotification({ + recipients: { type: "broadcast" }, + payload: { + title: "UI test notification", + description: "Created from E2E test", + severity: "normal", + topic: "e2e-test", + }, +}); + +expect(response.ok()).toBeTruthy(); +``` + +### Entity-targeted notification + +```typescript +await notificationsApi.createNotification({ + recipients: { + type: "entity", + entityRef: "user:default/test-user", + }, + payload: { + title: "Entity notification", + description: "For a specific user", + severity: "high", + topic: "e2e-test", + }, +}); +``` + +## Severity Values + +Use lowercase severity strings: + +| Value | UI label | +|-------|----------| +| `"critical"` | Critical | +| `"high"` | High | +| `"normal"` | Normal | +| `"low"` | Low | + +## Mark All as Read + +```typescript +await notificationsApi.markAllNotificationsAsRead(); +``` + +## Complete Example + +```typescript +import { test, expect } from "@red-hat-developer-hub/e2e-test-utils/test"; +import { RhdhNotificationsApi } from "@red-hat-developer-hub/e2e-test-utils/helpers"; +import { NotificationPage } from "@red-hat-developer-hub/e2e-test-utils/pages"; + +test("notification appears in UI", async ({ page, loginHelper }) => { + await loginHelper.loginAsKeycloakUser(); + + const title = `e2e-notification-${Date.now()}`; + const api = await RhdhNotificationsApi.build("test-token"); + const response = await api.createNotification({ + recipients: { type: "broadcast" }, + payload: { + title, + description: "Test notification", + severity: "normal", + topic: "e2e", + }, + }); + expect(response.ok()).toBeTruthy(); + + const notificationPage = new NotificationPage(page); + await notificationPage.navigateToNotifications(); + await notificationPage.notificationContains(title); +}); +``` + +## Related Pages + +- [NotificationPage](/guide/page-objects/notification-page) — UI interactions for notifications +- [AuthApiHelper](/guide/helpers/auth-api-helper) — obtain tokens when not using `test-token` diff --git a/docs/guide/helpers/ui-helper.md b/docs/guide/helpers/ui-helper.md index 12417ff..0b6aeda 100644 --- a/docs/guide/helpers/ui-helper.md +++ b/docs/guide/helpers/ui-helper.md @@ -1,6 +1,6 @@ # UIhelper -The `UIhelper` class provides methods for interacting with Material-UI components in RHDH. +The `UIhelper` class provides methods for interacting with RHDH UI components. Button and wait helpers support both **Material-UI (MUI)** and **Backstage UI (BUI)** selectors. ## Getting UIhelper @@ -20,15 +20,18 @@ const uiHelper = new UIhelper(page); ## Wait Operations -### `waitForLoad(timeout?)` +### `waitForLoad(timeout?)` / `waitForAppReady(timeout?)` -Wait for the page to fully load: +Wait for MUI and BUI loading indicators to disappear (linear progress bars, circular spinners, button spinners). `waitForAppReady` is an alias for `waitForLoad` — use either name; prefer `waitForAppReady` in new tests for clarity. ```typescript await uiHelper.waitForLoad(); -await uiHelper.waitForLoad(10000); // Custom timeout +await uiHelper.waitForAppReady(); +await uiHelper.waitForAppReady(10_000); // Custom timeout (ms) ``` +On instances using the new frontend system (BUI), `waitForLoad` waits for both legacy MUI selectors and BUI `[role="progressbar"]` / spinner elements. + ### `dismissQuickstartIfVisible(options?)` When the [quickstart](https://github.com/redhat-developer/rhdh-plugins/tree/main/workspaces/quickstart) plugin opens its drawer (for example after login), it can sit over the catalog, search field, or other controls. This method clicks **Hide** only if that button is visible, then waits for it to go away; otherwise it returns immediately. diff --git a/docs/guide/page-objects/notification-page.md b/docs/guide/page-objects/notification-page.md index e9c26c4..c416898 100644 --- a/docs/guide/page-objects/notification-page.md +++ b/docs/guide/page-objects/notification-page.md @@ -1,99 +1,101 @@ # NotificationPage -The `NotificationPage` class provides methods for managing notifications in RHDH. +The `NotificationPage` class provides methods for managing notifications in RHDH. Selectors support both **MUI** (legacy) and **BUI** (new frontend system) — the page object tries each approach automatically. ## Usage ```typescript import { NotificationPage } from "@red-hat-developer-hub/e2e-test-utils/pages"; +// Default: creates an internal UIhelper const notificationPage = new NotificationPage(page); + +// Optional: pass the fixture UIhelper (recommended in fixture-based tests) +const notificationPage = new NotificationPage(page, uiHelper); ``` ## Methods -### `clickNotificationsNavBarItem()` +### `navigateToNotifications()` -Navigate to notifications via the navbar: +Navigate to the notifications page via the sidebar (with `/notifications` fallback), dismiss toasts, and wait until the page is ready: ```typescript -await notificationPage.clickNotificationsNavBarItem(); +await notificationPage.navigateToNotifications(); ``` ### `notificationContains(text)` -Check if a notification contains specific text: +Verify a notification row contains specific text (expands to 20 rows per page when needed): ```typescript await notificationPage.notificationContains("Build completed"); -await notificationPage.notificationContains("Entity updated"); +await notificationPage.notificationContains(/Pipeline.*succeeded/); ``` -### `markAllNotificationsAsRead()` +### `selectNotification(textOrNth?)` -Mark all notifications as read: +Select a notification by row title or by checkbox index: ```typescript -await notificationPage.markAllNotificationsAsRead(); +await notificationPage.selectNotification("Build completed"); +await notificationPage.selectNotification(/alert/i); +await notificationPage.selectNotification(0); // legacy index-based selection ``` -### `selectSeverity(severity)` +### `selectAllNotifications()` -Filter notifications by severity: +Select all notifications using the header checkbox: ```typescript -await notificationPage.selectSeverity("critical"); -await notificationPage.selectSeverity("high"); -await notificationPage.selectSeverity("normal"); -await notificationPage.selectSeverity("low"); +await notificationPage.selectAllNotifications(); ``` -### `viewSaved()` +### `markAllNotificationsAsRead()` / `markLastNotificationAsRead()` / `markNotificationAsRead(text)` -View saved/bookmarked notifications: +Mark notifications as read: ```typescript -await notificationPage.viewSaved(); +await notificationPage.markAllNotificationsAsRead(); +await notificationPage.markLastNotificationAsRead(); +await notificationPage.markNotificationAsRead("Build completed"); ``` -### `viewAll()` +### `selectSeverity(severity?)` -View all notifications: +Filter notifications by severity (`critical`, `high`, `normal`, `low`): ```typescript -await notificationPage.viewAll(); +await notificationPage.selectSeverity("critical"); +await notificationPage.selectSeverity(""); // clear filter ``` -### `sortByNewestOnTop()` +### `viewSaved()` / `viewRead()` / `viewUnRead()` -Sort notifications with newest first: +Switch notification list views: ```typescript -await notificationPage.sortByNewestOnTop(); +await notificationPage.viewSaved(); +await notificationPage.viewRead(); +await notificationPage.viewUnRead(); ``` -### `sortByOldestOnTop()` +### `sortByNewestOnTop()` / `sortByOldestOnTop()` -Sort notifications with oldest first: +Change sort order: ```typescript +await notificationPage.sortByNewestOnTop(); await notificationPage.sortByOldestOnTop(); ``` -### `saveNotification(index)` - -Save/bookmark a notification: - -```typescript -await notificationPage.saveNotification(0); // Save first notification -``` - -### `deleteNotification(index)` +### `saveSelected()` / `saveAllSelected()` -Delete a notification: +Save selected notifications for later: ```typescript -await notificationPage.deleteNotification(0); // Delete first notification +await notificationPage.selectNotification("Important alert"); +await notificationPage.saveSelected(); ``` ## Complete Example @@ -101,34 +103,33 @@ await notificationPage.deleteNotification(0); // Delete first notification ```typescript import { test, expect } from "@red-hat-developer-hub/e2e-test-utils/test"; import { NotificationPage } from "@red-hat-developer-hub/e2e-test-utils/pages"; +import { RhdhNotificationsApi } from "@red-hat-developer-hub/e2e-test-utils/helpers"; -test("manage notifications", async ({ page, loginHelper }) => { +test("manage notifications", async ({ page, loginHelper, uiHelper }) => { await loginHelper.loginAsKeycloakUser(); - const notificationPage = new NotificationPage(page); - - // Navigate to notifications - await notificationPage.clickNotificationsNavBarItem(); - - // Filter by severity - await notificationPage.selectSeverity("critical"); - - // Check for specific notification - await notificationPage.notificationContains("Critical alert"); - - // Sort by newest - await notificationPage.sortByNewestOnTop(); - - // Save important notification - await notificationPage.saveNotification(0); - - // View saved notifications - await notificationPage.viewSaved(); - - // Mark all as read - await notificationPage.markAllNotificationsAsRead(); - - // View all again - await notificationPage.viewAll(); + const title = `e2e-${Date.now()}`; + const api = await RhdhNotificationsApi.build("test-token"); + const response = await api.createNotification({ + recipients: { type: "broadcast" }, + payload: { + title, + description: "Test notification", + severity: "high", + topic: "e2e", + }, + }); + expect(response.ok()).toBeTruthy(); + + const notificationPage = new NotificationPage(page, uiHelper); + await notificationPage.navigateToNotifications(); + await notificationPage.selectSeverity("high"); + await notificationPage.notificationContains(title); + await notificationPage.markNotificationAsRead(title); }); ``` + +## Related Pages + +- [RhdhNotificationsApi](/guide/helpers/notifications-api-helper) — create notifications via REST API +- [UIhelper](/guide/helpers/ui-helper) — underlying wait and navigation helpers diff --git a/docs/overlay/reference/patterns.md b/docs/overlay/reference/patterns.md index 37d5672..c076a60 100644 --- a/docs/overlay/reference/patterns.md +++ b/docs/overlay/reference/patterns.md @@ -58,6 +58,83 @@ test.describe('RBAC policy management', () => { }); ``` +### Session Token with `getSessionAuthToken` + +For catalog polling and other REST helpers, use `getSessionAuthToken` after login. It retries token retrieval and only navigates when needed: + +```typescript +import { test, expect } from "@red-hat-developer-hub/e2e-test-utils/test"; +import { + CatalogApiHelper, + getSessionAuthToken, +} from "@red-hat-developer-hub/e2e-test-utils/helpers"; + +test.describe("Webhook ingest", () => { + test.describe.configure({ timeout: 180_000 }); + + let catalogToken: string; + + test.beforeEach(async ({ page, uiHelper, loginHelper }) => { + await loginHelper.loginAsKeycloakUser(); + if (!catalogToken) { + catalogToken = await getSessionAuthToken( + page, + uiHelper, + process.env.RHDH_BASE_URL!, + ); + } + }); + + test.afterAll(async () => { + await CatalogApiHelper.dispose(); + }); + + test("entity appears after push", async () => { + // ... trigger webhook ... + + await expect + .poll( + () => + CatalogApiHelper.entityExists( + process.env.RHDH_BASE_URL!, + catalogToken, + "component", + "my-repo", + ), + { timeout: 150_000, intervals: [3_000] }, + ) + .toBe(true); + }); +}); +``` + +When tests run via `run-e2e.sh`, the root Playwright config supplies a 90s default per-test timeout. Use `test.describe.configure({ timeout: ... })` at the suite level for long polling tests — workspace `playwright.config.ts` timeouts are not applied in CI. + +### Catalog Event Polling + +Prefer **catalog API polling** over reloading the UI to detect ingest completion: + +| Approach | When to use | +|----------|-------------| +| `CatalogApiHelper.entityExists` | Wait for create/delete | +| `CatalogApiHelper.getEntityDescription` | Wait for metadata updates | +| `expect.poll(...)` | Built-in Playwright retry with custom interval | + +```typescript +await expect + .poll( + () => + CatalogApiHelper.getEntityDescription( + baseUrl, + token, + "component", + "my-entity", + ), + { timeout: 120_000 }, + ) + .toContain("expected description"); +``` + ## Project and Spec Best Practices Each Playwright project name creates a **separate namespace**. To keep deployments fast and predictable: @@ -241,6 +318,7 @@ await uiHelper.verifyRowInTableByUniqueText("Row 1", ["Column 1", "Column 2"]); ```typescript await uiHelper.waitForLoad(); +await uiHelper.waitForAppReady(); // same behavior; preferred on BUI instances ``` ### Wait for Text diff --git a/docs/overlay/reference/troubleshooting.md b/docs/overlay/reference/troubleshooting.md index 325bb57..7ac0d04 100644 --- a/docs/overlay/reference/troubleshooting.md +++ b/docs/overlay/reference/troubleshooting.md @@ -155,7 +155,7 @@ oc login --token= --server= ``` - Verify page loaded: ```typescript - await uiHelper.waitForLoad(); + await uiHelper.waitForAppReady(); ``` ### "Timeout waiting for selector" @@ -189,6 +189,17 @@ oc login --token= --server= await page.waitForLoadState("networkidle"); ``` +### Page stuck on loading spinner (BUI) + +**Problem:** Tests time out while a progress bar or button spinner stays visible on the new frontend system. + +**Solutions:** +- Wait for MUI and BUI indicators to clear: + ```typescript + await uiHelper.waitForAppReady(); + ``` +- After login or `page.goto()`, call `waitForAppReady()` before interacting with sidebar or catalog content. + ## Environment Variable Issues ### "Variable undefined" diff --git a/package.json b/package.json index 31aaa1e..32f0771 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@red-hat-developer-hub/e2e-test-utils", - "version": "2.1.1", + "version": "2.2.0", "description": "Test utilities for RHDH E2E tests", "license": "Apache-2.0", "repository": { diff --git a/src/playwright/helpers/auth-token.ts b/src/playwright/helpers/auth-token.ts new file mode 100644 index 0000000..3ec37d4 --- /dev/null +++ b/src/playwright/helpers/auth-token.ts @@ -0,0 +1,50 @@ +import { expect, type Page } from "@playwright/test"; +import { AuthApiHelper } from "./auth-api-helper.js"; +import { UIhelper } from "./ui-helper.js"; + +/** Obtain a bearer token from the logged-in browser session. */ +export async function getSessionAuthToken( + page: Page, + uiHelper: UIhelper, + baseUrl: string, +): Promise { + const authApiHelper = new AuthApiHelper(page); + + const readToken = async (): Promise => { + try { + const value = await authApiHelper.getToken(); + return value?.length > 0 ? value : undefined; + } catch { + return undefined; + } + }; + + const existingToken = await readToken(); + if (existingToken) { + return existingToken; + } + + await page.goto(baseUrl); + await uiHelper.waitForAppReady(); + + let token = ""; + await expect + .poll( + async () => { + const value = await readToken(); + if (value) { + token = value; + return true; + } + return false; + }, + { + message: "Token should be retrieved after session is established", + timeout: 30_000, + intervals: [2_000], + }, + ) + .toBe(true); + + return token; +} diff --git a/src/playwright/helpers/catalog-api-helper.test.ts b/src/playwright/helpers/catalog-api-helper.test.ts new file mode 100644 index 0000000..f955cd1 --- /dev/null +++ b/src/playwright/helpers/catalog-api-helper.test.ts @@ -0,0 +1,101 @@ +import assert from "node:assert"; +import { afterEach, describe, it, mock } from "node:test"; +import { request } from "@playwright/test"; +import { CatalogApiHelper } from "./catalog-api-helper.js"; + +const baseUrl = "https://rhdh.example.com"; +const token = "test-token"; + +describe("CatalogApiHelper", () => { + afterEach(async () => { + await CatalogApiHelper.dispose(); + mock.restoreAll(); + }); + + it("entityExists returns false when catalog responds with 404", async () => { + mock.method(request, "newContext", async () => ({ + get: async () => ({ + ok: () => false, + status: () => 404, + statusText: () => "Not Found", + }), + dispose: async () => {}, + })); + + const exists = await CatalogApiHelper.entityExists( + baseUrl, + token, + "component", + "missing-entity", + ); + + assert.strictEqual(exists, false); + }); + + it("entityExists returns true when entity is found", async () => { + mock.method(request, "newContext", async () => ({ + get: async () => ({ + ok: () => true, + status: () => 200, + statusText: () => "OK", + json: async () => ({ metadata: { name: "my-entity" } }), + }), + dispose: async () => {}, + })); + + const exists = await CatalogApiHelper.entityExists( + baseUrl, + token, + "component", + "my-entity", + ); + + assert.strictEqual(exists, true); + }); + + it("entityExists rethrows non-404 errors", async () => { + mock.method(request, "newContext", async () => ({ + get: async () => ({ + ok: () => false, + status: () => 500, + statusText: () => "Internal Server Error", + }), + dispose: async () => {}, + })); + + await assert.rejects( + () => + CatalogApiHelper.entityExists(baseUrl, token, "component", "my-entity"), + /500/, + ); + }); + + it("getEntityDescription returns metadata.description", async () => { + mock.method(request, "newContext", async () => ({ + get: async (url: string) => { + assert.match( + url, + /\/api\/catalog\/entities\/by-name\/component\/default\/my-entity$/, + ); + return { + ok: () => true, + status: () => 200, + statusText: () => "OK", + json: async () => ({ + metadata: { description: "updated description" }, + }), + }; + }, + dispose: async () => {}, + })); + + const description = await CatalogApiHelper.getEntityDescription( + baseUrl, + token, + "component", + "my-entity", + ); + + assert.strictEqual(description, "updated description"); + }); +}); diff --git a/src/playwright/helpers/catalog-api-helper.ts b/src/playwright/helpers/catalog-api-helper.ts new file mode 100644 index 0000000..dbc2c9b --- /dev/null +++ b/src/playwright/helpers/catalog-api-helper.ts @@ -0,0 +1,108 @@ +import { APIRequestContext, request } from "@playwright/test"; + +/** + * Static helper for catalog entity lookups via the RHDH catalog API. + * Used by event-driven discovery tests that poll for ingest completion. + */ +export class CatalogApiHelper { + private static context: APIRequestContext | undefined; + + private static async getContext(): Promise { + if (!this.context) { + this.context = await request.newContext({ + ignoreHTTPSErrors: true, + }); + } + return this.context; + } + + static async dispose(): Promise { + if (this.context) { + await this.context.dispose(); + this.context = undefined; + } + } + + static async entityExists( + baseUrl: string, + token: string, + kind: string, + name: string, + namespace = "default", + ): Promise { + try { + await this.getEntity(baseUrl, token, kind, name, namespace); + return true; + } catch (error) { + if (error instanceof Error && error.message.includes("404")) { + return false; + } + throw error; + } + } + + static async getEntity( + baseUrl: string, + token: string, + kind: string, + name: string, + namespace = "default", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): Promise { + const context = await this.getContext(); + + const url = `${baseUrl}/api/catalog/entities/by-name/${kind.toLowerCase()}/${namespace}/${name}`; + const response = await context.get(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok()) { + throw new Error( + `Failed to get ${kind} entity "${name}": ${response.status()} ${response.statusText()}`, + ); + } + + return await response.json(); + } + + static async getEntityDescription( + baseUrl: string, + token: string, + kind: string, + name: string, + namespace = "default", + ): Promise { + const entity = await this.getEntity(baseUrl, token, kind, name, namespace); + return entity.metadata?.description as string | undefined; + } + + static async getGroupEntity( + baseUrl: string, + token: string, + groupName: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): Promise { + return this.getEntity(baseUrl, token, "group", groupName); + } + + static async getGroupMembers( + baseUrl: string, + token: string, + groupName: string, + ): Promise { + const groupEntity = await CatalogApiHelper.getGroupEntity( + baseUrl, + token, + groupName, + ); + const members = + groupEntity.relations + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ?.filter((r: any) => r.type === "hasMember") + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((r: any) => r.targetRef.split("/")[1]) || []; + return members; + } +} diff --git a/src/playwright/helpers/index.ts b/src/playwright/helpers/index.ts index 505b159..73be2dc 100644 --- a/src/playwright/helpers/index.ts +++ b/src/playwright/helpers/index.ts @@ -4,3 +4,14 @@ export { LoginHelper, setupBrowser } from "./common.js"; export { UIhelper } from "./ui-helper.js"; export { RbacApiHelper, Policy, Role, Response } from "./rbac-api-helper.js"; export { AuthApiHelper } from "./auth-api-helper.js"; +export { getSessionAuthToken } from "./auth-token.js"; +export { CatalogApiHelper } from "./catalog-api-helper.js"; +export { + RhdhNotificationsApi, + type NotificationPayload, + type NotificationRecipients, + type NotificationRequest, + type NotificationSeverity, + type BroadcastRecipients, + type EntityRecipients, +} from "./notifications-api-helper.js"; diff --git a/src/playwright/helpers/notifications-api-helper.ts b/src/playwright/helpers/notifications-api-helper.ts new file mode 100644 index 0000000..a53d22d --- /dev/null +++ b/src/playwright/helpers/notifications-api-helper.ts @@ -0,0 +1,69 @@ +import { APIRequestContext, APIResponse, request } from "@playwright/test"; + +export type NotificationSeverity = "critical" | "high" | "normal" | "low"; + +export interface NotificationPayload { + title: string; + description: string; + severity: NotificationSeverity; + topic: string; +} + +export type BroadcastRecipients = { + type: "broadcast"; +}; + +export type EntityRecipients = { + type: "entity"; + entityRef: string | string[]; + excludeEntityRef?: string | string[]; +}; + +export type NotificationRecipients = BroadcastRecipients | EntityRecipients; + +export interface NotificationRequest { + recipients: NotificationRecipients; + payload: NotificationPayload; +} + +export class RhdhNotificationsApi { + private readonly apiUrl = `${process.env.RHDH_BASE_URL}/api/`; + /* eslint-disable @typescript-eslint/naming-convention */ + private readonly authHeader: { + Accept: "application/json"; + Authorization: string; + }; + /* eslint-enable @typescript-eslint/naming-convention */ + private myContext!: APIRequestContext; + + private constructor(private readonly token: string) { + this.authHeader = { + Accept: "application/json", + Authorization: `Bearer ${this.token}`, + }; + } + + public static async build(token: string): Promise { + const instance = new RhdhNotificationsApi(token); + instance.myContext = await request.newContext({ + baseURL: instance.apiUrl, + extraHTTPHeaders: instance.authHeader, + }); + return instance; + } + + public async createNotification( + notification: NotificationRequest, + ): Promise { + return await this.myContext.post("notifications", { data: notification }); + } + + public async markAllNotificationsAsRead(): Promise { + return await this.myContext.patch("notifications", { + data: { + ids: [], + read: true, + }, + }); + } +} diff --git a/src/playwright/helpers/ui-helper.ts b/src/playwright/helpers/ui-helper.ts index c3095a4..44b871e 100644 --- a/src/playwright/helpers/ui-helper.ts +++ b/src/playwright/helpers/ui-helper.ts @@ -23,6 +23,11 @@ export class UIhelper { } } + /** Wait for MUI and BUI loading indicators to clear. Alias for {@link waitForLoad}. */ + async waitForAppReady(timeout = 120000) { + await this.waitForLoad(timeout); + } + /** * Closes the quickstart drawer when the "Hide" button is visible (RHDH quickstart plugin), * so it does not cover catalog or other UI under test. @@ -273,7 +278,7 @@ export class UIhelper { .locator(`nav a:has-text("${navBarText}")`) .first(); await navLink.waitFor({ state: "visible", timeout: 15_000 }); - await navLink.dispatchEvent("click"); + await navLink.click(); } async openCatalogSidebar(kind: string) { diff --git a/src/playwright/page-objects/global-obj.test.ts b/src/playwright/page-objects/global-obj.test.ts new file mode 100644 index 0000000..dbbb911 --- /dev/null +++ b/src/playwright/page-objects/global-obj.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { WAIT_OBJECTS } from "./global-obj.js"; + +describe("WAIT_OBJECTS", () => { + it("includes MUI loading selectors", () => { + assert.ok(WAIT_OBJECTS.MuiLinearProgress); + assert.ok(WAIT_OBJECTS.MuiCircularProgress); + }); + + it("includes BUI loading selectors", () => { + assert.ok(WAIT_OBJECTS.progressBar); + assert.ok(WAIT_OBJECTS.buttonSpinner); + assert.ok(WAIT_OBJECTS.alertSpinner); + }); +}); diff --git a/src/playwright/page-objects/global-obj.ts b/src/playwright/page-objects/global-obj.ts index f1b785e..fef31d0 100644 --- a/src/playwright/page-objects/global-obj.ts +++ b/src/playwright/page-objects/global-obj.ts @@ -1,6 +1,10 @@ export const WAIT_OBJECTS = { MuiLinearProgress: 'div[class*="MuiLinearProgress-root"]', MuiCircularProgress: '[class*="MuiCircularProgress-root"]', + /** Backstage UI (BUI) — additive so MUI and BUI apps both settle before assertions */ + progressBar: '[role="progressbar"]', + buttonSpinner: ".bui-ButtonSpinner, .bui-ButtonIconSpinner", + alertSpinner: ".bui-AlertSpinner", }; export const UI_HELPER_ELEMENTS = { diff --git a/src/playwright/pages/notifications.ts b/src/playwright/pages/notifications.ts index e431ed1..4e64bb6 100644 --- a/src/playwright/pages/notifications.ts +++ b/src/playwright/pages/notifications.ts @@ -1,31 +1,40 @@ -import { expect, type Page } from "@playwright/test"; +import { expect, type Locator, 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) { + constructor(page: Page, uiHelper?: UIhelper) { this.page = page; - this.uiHelper = new UIhelper(page); + this.uiHelper = uiHelper ?? new UIhelper(page); } - async clickNotificationsNavBarItem() { - await this.uiHelper.openSidebar("Notifications"); - await expect( - this.page.getByTestId("loading-indicator").getByRole("img"), - ).toHaveCount(0); + /** Navigate to the notifications page and wait until it is ready. */ + async navigateToNotifications() { + await this.dismissNotificationToasts(); + await expect(async () => { + try { + await this.uiHelper.openSidebar("Notifications"); + await this.waitForNotificationsReady(15_000); + await this.uiHelper.verifyHeading("Notifications", 15_000); + } catch { + await this.page.goto("/notifications"); + await this.waitForNotificationsReady(15_000); + await expect(this.page).toHaveURL(/\/notifications/); + await this.uiHelper.verifyHeading("Notifications", 15_000); + } + }).toPass({ timeout: 60_000, intervals: [2_000] }); } 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); + await this.dismissNotificationToasts(); + let row = this.findNotificationRow(text); + if (!(await row.isVisible())) { + await this.setRowsPerPage(20); + row = this.findNotificationRow(text); + } + await expect(row).toBeVisible(); } async clickNotificationHeadingLink(text: string | RegExp) { @@ -35,19 +44,25 @@ export class NotificationPage { .getByRole("heading") .click(); } + async markAllNotificationsAsRead() { - const markAllNotificationsAsReadIsVisible = await this.page + const markAllButton = 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); + .getByRole("button"); + const markAllByLabel = this.page.getByRole("button", { + name: /mark all read/i, + }); + const target = (await markAllButton.isVisible().catch(() => false)) + ? markAllButton + : markAllByLabel; + + if (await target.isVisible().catch(() => false)) { + await target.click(); + const confirm = this.page.getByRole("button", { name: /MARK ALL/i }); + if (await confirm.isVisible().catch(() => false)) { + await confirm.click(); + } + await this.waitForNotificationsReady(); await expect(this.page.getByText("No records to display")).toBeVisible(); } } @@ -56,64 +71,73 @@ export class NotificationPage { await this.page.getByRole("checkbox").first().click(); } - async selectNotification(nth = 1) { - await this.page.getByRole("checkbox").nth(nth).click(); + async selectNotification(textOrNth?: string | RegExp | number) { + await this.dismissNotificationToasts(); + if (typeof textOrNth === "number") { + await this.page.getByRole("checkbox").nth(textOrNth).click(); + return; + } + + let row = + textOrNth !== undefined + ? this.findNotificationRow(textOrNth).first() + : this.notificationRowsBui().first(); + if (textOrNth !== undefined && !(await row.isVisible())) { + await this.setRowsPerPage(20); + row = this.findNotificationRow(textOrNth).first(); + } + + const buiCheckbox = row.getByRole("checkbox", { + name: "Select notification", + }); + if (await buiCheckbox.isVisible().catch(() => false)) { + await buiCheckbox.check({ force: true }); + return; + } + + if (textOrNth === undefined) { + await this.page.getByRole("checkbox").first().click(); + return; + } + + await row.getByRole("checkbox").first().click(); } async selectSeverity(severity = "") { - await this.page.getByLabel("Severity").click(); + await this.severityFilter().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); + await this.waitForNotificationsReady(); } 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); + await this.saveSelectedButton().click(); + await this.waitForNotificationsReady(); } 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); + await this.saveSelectedButton().click(); + await this.waitForNotificationsReady(); } 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); + await this.waitForNotificationsReady(); } async markLastNotificationAsRead() { - const row = this.page.locator("td:nth-child(3) > div").first(); - await row.getByRole("button").nth(1).click(); + await this.toggleRead("unread"); } async markNotificationAsRead(text: string) { - const row = this.page.locator(`tr:has-text("${text}")`); - await row.getByRole("button").nth(1).click(); + await this.toggleRead("unread", text); } async markLastNotificationAsUnRead() { - const row = this.page.locator("td:nth-child(3) > div").first(); - await row.getByRole("button").nth(1).click(); + await this.toggleRead("read"); } async viewRead() { @@ -121,9 +145,7 @@ export class NotificationPage { await this.page .getByRole("option", { name: "Read notifications", exact: true }) .click(); - await expect( - this.page.getByTestId("loading-indicator").getByRole("img"), - ).toHaveCount(0); + await this.waitForNotificationsReady(); } async viewUnRead() { @@ -131,24 +153,130 @@ export class NotificationPage { await this.page .getByRole("option", { name: "Unread notifications", exact: true }) .click(); - await expect( - this.page.getByTestId("loading-indicator").getByRole("img"), - ).toHaveCount(0); + await this.waitForNotificationsReady(); } 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); + await this.waitForNotificationsReady(); } 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); + await this.waitForNotificationsReady(); + } + + private severityFilter(): Locator { + return this.page.getByLabel(/^(Min )?severity$/i); + } + + private saveSelectedButton(): Locator { + const bui = this.page.locator( + 'thead button[aria-label="Save selected for later"]', + ); + const mui = this.page + .locator("thead") + .getByTitle("Save selected for later") + .getByRole("button"); + return bui.or(mui).first(); + } + + private notificationRowsBui() { + return this.page.getByRole("row").filter({ + has: this.page.getByRole("checkbox", { name: "Select notification" }), + }); + } + + private findNotificationRow(text: string | RegExp): Locator { + const buiRow = this.notificationRowsBui().filter({ hasText: text }).first(); + const legacyRow = this.page.locator("tr", { hasText: text }).first(); + return buiRow.or(legacyRow).first(); + } + + private async waitForNotificationsReady(timeout = 30_000) { + await this.uiHelper.waitForAppReady(timeout); + const muiLoader = this.page + .getByTestId("loading-indicator") + .getByRole("img"); + if ( + await muiLoader + .first() + .isVisible() + .catch(() => false) + ) { + await expect(muiLoader).toHaveCount(0, { timeout }); + } + } + + private async dismissNotificationToasts() { + const toasts = this.page.locator( + "#notistack-snackbar, .notistack-CollapseWrapper", + ); + if ( + await toasts + .first() + .isVisible() + .catch(() => false) + ) { + await expect(toasts.first()).toBeHidden({ timeout: 15_000 }); + } + } + + private async setRowsPerPage(size: number) { + await this.dismissNotificationToasts(); + const rowsPerPage = this.page.getByRole("button", { + name: /rows per page/i, + }); + const legacyRows = this.page.getByLabel(/.*rows/i); + if (await rowsPerPage.isVisible().catch(() => false)) { + await rowsPerPage.click({ force: true }); + } else { + await legacyRows.click(); + } + await this.page.getByRole("option", { name: String(size) }).click(); + await this.waitForNotificationsReady(); + } + + private async toggleRead(currentState: "read" | "unread", text?: string) { + await this.dismissNotificationToasts(); + const readButtonName = + currentState === "unread" + ? /Mark selected as read/i + : /Return selected among unread/i; + + const buiRows = this.notificationRowsBui(); + const buiRow = text ? buiRows.filter({ hasText: text }) : buiRows.first(); + const buiAction = buiRow.getByRole("button", { name: readButtonName }); + if (await buiAction.isVisible().catch(() => false)) { + const count = await buiRows.count(); + await buiAction.click(); + await this.waitForNotificationsReady(); + await this.expectUnreadCountDecreased(buiRows, count, currentState); + return; + } + + const legacyRow = text + ? this.page.locator(`tr:has-text("${text}")`) + : this.page.locator("td:nth-child(3) > div").first(); + await legacyRow.getByRole("button").nth(1).click(); + await this.waitForNotificationsReady(); + } + + private async expectUnreadCountDecreased( + rows: Locator, + count: number, + currentState: "read" | "unread", + ) { + const viewPattern = + currentState === "unread" + ? /Unread notifications \(/ + : /Read notifications \(/; + if (await this.page.getByText(viewPattern).isVisible()) { + await expect(async () => { + await expect(rows).toHaveCount(count - 1); + }).toPass({ timeout: 15_000, intervals: [1_000] }); + } } }