Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 81 additions & 31 deletions e2etests/pages/base-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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]/..');
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -290,7 +292,7 @@ export abstract class BasePage {
* - If no `<canvas>` elements are present on the page, this method will wait until
* the timeout is reached.
*/
async waitForCanvas(timeout: number = 20000): Promise<void> {
async waitForCanvas(timeout: number = LARGE_DATA_TIMEOUT): Promise<void> {
await this.page.waitForFunction(
() => {
const canvases = document.querySelectorAll('canvas');
Expand Down Expand Up @@ -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<void> {
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<void> {
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<void>} 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<void> {
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<void>} Resolves when the first matching response is received.
*/
async waitForFirstAPIResponseByPartialTextMatch(urlTexts: string[], timeout: number): Promise<void> {
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`);
}

/**
Expand Down Expand Up @@ -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<void>} 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<void> {
async waitForLoadingPageImgToDisappear(timeout: number = LARGE_DATA_TIMEOUT): Promise<void> {
try {
await this.loadingPageImg.first().waitFor({ timeout: 1000 });
} catch (_error) {
Expand All @@ -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<void>} 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<void> {
async waitForInitialisationToComplete(timeout: number = LARGE_DATA_TIMEOUT): Promise<void> {
try {
await this.initialisationMessage.first().waitFor({ timeout: 1000 });
} catch (_error) {
Expand Down Expand Up @@ -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<void>} A promise that resolves when the filter is applied.
*/
protected async selectFilter(filter: Locator, filterOption: string): Promise<void> {
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.
*
Expand Down Expand Up @@ -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<void>} A promise that resolves when the filter is applied.
*/
protected async selectFilter(filter: Locator, filterOption: string): Promise<void> {
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();
}
}
}
125 changes: 84 additions & 41 deletions e2etests/pages/resources-page.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<void>} 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<void>} 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<void> {
async navigateToResourcesPageAndResetFilters(timeout: number = LARGE_DATA_TIMEOUT): Promise<void> {
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<void>} 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<void>} 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<void> {
async clickExpensesTab(wait: boolean = true): Promise<void> {
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<void>} 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<void>} 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<void> {
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();
}
}
Expand All @@ -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();
}
}
Expand All @@ -307,11 +333,11 @@ export class ResourcesPage extends BasePage {
* await resourcesPage.clickMetaTab(false);
* await resourcesPage.selectMetaCategorizeBy('Region');
*/
async clickMetaTab(wait = true): Promise<void> {
async clickMetaTab(wait: boolean = true): Promise<void> {
debugLog('Clicking Meta Tab');
await this.tabMetaBtn.click();
if (wait) {
await this.waitForCanvas();
await this.waitForAPIResponseByPartialTextMatch('op=MetaBreakdown', LARGE_DATA_TIMEOUT);
await this.waitForAllProgressBarsToDisappear();
}
}
Expand All @@ -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<void>} 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<void> {
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();
}
}
Expand Down Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -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();
}

Expand All @@ -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,
});
}
}
Loading
Loading