diff --git a/e2etests/pages/base-page.ts b/e2etests/pages/base-page.ts index 14020f888..3ed8b5d5e 100644 --- a/e2etests/pages/base-page.ts +++ b/e2etests/pages/base-page.ts @@ -2,6 +2,7 @@ import { Locator, Page } from '@playwright/test'; import fs from 'fs'; import path from 'path'; import { debugLog, errorLog } from '../utils/debug-logging'; +import { LARGE_DATA_TIMEOUT } from '../playwright.config'; /** * Abstract class representing the base structure for all pages. @@ -20,6 +21,7 @@ export abstract class BasePage { readonly warningColor: string; // Default color for warning state readonly errorColor: string; // Default color for error state readonly successColor: string; // Default color for success state + readonly noDataMessage: Locator; // Filters readonly filtersBox: Locator; @@ -65,6 +67,7 @@ export abstract class BasePage { this.warningColor = 'rgb(232, 125, 30)'; // Default color for warning state this.errorColor = 'rgb(187, 20, 37)'; // Default color for error state this.successColor = 'rgb(0, 120, 77)'; // Default color for success state + this.noDataMessage = this.main.getByText('No data to display'); //Filters this.filtersBox = this.main.locator('xpath=(//div[.="Filters:"])[1]/..'); @@ -196,7 +199,6 @@ export abstract class BasePage { return root.locator(`[data-test-id="${testId}"], [data-testid="${testId}"]`); } - /** * Selects an option from a combo box if it is not already selected. * @@ -290,7 +292,7 @@ export abstract class BasePage { * - If no `` elements are present on the page, this method will wait until * the timeout is reached. */ - async waitForCanvas(timeout: number = 20000): Promise { + async waitForCanvas(timeout: number = LARGE_DATA_TIMEOUT): Promise { await this.page.waitForFunction( () => { const canvases = document.querySelectorAll('canvas'); @@ -328,13 +330,60 @@ export abstract class BasePage { * since `every` returns `true` for an empty array. * - No explicit timeout parameter is exposed; the default Playwright function timeout applies. */ - async waitForAllCanvases(): Promise { - await this.page.waitForFunction(() => { - return Array.from(document.querySelectorAll('canvas')).every(canvas => { - const ctx = canvas.getContext('2d', { willReadFrequently: true }); - return ctx && ctx.getImageData(0, 0, canvas.width, canvas.height).data.some(pixel => pixel !== 0); - }); + async waitForAllCanvases(timeout: number = LARGE_DATA_TIMEOUT): Promise { + await this.page.waitForFunction( + () => { + return Array.from(document.querySelectorAll('canvas')).every(canvas => { + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + return ctx && ctx.getImageData(0, 0, canvas.width, canvas.height).data.some(pixel => pixel !== 0); + }); + }, + null, + { timeout } + ); + } + + /** + * Waits for an API response whose URL contains the specified text. + * + * Listens for incoming network responses and resolves as soon as one is received + * whose URL includes `urlText` and has an HTTP 200 status code. + * + * @param {string} urlText - A substring to match against the URL of incoming responses. + * @param {number} timeout - Maximum time in milliseconds to wait for a matching response. + * Rejects if no matching response is received within this duration. + * @returns {Promise} Resolves when a matching 200 response is received. + * + * @example + * // Wait for the resources API to respond + * await basePage.waitForAPIResponseByPartialTextMatch('op=CleanExpenses', 30000); + * + * @remarks + * For waiting on any one of multiple possible URLs, use + * `waitForFirstAPIResponseByPartialTextMatch` instead. + */ + async waitForAPIResponseByPartialTextMatch(urlText: string, timeout: number): Promise { + debugLog(`Waiting for ${urlText} API response`); + await this.page.waitForResponse(response => response.url().includes(urlText) && response.status() === 200, { timeout }); + debugLog(`API response including ${urlText} received`); + } + + /** + * Waits for the first API response whose URL matches any of the provided strings. + * + * Resolves as soon as one matching response is received, ignoring the rest. + * Useful when multiple endpoints may satisfy a condition and only the first is needed. + * + * @param {string[]} urlTexts - Array of URL substrings to match against incoming responses. + * @param {number} timeout - Maximum time in milliseconds to wait for a matching response. + * @returns {Promise} Resolves when the first matching response is received. + */ + async waitForFirstAPIResponseByPartialTextMatch(urlTexts: string[], timeout: number): Promise { + debugLog(`Waiting for first API response matching any of: [${urlTexts.join(', ')}]`); + await this.page.waitForResponse(response => urlTexts.some(urlText => response.url().includes(urlText)) && response.status() === 200, { + timeout, }); + debugLog(`First matching API response received`); } /** @@ -690,7 +739,7 @@ export abstract class BasePage { * @param {number} [timeout=10000] - The maximum time to wait for the loading image to disappear, in milliseconds. * @returns {Promise} A promise that resolves when the loading image is no longer visible or exits early if the image is not present. */ - async waitForLoadingPageImgToDisappear(timeout: number = 20000): Promise { + async waitForLoadingPageImgToDisappear(timeout: number = LARGE_DATA_TIMEOUT): Promise { try { await this.loadingPageImg.first().waitFor({ timeout: 1000 }); } catch (_error) { @@ -714,7 +763,7 @@ export abstract class BasePage { * @param {number} [timeout=10000] - The maximum time to wait for the initialisation message to disappear, in milliseconds. * @returns {Promise} A promise that resolves when the initialisation message is no longer visible or exits early if the message is not present. */ - async waitForInitialisationToComplete(timeout: number = 20000): Promise { + async waitForInitialisationToComplete(timeout: number = LARGE_DATA_TIMEOUT): Promise { try { await this.initialisationMessage.first().waitFor({ timeout: 1000 }); } catch (_error) { @@ -979,27 +1028,6 @@ export abstract class BasePage { } } - /** - * Selects a filter and applies the specified filter option. - * - * @param {Locator} filter - The filter locator to select. - * @param {string} filterOption - The specific filter option to apply. - * @throws {Error} Throws an error if `filterOption` is not provided when `filter` is specified. - * @returns {Promise} A promise that resolves when the filter is applied. - */ - protected async selectFilter(filter: Locator, filterOption: string): Promise { - if (filter) { - if (!filterOption) { - throw new Error('filterOption must be provided when filter is specified'); - } - if (!(await filter.isVisible())) await this.showMoreFiltersBtn.click(); - await filter.click(); - - await this.filterPopover.getByLabel(filterOption).first().click(); - await this.filterApplyButton.click(); - } - } - /** * Retrieves the currently active filter button from the filters box. * @@ -1028,4 +1056,26 @@ export abstract class BasePage { getActiveFilter(): Locator { return this.filtersBox.locator('//button[contains(@class, "MuiButton-contained")]'); } + + /** + * Selects a filter and applies the specified filter option. + * + * @param {Locator} filter - The filter locator to select. + * @param {string} filterOption - The specific filter option to apply. + * @throws {Error} Throws an error if `filterOption` is not provided when `filter` is specified. + * @returns {Promise} A promise that resolves when the filter is applied. + */ + protected async selectFilter(filter: Locator, filterOption: string): Promise { + if (filter) { + if (!filterOption) { + throw new Error('filterOption must be provided when filter is specified'); + } + if (!(await filter.isVisible())) await this.showMoreFiltersBtn.click(); + await filter.click(); + + const escapedOption = filterOption.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + await this.filterPopover.getByLabel(new RegExp(`${escapedOption}$`)).click(); + await this.filterApplyButton.click(); + } + } } diff --git a/e2etests/pages/resources-page.ts b/e2etests/pages/resources-page.ts index dcf61255b..21d4a44d8 100644 --- a/e2etests/pages/resources-page.ts +++ b/e2etests/pages/resources-page.ts @@ -1,6 +1,7 @@ -import {BasePage} from './base-page'; -import {Locator, Page} from '@playwright/test'; -import {debugLog} from '../utils/debug-logging'; +import { BasePage } from './base-page'; +import { Locator, Page } from '@playwright/test'; +import { debugLog } from '../utils/debug-logging'; +import { LARGE_DATA_TIMEOUT } from '../playwright.config'; /** * Represents the Resources Page. @@ -211,63 +212,88 @@ export class ResourcesPage extends BasePage { /** * Navigates to the Resources page and resets all active filters. * - * This method navigates to `/resources`, waits for all progress bars to disappear, - * waits for the canvas to finish rendering, resets any active filters, waits for the - * page to fully load, and finally waits for the first resource item in the table to - * be present. This ensures the page is in a clean, fully loaded state before any - * test interactions begin. + * Performs the following steps: + * 1. Navigates to the `/resources` URL. + * 2. Waits for the first API response matching any of the expected data identifiers. + * 3. Waits for all progress bars to disappear. + * 4. Resets all active filters on the page. + * 5. Waits for the page to finish loading. * - * @returns {Promise} Resolves when the page is loaded, filters are reset, and - * the first resource table item is visible. + * @param {number} [timeout=LARGE_DATA_TIMEOUT] - Maximum time in milliseconds to wait for the + * initial API response. Defaults to `LARGE_DATA_TIMEOUT` to accommodate slow data loads. + * @returns {Promise} Resolves when the page is loaded and all filters have been reset. * * @example - * // Use in a beforeEach to ensure a clean state before each test - * test.beforeEach(async ({ resourcesPage }) => { - * await resourcesPage.navigateToResourcesPageAndResetFilters(); - * }); + * // Navigate and reset filters using the default timeout + * await resourcesPage.navigateToResourcesPageAndResetFilters(); * - * @remarks - * - Prefer this method over a bare `navigateToURL()` call when tests require a - * filter-free state and a populated resource table before proceeding. - * - The `firstResourceItemInTable` wait uses a 15 second timeout to account for - * slow data loading on the Resources page. + * @example + * // Navigate and reset filters with a custom timeout + * await resourcesPage.navigateToResourcesPageAndResetFilters(60000); */ - async navigateToResourcesPageAndResetFilters(): Promise { + async navigateToResourcesPageAndResetFilters(timeout: number = LARGE_DATA_TIMEOUT): Promise { await this.navigateToURL('/resources'); + await this.waitForFirstAPIResponseByPartialTextMatch( + ['op=CleanExpenses', 'resources_count', 'breakdown_tags', 'op=MetaBreakdown'], + timeout + ); await this.waitForAllProgressBarsToDisappear(); - await this.waitForCanvas(); await this.resetFilters(); await this.waitForPageLoad(); } /** * Clicks the "Expenses" tab on the Resources page. - * This method interacts with the `tabExpensesBtn` locator and waits for the canvas to update. * - * @returns {Promise} Resolves when the tab is clicked and the canvas is updated. + * If the tab is not already selected, it clicks the tab button and optionally + * waits for the breakdown expenses API response and all progress bars to disappear. + * + * @param {boolean} [wait=true] - Whether to wait for the API response and progress bars after clicking. + * Set to `false` to skip waiting, e.g. when chaining multiple interactions. + * @returns {Promise} Resolves when the tab is active and the optional wait is complete. + * + * @example + * // Click the Expenses tab and wait for data to load + * await resourcesPage.clickExpensesTab(); + * + * @example + * // Click without waiting + * await resourcesPage.clickExpensesTab(false); */ - async clickExpensesTab(wait = true): Promise { + async clickExpensesTab(wait: boolean = true): Promise { debugLog('Clicking ExpensesTab'); if ((await this.tabExpensesBtn.getAttribute('aria-selected')) !== 'true') { await this.tabExpensesBtn.click(); - } - if (wait) { - await this.waitForCanvas(); + if (wait) { + await this.waitForAPIResponseByPartialTextMatch('breakdown_expenses', LARGE_DATA_TIMEOUT); + } await this.waitForAllProgressBarsToDisappear(); } } /** * Clicks the "Resource Count" tab on the Resources page. - * This method interacts with the `tabResourceCountBtn` locator and waits for the canvas to update. * - * @returns {Promise} Resolves when the tab is clicked and the canvas is updated. + * Clicks the tab button and optionally waits for the resource count API + * response and all progress bars to disappear. + * + * @param {boolean} [wait=true] - Whether to wait for the API response and progress bars after clicking. + * Set to `false` to skip waiting, e.g. when chaining multiple interactions. + * @returns {Promise} Resolves when the tab is clicked and the optional wait is complete. + * + * @example + * // Click the Resource Count tab and wait for data to load + * await resourcesPage.clickResourceCountTab(); + * + * @example + * // Click without waiting + * await resourcesPage.clickResourceCountTab(false); */ async clickResourceCountTab(wait = true): Promise { debugLog('Clicking Resource Count tab'); await this.tabResourceCountBtn.click(); if (wait) { - await this.waitForCanvas(); + await this.waitForAPIResponseByPartialTextMatch('resources_count', LARGE_DATA_TIMEOUT); await this.waitForAllProgressBarsToDisappear(); } } @@ -282,7 +308,7 @@ export class ResourcesPage extends BasePage { debugLog('Clicking Tags Tab'); await this.tabTagsBtn.click(); if (wait) { - await this.waitForCanvas(); + await this.waitForAPIResponseByPartialTextMatch('breakdown_tags', LARGE_DATA_TIMEOUT); await this.waitForAllProgressBarsToDisappear(); } } @@ -307,11 +333,11 @@ export class ResourcesPage extends BasePage { * await resourcesPage.clickMetaTab(false); * await resourcesPage.selectMetaCategorizeBy('Region'); */ - async clickMetaTab(wait = true): Promise { + async clickMetaTab(wait: boolean = true): Promise { debugLog('Clicking Meta Tab'); await this.tabMetaBtn.click(); if (wait) { - await this.waitForCanvas(); + await this.waitForAPIResponseByPartialTextMatch('op=MetaBreakdown', LARGE_DATA_TIMEOUT); await this.waitForAllProgressBarsToDisappear(); } } @@ -329,17 +355,31 @@ export class ResourcesPage extends BasePage { /** * Selects an option from the "Categorize By" dropdown on the Resources page. - * This method uses the `categorizeBySelect` locator to select the specified option - * and optionally waits for the page to load and the canvas to update after the selection. * - * @param {string} option - The option to select from the dropdown. - * @param {boolean} [wait=true] - Whether to wait for the page to load and the canvas to update after the selection. + * If the selected option differs from the current selection, waits for the + * breakdown API response before continuing. Optionally waits for all progress + * bars to disappear after the selection. + * + * @param {string} option - The option to select from the Categorize By dropdown. + * @param {boolean} [wait=true] - Whether to wait for the API response and progress bars after selection. + * Set to `false` to skip waiting, e.g. when chaining multiple interactions. * @returns {Promise} Resolves when the option is selected and the optional wait is complete. + * + * @example + * // Select a categorize by option and wait for data to load + * await resourcesPage.selectCategorizeBy('Service name'); + * + * @example + * // Select without waiting + * await resourcesPage.selectCategorizeBy('Region', false); */ async selectCategorizeBy(option: string, wait: boolean = true): Promise { + const selectedOption = await this.selectedComboBoxOption(this.categorizeBySelect); await this.selectFromComboBox(this.categorizeBySelect, option); if (wait) { - await this.waitForCanvas(); + if (selectedOption !== option) { + await this.waitForFirstAPIResponseByPartialTextMatch(['breakdown_by', 'resources_count'], LARGE_DATA_TIMEOUT); + } await this.waitForAllProgressBarsToDisappear(); } } @@ -445,7 +485,8 @@ export class ResourcesPage extends BasePage { throw new Error('Tag must be provided'); } await this.groupByTagSelect.click(); - await this.simplePopover.getByText(tag, { exact: true }).click(); + await this.simplePopover.locator('li').last().waitFor(); + await this.simplePopover.locator(`//li[.="${tag}"]`).click(); } /** @@ -671,7 +712,7 @@ export class ResourcesPage extends BasePage { await perspectiveButton.click(); await this.perspectivesApplyBtn.click(); await this.perspectivesApplyBtn.waitFor({ state: 'hidden' }); - await this.waitForCanvas(); + await this.waitForFirstAPIResponseByPartialTextMatch(['breakdown_by', 'resources_count', 'op=MetaBreakdown'], LARGE_DATA_TIMEOUT); await this.waitForAllProgressBarsToDisappear(); } @@ -693,6 +734,8 @@ export class ResourcesPage extends BasePage { * (case-sensitive) the name of the existing perspective shown in the modal. */ getPerspectiveOverwriteMessage(perspectiveName: string): Locator { - return this.savePerspectiveSideModal.getByText(`The existing perspective (${perspectiveName}) will be overwritten with new options.`, { exact: true }); + return this.savePerspectiveSideModal.getByText(`The existing perspective (${perspectiveName}) will be overwritten with new options.`, { + exact: true, + }); } } diff --git a/e2etests/playwright.config.ts b/e2etests/playwright.config.ts index 5f7b96aef..144c12b41 100644 --- a/e2etests/playwright.config.ts +++ b/e2etests/playwright.config.ts @@ -5,6 +5,13 @@ import path from 'path'; dotenv.config({ path: path.resolve(__dirname, '.env.local') }); +/** + * Extended timeout (ms) for operations that load large amounts of data, + * such as reports, exports, or pages with many resources. + * Use this with the wait helpers in `utils/wait-utils.ts`. + */ +export const LARGE_DATA_TIMEOUT = 30000; + /** * See https://playwright.dev/docs/test-configuration. */ diff --git a/e2etests/tests/perspective-tests.spec.ts b/e2etests/tests/perspective-tests.spec.ts index fac7cfed7..c8d1e3a5d 100644 --- a/e2etests/tests/perspective-tests.spec.ts +++ b/e2etests/tests/perspective-tests.spec.ts @@ -2,6 +2,7 @@ import { test } from '../fixtures/page.fixture'; import { expect, Locator } from '@playwright/test'; import { debugLog } from '../utils/debug-logging'; +import { LARGE_DATA_TIMEOUT } from '../playwright.config'; test.describe('[MPT-18579] Perspective Tests', { tag: ['@ui', '@resources', '@perspectives', '@slow'] }, () => { test.describe.configure({ mode: 'default' }); @@ -23,7 +24,9 @@ test.describe('[MPT-18579] Perspective Tests', { tag: ['@ui', '@resources', '@pe await test.step('Select options to save as a perspective', async () => { await resourcesPage.selectFilterByText(filter, filterOption); await resourcesPage.clickExpensesTab(); - await resourcesPage.selectCategorizeBy(categorizeBy); + await resourcesPage.selectCategorizeBy(categorizeBy, false); + await resourcesPage.waitForAPIResponseByPartialTextMatch('breakdown_expenses', LARGE_DATA_TIMEOUT); + await resourcesPage.waitForCanvas(); await resourcesPage.selectGroupByTag(groupByTag); await resourcesPage.click(resourcesPage.savePerspectiveBtn); }); diff --git a/e2etests/tests/resources-tests.spec.ts b/e2etests/tests/resources-tests.spec.ts index cce0377b5..35863bafa 100644 --- a/e2etests/tests/resources-tests.spec.ts +++ b/e2etests/tests/resources-tests.spec.ts @@ -734,11 +734,7 @@ test.describe('[MPT-11957] Resources page mocked tests', { tag: ['@ui', '@resour test.beforeEach('Login admin user', async ({ resourcesPage }) => { await test.step('Login admin user', async () => { await resourcesPage.page.clock.setFixedTime(new Date('2025-07-15T14:40:00Z')); - await resourcesPage.navigateToURL('/resources'); - await resourcesPage.waitForAllProgressBarsToDisappear(); - await resourcesPage.waitForCanvas(); - await resourcesPage.resetFilters(); - await resourcesPage.firstResourceItemInTable.waitFor(); + await resourcesPage.navigateToResourcesPageAndResetFilters(); }); }); @@ -807,7 +803,7 @@ test.describe('[MPT-11957] Resources page mocked tests', { tag: ['@ui', '@resour let match: boolean; await test.step('Change categorization to Region and verify the chart', async () => { - await resourcesPage.selectCategorizeBy('Region'); + await resourcesPage.selectCategorizeBy('Region', false); await resourcesPage.downloadFile(resourcesPage.exportChartBtn, actualPath); match = await comparePngImages(expectedPath, actualPath, diffPath); expect.soft(match).toBe(true); @@ -818,7 +814,7 @@ test.describe('[MPT-11957] Resources page mocked tests', { tag: ['@ui', '@resour diffPath = path.resolve('tests', 'downloads', 'diff-resource-expenses-chart-export.png'); await test.step('Change categorization to Resource Type and verify the chart', async () => { - await resourcesPage.selectCategorizeBy('Resource type'); + await resourcesPage.selectCategorizeBy('Resource type', false); await resourcesPage.downloadFile(resourcesPage.exportChartBtn, actualPath); match = await comparePngImages(expectedPath, actualPath, diffPath); expect.soft(match).toBe(true); @@ -829,7 +825,7 @@ test.describe('[MPT-11957] Resources page mocked tests', { tag: ['@ui', '@resour diffPath = path.resolve('tests', 'downloads', 'diff-data-expenses-chart-export.png'); await test.step('Change categorization to Data source and verify the chart', async () => { - await resourcesPage.selectCategorizeBy('Data source'); + await resourcesPage.selectCategorizeBy('Data source', false); await resourcesPage.downloadFile(resourcesPage.exportChartBtn, actualPath); match = await comparePngImages(expectedPath, actualPath, diffPath); expect.soft(match).toBe(true); @@ -840,7 +836,7 @@ test.describe('[MPT-11957] Resources page mocked tests', { tag: ['@ui', '@resour diffPath = path.resolve('tests', 'downloads', 'diff-owner-expenses-chart-export.png'); await test.step('Change categorization to Owner and verify the chart', async () => { - await resourcesPage.selectCategorizeBy('Owner'); + await resourcesPage.selectCategorizeBy('Owner', false); await resourcesPage.downloadFile(resourcesPage.exportChartBtn, actualPath); match = await comparePngImages(expectedPath, actualPath, diffPath); expect.soft(match).toBe(true); @@ -851,7 +847,7 @@ test.describe('[MPT-11957] Resources page mocked tests', { tag: ['@ui', '@resour diffPath = path.resolve('tests', 'downloads', 'diff-pool-expenses-chart-export.png'); await test.step('Change categorization to Pool and verify the chart', async () => { - await resourcesPage.selectCategorizeBy('Pool'); + await resourcesPage.selectCategorizeBy('Pool', false); await resourcesPage.downloadFile(resourcesPage.exportChartBtn, actualPath); match = await comparePngImages(expectedPath, actualPath, diffPath); expect.soft(match).toBe(true); @@ -862,7 +858,7 @@ test.describe('[MPT-11957] Resources page mocked tests', { tag: ['@ui', '@resour diffPath = path.resolve('tests', 'downloads', 'diff-k8node-expenses-chart-export.png'); await test.step('Change categorization to K8s node and verify the chart', async () => { - await resourcesPage.selectCategorizeBy('K8s node'); + await resourcesPage.selectCategorizeBy('K8s node', false); await resourcesPage.downloadFile(resourcesPage.exportChartBtn, actualPath); match = await comparePngImages(expectedPath, actualPath, diffPath); expect.soft(match).toBe(true); @@ -873,7 +869,7 @@ test.describe('[MPT-11957] Resources page mocked tests', { tag: ['@ui', '@resour diffPath = path.resolve('tests', 'downloads', 'diff-k8sservice-expenses-chart-export.png'); await test.step('Change categorization to K8s service and verify the chart', async () => { - await resourcesPage.selectCategorizeBy('K8s service'); + await resourcesPage.selectCategorizeBy('K8s service', false); await resourcesPage.downloadFile(resourcesPage.exportChartBtn, actualPath); match = await comparePngImages(expectedPath, actualPath, diffPath); expect.soft(match).toBe(true); @@ -884,7 +880,7 @@ test.describe('[MPT-11957] Resources page mocked tests', { tag: ['@ui', '@resour diffPath = path.resolve('tests', 'downloads', 'diff-k8snamespace-expenses-chart-export.png'); await test.step('Change categorization to K8s namespace and verify the chart', async () => { - await resourcesPage.selectCategorizeBy('K8s namespace'); + await resourcesPage.selectCategorizeBy('K8s namespace', false); await resourcesPage.downloadFile(resourcesPage.exportChartBtn, actualPath); match = await comparePngImages(expectedPath, actualPath, diffPath); expect.soft(match).toBe(true);