diff --git a/e2etests/pages/anomalies-create-page.ts b/e2etests/pages/anomalies-create-page.ts index b150d1e89..dd36326d1 100644 --- a/e2etests/pages/anomalies-create-page.ts +++ b/e2etests/pages/anomalies-create-page.ts @@ -56,7 +56,7 @@ export class AnomaliesCreatePage extends BaseCreatePage { if (!filterOption) { throw new Error('filterOption must be provided when filter is specified'); } - if (!(await filter.isVisible())) await this.clickLocator(this.showMoreFiltersBtn); + if (!(await filter.isVisible())) await this.click(this.showMoreFiltersBtn); await filter.click(); const option = this.filterPopover.getByText(filterOption); await option.click(); diff --git a/e2etests/pages/base-create-page.ts b/e2etests/pages/base-create-page.ts index b9c7d5b43..0dc430d23 100644 --- a/e2etests/pages/base-create-page.ts +++ b/e2etests/pages/base-create-page.ts @@ -9,36 +9,6 @@ export abstract class BaseCreatePage extends BasePage { readonly url: string; readonly nameInput: Locator; readonly typeSelect: Locator; - - // Filters - readonly filtersBox: Locator; - readonly allFilterBoxButtons: Locator; - readonly filterPopover: Locator; - readonly suggestionsFilter: Locator; - readonly dataSourceFilter: Locator; - readonly poolFilter: Locator; - readonly ownerFilter: Locator; - readonly regionFilter: Locator; - readonly serviceFilter: Locator; - readonly resourceTypeFilter: Locator; - readonly activityFilter: Locator; - readonly recommendationsFilter: Locator; - readonly constraintViolationsFilter: Locator; - readonly firstSeenFilter: Locator; - readonly lastSeenFilter: Locator; - readonly tagFilter: Locator; - readonly withoutTagFilter: Locator; - readonly metaFilter: Locator; - readonly paidNetworkTrafficFromFilter: Locator; - readonly paidNetworkTrafficToFilter: Locator; - readonly k8sNodeFilter: Locator; - readonly k8sServiceFilter: Locator; - readonly k8sNamespaceFilter: Locator; - readonly billingOnlyOption: Locator; - readonly filterApplyButton: Locator; - readonly resetFiltersBtn: Locator; - readonly showMoreFiltersBtn: Locator; - readonly showLessFiltersBtn: Locator; readonly saveBtn: Locator; readonly cancelBtn: Locator; @@ -59,35 +29,6 @@ export abstract class BaseCreatePage extends BasePage { this.nameInput = this.main.getByTestId('input_name'); this.typeSelect = this.main.getByTestId('type-selector-select'); - //Filters - this.filtersBox = this.main.locator('xpath=(//div[.="Filters:"])[1]/..'); - this.allFilterBoxButtons = this.filtersBox.locator('button'); - this.filterPopover = this.page.locator('//div[contains(@id, "filter-popover")]'); - this.filterApplyButton = this.filterPopover.getByRole('button', { name: 'Apply' }); - - this.suggestionsFilter = this.filtersBox.getByRole('button', { name: 'Suggestions' }); - this.dataSourceFilter = this.filtersBox.getByRole('button', { name: 'Data source (' }); - this.poolFilter = this.filtersBox.getByRole('button', { name: 'Pool (' }); - this.ownerFilter = this.filtersBox.getByRole('button', { name: 'Owner (' }); - this.regionFilter = this.filtersBox.getByRole('button', { name: 'Region (' }); - this.serviceFilter = this.filtersBox.getByRole('button', { name: /^Service \(/ }); - this.resourceTypeFilter = this.filtersBox.getByRole('button', { name: 'Resource type (' }); - this.activityFilter = this.filtersBox.getByRole('button', { name: 'Activity (' }); - this.recommendationsFilter = this.filtersBox.getByRole('button', { name: 'Recommendations (' }); - this.constraintViolationsFilter = this.filtersBox.getByRole('button', { name: 'Constraint violations (' }); - this.firstSeenFilter = this.filtersBox.getByRole('button', { name: 'First seen (' }); - this.lastSeenFilter = this.filtersBox.getByRole('button', { name: 'Last seen (' }); - this.tagFilter = this.filtersBox.getByRole('button', { name: /^Tag \(/ }); - this.withoutTagFilter = this.filtersBox.getByRole('button', { name: 'Without tag (' }); - this.metaFilter = this.filtersBox.getByRole('button', { name: 'Meta (' }); - this.paidNetworkTrafficFromFilter = this.filtersBox.getByRole('button', { name: 'Paid network traffic from (' }); - this.paidNetworkTrafficToFilter = this.filtersBox.getByRole('button', { name: 'Paid network traffic to (' }); - this.k8sNodeFilter = this.filtersBox.getByRole('button', { name: 'K8s node (' }); - this.k8sServiceFilter = this.filtersBox.getByRole('button', { name: 'K8s service (' }); - this.k8sNamespaceFilter = this.filtersBox.getByRole('button', { name: 'K8s namespace (' }); - - this.showMoreFiltersBtn = this.main.getByRole('button', { name: 'Show more' }); - this.showLessFiltersBtn = this.main.getByRole('button', { name: 'Show less' }); this.saveBtn = this.main.getByTestId('btn_create'); this.cancelBtn = this.main.getByTestId('btn_cancel'); @@ -115,41 +56,4 @@ export abstract class BaseCreatePage extends BasePage { } await this.setButton.click(); } - - /** - * 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).click(); - await this.filterApplyButton.click(); - } - } - - /** - * Selects a filter by its text and applies the specified filter option. - * - * This method locates a filter button within the filters box by matching its name - * with the provided `filter` parameter. It then applies the specified `filterOption` - * to the selected filter. - * - * @param {string} filter - The name of the filter to select. - * @param {string} filterOption - The specific filter option to apply. - * @returns {Promise} A promise that resolves when the filter is applied. - */ - async selectFilterByText(filter: string, filterOption: string): Promise { - const filterLocator = this.filtersBox.getByRole('button', { name: new RegExp(`^${filter}`) }); - await this.selectFilter(filterLocator, filterOption); - } } diff --git a/e2etests/pages/base-page.ts b/e2etests/pages/base-page.ts index ab45f0bed..f91bc2f22 100644 --- a/e2etests/pages/base-page.ts +++ b/e2etests/pages/base-page.ts @@ -21,6 +21,32 @@ export abstract class BasePage { readonly errorColor: string; // Default color for error state readonly successColor: string; // Default color for success state + // Filters + readonly filtersBox: Locator; + readonly allFilterBoxButtons: Locator; + readonly filterPopover: Locator; + readonly suggestionsFilter: Locator; + readonly dataSourceFilter: Locator; + readonly poolFilter: Locator; + readonly ownerFilter: Locator; + readonly regionFilter: Locator; + readonly serviceFilter: Locator; + readonly resourceTypeFilter: Locator; + readonly activityFilter: Locator; + readonly recommendationsFilter: Locator; + readonly constraintViolationsFilter: Locator; + readonly firstSeenFilter: Locator; + readonly lastSeenFilter: Locator; + readonly tagFilter: Locator; + readonly withoutTagFilter: Locator; + readonly metaFilter: Locator; + readonly paidNetworkTrafficFromFilter: Locator; + readonly paidNetworkTrafficToFilter: Locator; + readonly filterApplyButton: Locator; + readonly resetFiltersBtn: Locator; + readonly showMoreFiltersBtn: Locator; + readonly showLessFiltersBtn: Locator; + /** * Initializes a new instance of the BasePage class. * @param {Page} page - The Playwright page object. @@ -39,11 +65,55 @@ 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 + + //Filters + this.filtersBox = this.main.locator('xpath=(//div[.="Filters:"])[1]/..'); + this.allFilterBoxButtons = this.filtersBox.locator('button'); + this.filterPopover = this.page.locator('//div[contains(@id, "filter-popover")]'); + this.filterApplyButton = this.filterPopover.getByRole('button', { name: 'Apply' }); + + this.suggestionsFilter = this.filtersBox.getByRole('button', { name: 'Suggestions' }); + this.dataSourceFilter = this.filtersBox.getByRole('button', { name: 'Data source (' }); + this.poolFilter = this.filtersBox.getByRole('button', { name: 'Pool (' }); + this.ownerFilter = this.filtersBox.getByRole('button', { name: 'Owner (' }); + this.regionFilter = this.filtersBox.getByRole('button', { name: 'Region (' }); + this.serviceFilter = this.filtersBox.getByRole('button', { name: /^Service \(/ }); + this.resourceTypeFilter = this.filtersBox.getByRole('button', { name: 'Resource type (' }); + this.activityFilter = this.filtersBox.getByRole('button', { name: 'Activity (' }); + this.recommendationsFilter = this.filtersBox.getByRole('button', { name: 'Recommendations (' }); + this.constraintViolationsFilter = this.filtersBox.getByRole('button', { name: 'Constraint violations (' }); + this.firstSeenFilter = this.filtersBox.getByRole('button', { name: 'First seen (' }); + this.lastSeenFilter = this.filtersBox.getByRole('button', { name: 'Last seen (' }); + this.tagFilter = this.filtersBox.getByRole('button', { name: /^Tag \(/ }); + this.withoutTagFilter = this.filtersBox.getByRole('button', { name: 'Without tag (' }); + this.metaFilter = this.filtersBox.getByRole('button', { name: 'Meta (' }); + this.paidNetworkTrafficFromFilter = this.filtersBox.getByRole('button', { name: 'Paid network traffic from (' }); + this.paidNetworkTrafficToFilter = this.filtersBox.getByRole('button', { name: 'Paid network traffic to (' }); + this.resetFiltersBtn = this.main.getByRole('button', { name: 'Reset filters' }); + this.showMoreFiltersBtn = this.main.getByRole('button', { name: 'Show more' }); + this.showLessFiltersBtn = this.main.getByRole('button', { name: 'Show less' }); } /** - * Navigates to the URL of the page. - * @returns {Promise} A promise that resolves when the navigation is complete. + * Navigates to the page URL. + * + * This method navigates to either a custom URL or the default page URL and waits + * for the loading page image to disappear before continuing. + * + * @param {string | null} [customUrl=null] - Optional custom URL to navigate to. If not provided, uses the page's default URL. + * @returns {Promise} A promise that resolves when navigation is complete and the page has loaded. + * + * @example + * // Navigate to the default page URL + * await resourcesPage.navigateToURL(); + * + * @example + * // Navigate to a custom URL + * await resourcesPage.navigateToURL('/resources?filter=active'); + * + * @remarks + * This method waits for the 'load' event and also ensures the loading spinner/image + * has disappeared, providing a more reliable indication that the page is ready for interaction. */ async navigateToURL(customUrl: string = null): Promise { debugLog(`Navigating to URL: ${customUrl ? customUrl : this.url}`); @@ -51,6 +121,32 @@ export abstract class BasePage { await this.waitForLoadingPageImgToDisappear(); } + /** + * Fits the viewport to the full height of the page content. + * + * This method adjusts the browser viewport to match the full scrollable height + * of the main content wrapper, up to a maximum height limit. This is useful + * for capturing full-page screenshots or ensuring all content is visible + * without scrolling. + * + * @returns {Promise} A promise that resolves when the viewport has been resized. + * + * @example + * // Fit viewport before taking a full-page screenshot + * await resourcesPage.fitViewportToFullPage(); + * await resourcesPage.page.screenshot({ path: 'full-page.png' }); + * + * @example + * // Ensure all table rows are visible for testing + * await poolsPage.fitViewportToFullPage(); + * const rowCount = await poolsPage.table.locator('tr').count(); + * + * @remarks + * - The viewport width remains unchanged to maintain consistency + * - Maximum height is capped at 12000px to avoid GPU/OS limitations + * - Includes an 80px header height adjustment + * - If the main content wrapper is not found, returns 0 height (no resize) + */ async fitViewportToFullPage(): Promise { const { maxHeight = 12000 } = {}; const headerHeight = 80; @@ -69,25 +165,58 @@ export abstract class BasePage { } /** - * Retrieves a locator for an element based on a test ID attribute. - * This method searches for elements with either `data-test-id` or `data-testid` attributes - * matching the provided test ID value. + * Locates an element by either `data-test-id` or `data-testid` attribute. + * + * This method provides a unified way to query elements that may use either the + * hyphenated (`data-test-id`) or the camelCase-style (`data-testid`) test ID + * attribute convention, returning the first match for either variant. * * @param {string} testId - The test ID value to search for. - * @param {Locator | Page} [root=this.page] - The root element or page to perform the search within. - * Defaults to the current Playwright page. - * @returns {Locator} A Playwright locator for the matching element(s). + * @param {Locator | Page} [root=this.page] - The root element or page to scope the search within. + * Defaults to the current page, but can be narrowed to a specific locator for scoped queries. + * @returns {Locator} A Playwright locator matching elements with the given test ID under either attribute name. + * + * @example + * // Find an element by test ID anywhere on the page + * const submitBtn = basePage.getByAnyTestId('submit-button'); + * await submitBtn.click(); + * + * @example + * // Scope the search to a specific container + * const modal = page.locator('.modal'); + * const confirmBtn = basePage.getByAnyTestId('confirm-button', modal); + * await confirmBtn.click(); + * + * @remarks + * This method is useful when the codebase is inconsistent about which test ID + * attribute convention is used (`data-test-id` vs `data-testid`), allowing tests + * to remain resilient regardless of which attribute is present on the element. */ getByAnyTestId(testId: string, root: Locator | Page = this.page): Locator { return root.locator(`[data-test-id="${testId}"], [data-testid="${testId}"]`); } + /** * Selects an option from a combo box if it is not already selected. - * @param {Locator} comboBox - The locator for the combo box element. - * @param {string} option - The option to select from the combo box. - * @param {boolean} [closeList=false] - Whether to close the list after selecting the option. - * @returns {Promise} A promise that resolves when the option is selected. + * + * Reads the currently selected value and, if it differs from the desired option, + * opens the dropdown and clicks the matching option by its exact name. Optionally + * dismisses the dropdown afterwards by clicking the page body. + * + * @param {Locator} comboBox - The Playwright locator representing the combo box element. + * @param {string} option - The exact text of the option to select (case-sensitive). + * @param {boolean} [closeList=false] - When `true`, clicks the page body after selection + * to close the dropdown. Useful when subsequent interactions require the list to be dismissed. + * @returns {Promise} Resolves when the option is selected, or immediately if it was already selected. + * + * @example + * // Select 'Monthly' from a period combo box + * await basePage.selectFromComboBox(periodComboBox, 'Monthly'); + * + * @example + * // Select an option and close the dropdown afterwards + * await basePage.selectFromComboBox(regionComboBox, 'US East', true); */ async selectFromComboBox(comboBox: Locator, option: string, closeList: boolean = false): Promise { if ((await this.selectedComboBoxOption(comboBox)) !== option) { @@ -98,33 +227,27 @@ export abstract class BasePage { } /** - * Retrieves the currently selected option from a combo box. - * This method locates the text content of the selected option within the combo box - * and trims any leading or trailing whitespace. + * Retrieves the currently selected option text from a combo box. + * + * Reads the text content of the first child `div` inside the combo box element, + * which is expected to display the currently selected value, and returns it trimmed. * - * @param {Locator} comboBox - The locator for the combo box element. - * @returns {Promise} A promise that resolves to the trimmed text content of the selected option. + * @param {Locator} comboBox - The Playwright locator representing the combo box element. + * @returns {Promise} Resolves to the trimmed text of the selected option, + * or `undefined` if no text content is found. + * + * @example + * const selected = await basePage.selectedComboBoxOption(periodComboBox); + * expect(selected).toBe('Monthly'); + * + * @remarks + * Relies on the combo box having a first child `div` that holds the displayed value. + * If the combo box DOM structure differs, this method may not return the expected result. */ async selectedComboBoxOption(comboBox: Locator): Promise { return (await comboBox.locator('xpath=/div[1]').textContent())?.trim(); } - /** - * Sets up routing for the page to intercept all network requests and add an Authorization header. - * @param {string} token - The token to be used for the Authorization header. - * @returns {Promise} A promise that resolves when the routing is set up. - */ - async setupRouting(token: string): Promise { - await this.page.route('**/*', route => { - console.log(`Intercepting request to: ${route.request().url()}`); - const headers = { - ...route.request().headers(), - Authorization: `Bearer ${token}`, - }; - route.continue({ headers }); - }); - } - /** * Waits for the page to load completely. * This method uses Playwright's `waitForLoadState` to ensure the page has reached the 'load' state. @@ -140,8 +263,32 @@ export abstract class BasePage { /** * Waits for at least one canvas element on the page to have non-zero pixel data. - * This method is useful to ensure that a canvas has finished rendering before proceeding. - * @returns {Promise} A promise that resolves when the condition is met. + * + * This method polls the DOM until any `` element contains at least one + * non-zero pixel in its 2D rendering context, indicating that it has finished + * rendering. It is useful for ensuring charts or visual elements are fully painted + * before taking screenshots or making visual assertions. + * + * @param {number} [timeout=20000] - Maximum time in milliseconds to wait for a canvas + * to contain non-zero pixel data. Defaults to 20000ms (20 seconds). + * @returns {Promise} Resolves as soon as at least one canvas has rendered content, + * or rejects if the timeout is exceeded before any canvas renders. + * + * @example + * // Wait for a chart to finish rendering before taking a screenshot + * await basePage.waitForCanvas(); + * await basePage.page.screenshot({ path: 'chart.png' }); + * + * @example + * // Use a custom timeout for slow-rendering canvases + * await basePage.waitForCanvas(30000); + * + * @remarks + * - Uses `willReadFrequently: true` on the canvas context for optimised pixel reads. + * - Only requires **one** canvas to be non-empty; use `waitForAllCanvases` if all + * canvases must be rendered before proceeding. + * - If no `` elements are present on the page, this method will wait until + * the timeout is reached. */ async waitForCanvas(timeout: number = 20000): Promise { await this.page.waitForFunction( @@ -159,8 +306,27 @@ export abstract class BasePage { /** * Waits for all canvas elements on the page to have non-zero pixel data. - * This method ensures that all canvases have finished rendering before proceeding. - * @returns {Promise} A promise that resolves when the condition is met. + * + * This method polls the DOM until every `` element contains at least one + * non-zero pixel in its 2D rendering context, indicating that all canvases have + * finished rendering. Use this when multiple charts or visual elements must all be + * fully painted before proceeding. + * + * @returns {Promise} Resolves when every canvas on the page has rendered content, + * or rejects if the default Playwright timeout is exceeded. + * + * @example + * // Wait for all charts to finish rendering before taking a screenshot + * await basePage.waitForAllCanvases(); + * await basePage.page.screenshot({ path: 'dashboard.png' }); + * + * @remarks + * - Uses `willReadFrequently: true` on each canvas context for optimised pixel reads. + * - Requires **all** canvases to be non-empty; use `waitForCanvas` if only one canvas + * needs to be rendered before proceeding. + * - If no `` elements are present on the page, this method resolves immediately + * 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(() => { @@ -173,9 +339,28 @@ export abstract class BasePage { /** * Waits for the text content of an element to include the expected text. + * + * This method filters the locator to match only elements containing the specified + * text, then waits for that filtered element to appear in the DOM. It is useful for + * asserting that dynamic content has been rendered before proceeding with further + * interactions or assertions. + * * @param {Locator} locator - The locator for the element whose text content is being checked. - * @param {string} expectedText - The text expected to be included in the element's text content. - * @returns {Promise} A promise that resolves when the text content includes the expected text. + * @param {string} expectedText - The text expected to be present within the element's text content. + * @returns {Promise} Resolves when the element containing the expected text is attached to the DOM. + * + * @example + * // Wait for a success message to appear + * await basePage.waitForTextContent(basePage.tooltip, 'Saved successfully'); + * + * @example + * // Wait for a table cell to display a specific value + * await basePage.waitForTextContent(basePage.table.locator('td').first(), '$1,234.56'); + * + * @remarks + * - Uses Playwright's `filter({ hasText })` which performs a substring match, not an exact match. + * - The method waits for the element to be attached to the DOM but does not assert visibility. + * Use `toBeVisible()` for stricter visibility assertions. */ async waitForTextContent(locator: Locator, expectedText: string): Promise { await locator.filter({ hasText: expectedText }).waitFor(); @@ -183,8 +368,28 @@ export abstract class BasePage { /** * Evaluates whether a button element has the active button class. - * @param {Locator} button - The locator for the button element to be evaluated. - * @returns {Promise} A promise that resolves to a boolean indicating whether the button has the active button class. + * + * This method inspects the CSS class list of the given button element and returns + * `true` if any class name ends with `-button-activeButton`, which is the convention + * used in this codebase to mark a button as active/selected. + * + * @param {Locator} button - The Playwright locator for the button element to evaluate. + * @returns {Promise} Resolves to `true` if the button has the active class, `false` otherwise. + * + * @example + * // Check if a toggle button is active before clicking + * const isActive = await basePage.evaluateActiveButton(myToggleBtn); + * console.log(`Button is active: ${isActive}`); + * + * @example + * // Use with clickButtonIfNotActive to ensure a button is activated + * await basePage.clickButtonIfNotActive(viewToggleBtn); + * + * @remarks + * - The check is based on the CSS class suffix `-button-activeButton`, which is specific + * to MUI-based components in this project. If the component library changes, this + * detection logic may need to be updated. + * - This method evaluates the element in the browser context via `element.evaluate`. */ async evaluateActiveButton(button: Locator): Promise { return await button.evaluate(el => { @@ -206,10 +411,56 @@ export abstract class BasePage { await button.click(); } } + /** - * Brings the context of the current page to the front. - * This method is useful when multiple pages or contexts are open and you need to focus on the current page. - * @returns {Promise} A promise that resolves when the context is brought to the front. + * Checks if an element is marked as selected using the aria-selected attribute. + * + * This method retrieves the `aria-selected` attribute from the specified element + * and returns true if its value is 'true', otherwise returns false. + * This is commonly used for checking the selection state of elements in accessible + * UI components like tabs, options in listboxes, or tree items. + * + * @param {Locator} element - The Playwright locator for the element to check. + * @returns {Promise} A promise that resolves to true if the element has aria-selected="true", false otherwise. + * + * @example + * // Check if a tab is selected + * const tab = page.getByRole('tab', { name: 'Overview' }); + * const isSelected = await basePage.isAriaSelected(tab); + * if (isSelected) { + * console.log('Tab is currently selected'); + * } + * + * @example + * // Use in an assertion + * const option = page.getByRole('option', { name: 'Option 1' }); + * await expect(await basePage.isAriaSelected(option)).toBe(true); + * + * @remarks + * This method checks the ARIA attribute rather than visual state, making it + * more reliable for accessibility-compliant components. If the aria-selected + * attribute is not present, the method will return false. + */ + async isAriaSelected(element: Locator): Promise { + return (await element.getAttribute('aria-selected')) === 'true'; + } + + /** + * Brings the current page to the front of the browser window stack. + * + * This method calls Playwright's `bringToFront` on the current page, ensuring it + * is the active/focused tab. It is useful in multi-page or multi-context test + * scenarios where focus may have shifted to another page or popup. + * + * @returns {Promise} Resolves when the page has been brought to the front. + * + * @example + * // Bring the main page back into focus after a popup was opened + * await basePage.bringContextToFront(); + * + * @remarks + * - This is a thin wrapper around Playwright's `page.bringToFront()`. + * - Has no effect if the page is already the active tab. */ async bringContextToFront(): Promise { await this.page.bringToFront(); @@ -217,17 +468,58 @@ export abstract class BasePage { /** * Waits for an element to be detached from the DOM. - * @param {Locator} element - The locator for the element to wait for detachment. - * @returns {Promise} A promise that resolves when the element is detached. + * + * This method waits until the specified element is removed from the DOM entirely. + * It is useful for asserting that a modal, tooltip, spinner, or other transient + * element has been fully dismissed before proceeding. + * + * @param {Locator} element - The Playwright locator for the element to wait for detachment. + * @returns {Promise} Resolves when the element has been detached from the DOM, + * or rejects if the default Playwright timeout is exceeded. + * + * @example + * // Wait for a loading spinner to be removed before interacting with the page + * await basePage.waitForElementDetached(basePage.progressBar); + * + * @example + * // Wait for a modal to be closed and removed from the DOM + * await confirmModal.clickCancel(); + * await basePage.waitForElementDetached(confirmModal.dialog); + * + * @remarks + * - Uses Playwright's `waitFor({ state: 'detached' })`, which waits for the element + * to be removed from the DOM, not just hidden. + * - If the element is already detached when this method is called, it resolves immediately. */ async waitForElementDetached(element: Locator): Promise { await element.waitFor({ state: 'detached' }); } /** - * Introduces a delay for screenshot updates, required to ensure that the target is fully loaded. - * @param {number} [timeout=5000] - The delay duration in milliseconds. - * @returns {Promise} A promise that resolves after the specified timeout. + * Introduces a conditional delay intended for use during screenshot update runs. + * + * When the `SCREENSHOT_UPDATE_DELAY` environment variable is set to `'true'`, + * this method pauses execution for the specified duration to allow visual elements + * to fully settle before a screenshot is captured. When the variable is not set, + * this method resolves immediately without any delay. + * + * @param {number} [timeout=5000] - The delay duration in milliseconds. Defaults to 5000ms (5 seconds). + * @returns {Promise} Resolves after the delay if the env var is set, or immediately otherwise. + * + * @example + * // Allow animations to settle before capturing a baseline screenshot + * await basePage.screenshotUpdateDelay(); + * await expect(basePage.page).toMatchSnapshot('dashboard.png'); + * + * @example + * // Use a shorter delay for faster screenshot update runs + * await basePage.screenshotUpdateDelay(2000); + * + * @remarks + * - This method is a no-op in normal test runs; the delay is only applied when + * `SCREENSHOT_UPDATE_DELAY=true` is set in the environment. + * - Prefer using this over a bare `delay()` call in screenshot-related tests to + * keep the delay behaviour configurable and skippable in CI. */ async screenshotUpdateDelay(timeout: number = 5000): Promise { if (process.env.SCREENSHOT_UPDATE_DELAY === 'true') { @@ -324,7 +616,7 @@ export abstract class BasePage { * @returns {Promise} A promise that resolves to the total sum of currency values, rounded to two decimal places. */ async sumCurrencyColumn(columnLocator: Locator, nextPageBtn: Locator): Promise { - await columnLocator.last().waitFor({'timeout': 10000}) + await columnLocator.last().waitFor({ timeout: 10000 }); let totalSum = 0; while (true) { @@ -558,7 +850,7 @@ export abstract class BasePage { * @param {Locator} locator - The Playwright locator representing the element to be clicked. * @returns {Promise} A promise that resolves when the click action is completed. */ - async clickLocator(locator: Locator): Promise { + async click(locator: Locator): Promise { await locator.click(); } @@ -639,4 +931,101 @@ export abstract class BasePage { await input.fill(value); } } + + /** + * Selects a filter by its text and applies the specified filter option. + * + * This method locates a filter button within the filters box by matching its name + * with the provided `filter` parameter. It then applies the specified `filterOption` + * to the selected filter. + * + * @param {string} filter - The name of the filter to select. + * @param {string} filterOption - The specific filter option to apply. + * @returns {Promise} A promise that resolves when the filter is applied. + */ + async selectFilterByText(filter: string, filterOption: string): Promise { + const filterLocator = this.filtersBox.getByRole('button', { name: new RegExp(`^${filter}`) }); + await this.selectFilter(filterLocator, filterOption); + } + + /** + * Clicks the "Show More Filters" button on the Resources page. + * This method interacts with the `showMoreFiltersBtn` locator to expand the filters section. + * + * @returns {Promise} Resolves when the button is clicked. + */ + async clickShowMoreFilters(): Promise { + await this.showMoreFiltersBtn.click(); + } + + /** + * Resets all filters on the page. + * + * This method checks if the "Reset Filters" button is visible, clicks it to reset all filters, + * and optionally waits for the page loader to disappear and the canvas to update. + * + * @param {boolean} [wait=true] - Whether to wait for the page loader to disappear and the canvas to load after resetting the filters. + * @returns {Promise} Resolves when the filters are reset and the optional wait is complete. + */ + async resetFilters(wait: boolean = true): Promise { + if (await this.resetFiltersBtn.isVisible()) { + debugLog('Resetting all filters'); + await this.resetFiltersBtn.click(); + await this.resetFiltersBtn.waitFor({ state: 'hidden' }); + if (wait) { + await this.waitForAllProgressBarsToDisappear(); + await this.waitForCanvas(); + } + } + } + + /** + * 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).click(); + await this.filterApplyButton.click(); + } + } + + /** + * Retrieves the currently active filter button from the filters box. + * + * This method locates and returns the filter button that has the "contained" style, + * which indicates it is currently active or selected. Active filters are identified + * by the presence of the "MuiButton-contained" CSS class. + * + * @returns {Locator} A Playwright locator for the active filter button. + * + * @example + * // Get the active filter and verify it's visible + * const activeFilter = await resourcesPage.getActiveFilter(); + * await expect(activeFilter).toBeVisible(); + * + * @example + * // Get the text of the active filter + * const activeFilter = resourcesPage.getActiveFilter(); + * const filterText = await activeFilter.textContent(); + * console.log(`Active filter: ${filterText}`); + * + * @remarks + * This method uses XPath to find buttons with the MUI "contained" variant class, + * which is the visual style applied to active/selected filter buttons in the UI. + * Note that this method is synchronous and returns a Locator immediately. + */ + getActiveFilter(): Locator { + return this.filtersBox.locator('//button[contains(@class, "MuiButton-contained")]'); + } } diff --git a/e2etests/pages/cloud-accounts-page.ts b/e2etests/pages/cloud-accounts-page.ts index f35e7ee1a..a8430fb24 100644 --- a/e2etests/pages/cloud-accounts-page.ts +++ b/e2etests/pages/cloud-accounts-page.ts @@ -66,10 +66,30 @@ export class CloudAccountsPage extends BasePage { } + /** + * Navigates to the Cloud Accounts page and waits for it to be fully loaded. + * + * This method navigates to the default Cloud Accounts URL, waits for all progress + * bars to disappear, and then waits for at least one cloud account link to be present + * in the table, ensuring the page is fully ready for interaction before proceeding. + * + * @returns {Promise} Resolves when the page is fully loaded and cloud account links are visible. + * + * @example + * // Navigate to the Cloud Accounts page before running a test + * await cloudAccountsPage.navigateToCloudAccountsPage(); + * await expect(cloudAccountsPage.heading).toBeVisible(); + * + * @remarks + * - Prefer this method over a bare `navigateToURL()` call when tests require the + * cloud account table to be populated before proceeding. + * - If no cloud account links are present, this method will hang until the default + * Playwright timeout is exceeded. + */ async navigateToCloudAccountsPage(): Promise { - await this.navigateToURL(); - await this.waitForAllProgressBarsToDisappear(); - await this.allCloudAccountLinks.last().waitFor(); + await this.navigateToURL(); + await this.waitForAllProgressBarsToDisappear(); + await this.allCloudAccountLinks.last().waitFor(); } /** diff --git a/e2etests/pages/create-anomaly-page.ts b/e2etests/pages/create-anomaly-page.ts deleted file mode 100644 index 4a80a3a77..000000000 --- a/e2etests/pages/create-anomaly-page.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {Locator, Page} from "@playwright/test"; -import {BaseCreatePage} from "./base-create-page"; - -/** - * Represents the Create Anomaly Page. - * Extends the BaseCreatePage class. - */ -export class CreateAnomalyPage extends BaseCreatePage { - readonly heading: Locator; - readonly evaluationPeriodInput: Locator; - readonly thresholdInput: Locator; - - /** - * Initializes a new instance of the CreateAnomalyPage class. - * @param {Page} page - The Playwright page object. - */ - constructor(page: Page) { - super(page, '/anomalies/create'); - this.heading = this.main.getByTestId('lbl_create_anomaly_detection_policy'); - this.evaluationPeriodInput = this.main.getByTestId('input_evaluationPeriod'); - this.thresholdInput = this.main.getByTestId('input_threshold'); - } -} diff --git a/e2etests/pages/events-page.ts b/e2etests/pages/events-page.ts index b36f1575b..3d002d468 100644 --- a/e2etests/pages/events-page.ts +++ b/e2etests/pages/events-page.ts @@ -13,7 +13,6 @@ export class EventsPage extends BasePage { readonly errorBtn: Locator; readonly noEventsMessage: Locator; - /** * Initializes a new instance of the EventsPage class. * @param {Page} page - The Playwright page object. @@ -50,15 +49,6 @@ export class EventsPage extends BasePage { return this.main.locator(`//p[contains(text(), "${text}")]`) } - /** - * Gets an event by matching text using a RegExp pattern. - * @param {RegExp} pattern - The regular expression pattern to match. - * @returns {Locator} A locator for the event matching the pattern. - */ - getEventByPattern(pattern: RegExp): Locator { - return this.main.getByText(pattern); - } - /** * Gets an event by matching multiple text conditions. * @returns {Locator} A locator for the event matching both text conditions. diff --git a/e2etests/pages/perspectives-page.ts b/e2etests/pages/perspectives-page.ts index b6a256b49..0268d32f2 100644 --- a/e2etests/pages/perspectives-page.ts +++ b/e2etests/pages/perspectives-page.ts @@ -1,5 +1,6 @@ -import {BasePage} from "./base-page"; -import {Locator, Page} from "@playwright/test"; +import { BasePage } from './base-page'; +import { Locator, Page } from '@playwright/test'; +import { debugLog } from '../utils/debug-logging'; /** * Represents the Perspectives Page. @@ -7,6 +8,15 @@ import {Locator, Page} from "@playwright/test"; */ export class PerspectivesPage extends BasePage { readonly heading: Locator; + readonly breakdownByColumn: Locator; + readonly categorizeByColumn: Locator; + readonly groupByColumn: Locator; + readonly filtersColumn: Locator; + readonly noPerspectivesMessage: Locator; + readonly totalCountValue: Locator; + readonly deleteSideModal: Locator; + readonly sideModalDeleteButton: Locator; + readonly allDeleteButtons: Locator; /** * Initializes a new instance of the PerspectivesPage class. @@ -15,5 +25,151 @@ export class PerspectivesPage extends BasePage { constructor(page: Page) { super(page, '/resources/perspectives'); this.heading = this.page.getByTestId('lbl_perspectives'); + this.breakdownByColumn = this.page.locator('//td[2]'); + this.categorizeByColumn = this.page.locator('//td[3]'); + this.groupByColumn = this.page.locator('//td[4]'); + this.filtersColumn = this.page.locator('//td[5]'); + this.noPerspectivesMessage = this.table.locator('td', { hasText: 'No perspectives' }); + this.totalCountValue = this.main.locator('//div[contains(text(), "Total")]/following-sibling::div'); + this.deleteSideModal = this.page.getByTestId('smodal_delete_perspective'); + this.sideModalDeleteButton = this.deleteSideModal.getByRole('button', { name: 'Delete' }); + this.allDeleteButtons = this.table.locator('//*[@data-testid="DeleteOutlinedIcon"]'); + } + + /** + * Gets the table row element for a specific perspective by its name. + * + * This method uses XPath to find the anchor tag containing the perspective name + * and returns the ancestor table row element. + * + * @param {string} perspectiveName - The name of the perspective to locate in the table. + * @returns {Promise} A promise that resolves to the locator for the table row containing the perspective. + * + * @example + * // Get the row for "My Custom Perspective" + * const row = await perspectivesPage.getTableRowByPerspectiveName("My Custom Perspective"); + */ + async getTableRowByPerspectiveName(perspectiveName: string): Promise { + return this.table.locator(`//a[contains(text(), "${perspectiveName}")]/ancestor::tr`); + } + + /** + * Clicks the delete button for a specific perspective. + * + * This method performs the following steps: + * 1. Locates the table row containing the specified perspective name + * 2. Finds the delete icon button within that row + * 3. Clicks the delete button to initiate the deletion process + * + * @param {string} perspectiveName - The name of the perspective to delete. + * @returns {Promise} A promise that resolves when the delete button is clicked. + * + * @example + * // Delete a perspective named "Temporary Perspective" + * await perspectivesPage.clickDeleteButtonForPerspective("Temporary Perspective"); + * + * @remarks + * This method only clicks the delete button. You may need to confirm the deletion + * in a subsequent modal dialog depending on the application's workflow. + */ + async clickDeleteButtonForPerspective(perspectiveName: string): Promise { + const perspectiveRow = await this.getTableRowByPerspectiveName(perspectiveName); + const deleteButton = perspectiveRow.locator('//*[@data-testid="DeleteOutlinedIcon"]'); + await deleteButton.click(); + } + + /** + * Deletes a perspective by name. + * + * This method performs the complete deletion workflow: + * 1. Clicks the delete button for the specified perspective + * 2. Confirms the deletion by clicking the delete button in the side modal + * 3. Waits for the side modal to close (detached from DOM) + * + * @param {string} perspectiveName - The name of the perspective to delete. + * @returns {Promise} A promise that resolves when the perspective is deleted and the modal is closed. + * + * @example + * // Delete a perspective named "My Custom Perspective" + * await perspectivesPage.deletePerspective("My Custom Perspective"); + * + * @remarks + * This method waits for the delete modal to be detached, ensuring the deletion + * operation is complete before proceeding with subsequent actions. + */ + async deletePerspective(perspectiveName: string): Promise { + await this.clickDeleteButtonForPerspective(perspectiveName); + await this.sideModalDeleteButton.click(); + await this.sideModalDeleteButton.waitFor({ state: 'detached' }); + } + + /** + * Retrieves the total count of perspectives displayed on the page. + * + * This method extracts the numeric value from the "Total" count element, + * which displays the number of perspectives currently available. + * + * @returns {Promise} A promise that resolves to the total number of perspectives. + * + * @example + * // Get the current count of perspectives + * const count = await perspectivesPage.getPerspectivesCount(); + * console.log(`Total perspectives: ${count}`); + * + * @example + * // Use in an assertion + * const initialCount = await perspectivesPage.getPerspectivesCount(); + * await perspectivesPage.deletePerspective("Test Perspective"); + * const newCount = await perspectivesPage.getPerspectivesCount(); + * expect(newCount).toBe(initialCount - 1); + * + * @remarks + * The method parses the text content as an integer and logs the value for debugging purposes. + */ + async getPerspectivesCount(): Promise { + const totalCountText = (await this.totalCountValue.textContent()).trim(); + debugLog(`Total count text: ${totalCountText}`); + return parseInt(totalCountText, 10); + } + + /** + * Deletes all perspectives from the page. + * + * This method checks if there are any perspectives present (by checking if the + * "No perspectives" message is not visible). If perspectives exist, it continuously + * deletes the first perspective until none remain: + * 1. Checks if delete buttons exist + * 2. Clicks the first delete button + * 3. Confirms deletion in the side modal + * 4. Waits for the modal to close + * 5. Repeats until no delete buttons remain + * + * @returns {Promise} A promise that resolves when all perspectives are deleted. + * + * @example + * // Delete all perspectives before starting a test + * await perspectivesPage.deleteAllPerspectives(); + * + * @example + * // Clean up after test + * test.afterEach(async ({ perspectivesPage }) => { + * await perspectivesPage.deleteAllPerspectives(); + * }); + * + * @remarks + * This method safely handles the case where no perspectives exist by checking + * for the "No perspectives" message first. It uses a while loop to always delete + * the first button, which prevents issues with re-indexing after each deletion. + */ + async deleteAllPerspectives(): Promise { + if(!await this.noPerspectivesMessage.isVisible()) { + debugLog(`Deleting all perspectives...`); + while ((await this.allDeleteButtons.count()) > 0) { + await this.allDeleteButtons.first().click(); + await this.sideModalDeleteButton.click(); + await this.sideModalDeleteButton.waitFor({ state: 'detached' }); + await this.waitForAllProgressBarsToDisappear(); + } + } } } diff --git a/e2etests/pages/resources-page.ts b/e2etests/pages/resources-page.ts index 2a34c6d01..34f39635e 100644 --- a/e2etests/pages/resources-page.ts +++ b/e2etests/pages/resources-page.ts @@ -19,44 +19,33 @@ export class ResourcesPage extends BasePage { readonly possibleSavingsCard: Locator; readonly possibleMonthlySavingsValue: Locator; - // Filters - readonly filtersBox: Locator; - readonly allFilterBoxButtons: Locator; - readonly filterPopover: Locator; - readonly suggestionsFilter: Locator; - readonly dataSourceFilter: Locator; - readonly poolFilter: Locator; - readonly ownerFilter: Locator; - readonly regionFilter: Locator; - readonly serviceFilter: Locator; - readonly resourceTypeFilter: Locator; - readonly activityFilter: Locator; - readonly recommendationsFilter: Locator; - readonly constraintViolationsFilter: Locator; - readonly firstSeenFilter: Locator; - readonly lastSeenFilter: Locator; - readonly tagFilter: Locator; - readonly withoutTagFilter: Locator; - readonly metaFilter: Locator; - readonly paidNetworkTrafficFromFilter: Locator; - readonly paidNetworkTrafficToFilter: Locator; - readonly k8sNodeFilter: Locator; - readonly k8sServiceFilter: Locator; - readonly k8sNamespaceFilter: Locator; - readonly billingOnlyOption: Locator; - readonly filterApplyButton: Locator; - readonly resetFiltersBtn: Locator; - readonly showMoreFiltersBtn: Locator; - readonly showLessFiltersBtn: Locator; + readonly perspectivesSideModal: Locator; + readonly perspectivesSeeAllPerspectivesLink: Locator; + readonly perspectivesApplyBtn: Locator; + + // Save perspective modal + readonly savePerspectiveSideModal: Locator; + readonly savePerspectiveSaveAsInput: Locator; + readonly savePerspectiveBreakDownByValue: Locator; + readonly savePerspectiveCategorizeByValue: Locator; + readonly savePerspectiveGroupByTypeValue: Locator; + readonly savePerspectiveGroupByValue: Locator; + readonly savePerspectiveFiltersValue: Locator; + readonly savePerspectiveFiltersOptionValue: Locator; + readonly savePerspectiveNoFiltersValue: Locator; + readonly savePerspectiveSaveBtn: Locator; // Tabs readonly tabExpensesBtn: Locator; readonly tabResourceCountBtn: Locator; readonly tabTagsBtn: Locator; + readonly tabMetaBtn: Locator; // Charts readonly categorizeBySelect: Locator; + readonly metaCategorizeBySelect: Locator; readonly expensesSelect: Locator; + readonly breakdownTypeSelect: Locator; readonly showWeekendsCheckbox: Locator; readonly searchInput: Locator; readonly expensesBreakdownChart: Locator; @@ -73,6 +62,9 @@ export class ResourcesPage extends BasePage { readonly groupByOwnerBtn: Locator; readonly groupByOwnerCloseBtn: Locator; readonly groupByTagSelect: Locator; + readonly selectedGroupByTagItem: Locator; + readonly selectedGroupByTagKey: Locator; + readonly selectedGroupByTagValue: Locator; //Column selection readonly columnsBtn: Locator; @@ -125,46 +117,42 @@ export class ResourcesPage extends BasePage { this.possibleSavingsCard = this.main.getByTestId('card_possible_savings'); this.possibleMonthlySavingsValue = this.possibleSavingsCard.getByTestId('p_savings_value'); - //Filters - this.filtersBox = this.main.locator('xpath=(//div[.="Filters:"])[1]/..'); - this.allFilterBoxButtons = this.filtersBox.locator('button'); - this.filterPopover = this.page.locator('//div[contains(@id, "filter-popover")]'); - - this.suggestionsFilter = this.filtersBox.getByRole('button', { name: 'Suggestions' }); - this.dataSourceFilter = this.filtersBox.getByRole('button', { name: 'Data source (' }); - this.poolFilter = this.filtersBox.getByRole('button', { name: 'Pool (' }); - this.ownerFilter = this.filtersBox.getByRole('button', { name: 'Owner (' }); - this.regionFilter = this.filtersBox.getByRole('button', { name: 'Region (' }); - this.serviceFilter = this.filtersBox.getByRole('button', { name: /^Service \(/ }); - this.resourceTypeFilter = this.filtersBox.getByRole('button', { name: 'Resource type (' }); - this.activityFilter = this.filtersBox.getByRole('button', { name: 'Activity (' }); - this.recommendationsFilter = this.filtersBox.getByRole('button', { name: 'Recommendations (' }); - this.constraintViolationsFilter = this.filtersBox.getByRole('button', { name: 'Constraint violations (' }); - this.firstSeenFilter = this.filtersBox.getByRole('button', { name: 'First seen (' }); - this.lastSeenFilter = this.filtersBox.getByRole('button', { name: 'Last seen (' }); - this.tagFilter = this.filtersBox.getByRole('button', { name: /^Tag \(/ }); - this.withoutTagFilter = this.filtersBox.getByRole('button', { name: 'Without tag (' }); - this.metaFilter = this.filtersBox.getByRole('button', { name: 'Meta (' }); - this.paidNetworkTrafficFromFilter = this.filtersBox.getByRole('button', { name: 'Paid network traffic from (' }); - this.paidNetworkTrafficToFilter = this.filtersBox.getByRole('button', { name: 'Paid network traffic to (' }); - this.k8sNodeFilter = this.filtersBox.getByRole('button', { name: 'K8s node (' }); - this.k8sServiceFilter = this.filtersBox.getByRole('button', { name: 'K8s service (' }); - this.k8sNamespaceFilter = this.filtersBox.getByRole('button', { name: 'K8s namespace (' }); - - this.billingOnlyOption = this.filterPopover.getByLabel('Billing only'); - this.filterApplyButton = this.filterPopover.getByRole('button', { name: 'Apply' }); - this.resetFiltersBtn = this.main.getByRole('button', { name: 'Reset filters' }); - this.showMoreFiltersBtn = this.main.getByRole('button', { name: 'Show more' }); - this.showLessFiltersBtn = this.main.getByRole('button', { name: 'Show less' }); + // Perspectives side modal + this.perspectivesSideModal = this.page.getByTestId('smodal_perspective'); + this.perspectivesSeeAllPerspectivesLink = this.perspectivesSideModal.getByRole('link', { name: 'See all Perspectives' }); + this.perspectivesApplyBtn = this.perspectivesSideModal.getByRole('button', { name: 'Apply' }); + + // Save perspective modal + this.savePerspectiveSideModal = this.page.getByTestId('smodal_save_perspective'); + this.savePerspectiveSaveAsInput = this.savePerspectiveSideModal.getByTestId('input_save_as'); + this.savePerspectiveBreakDownByValue = this.savePerspectiveSideModal.locator( + '//div[contains(text(), "Breakdown by")]/following-sibling::div' + ); + this.savePerspectiveCategorizeByValue = this.savePerspectiveSideModal.locator( + '//div[contains(text(), "Categorize by")]/following-sibling::div' + ); + this.savePerspectiveGroupByTypeValue = this.savePerspectiveSideModal.locator( + '//div[contains(text(), "Group by")]/following-sibling::div//div[1]/div[1]' + ); + this.savePerspectiveGroupByValue = this.savePerspectiveSideModal.locator( + '//div[contains(text(), "Group by")]/following-sibling::div//div[1]/div[2]' + ); + this.savePerspectiveFiltersValue = this.savePerspectiveSideModal.locator('//h4[.="Filters"]/../div/div[1]'); + this.savePerspectiveFiltersOptionValue = this.savePerspectiveSideModal.locator('//h4[.="Filters"]/../div/div[2]'); + this.savePerspectiveNoFiltersValue = this.savePerspectiveSideModal.locator('//div[contains(text(), "Filters")]/following-sibling::div'); + this.savePerspectiveSaveBtn = this.savePerspectiveSideModal.getByRole('button', { name: 'Save' }); //tabs this.tabExpensesBtn = this.main.getByTestId('tab_expenses'); this.tabResourceCountBtn = this.main.getByTestId('tab_counts'); this.tabTagsBtn = this.main.getByTestId('tab_tags'); + this.tabMetaBtn = this.main.getByTestId('tab_meta'); // Charts this.categorizeBySelect = this.main.getByTestId('resource-categorize-by-selector-select'); + this.metaCategorizeBySelect = this.main.getByTestId('resources-meta-categorize-by-selector-select'); this.expensesSelect = this.main.getByTestId('expenses-split-selector-select'); + this.breakdownTypeSelect = this.main.getByTestId('resources-meta-breakdown-type-selector-select'); this.showWeekendsCheckbox = this.main.getByLabel('Show weekends'); this.searchInput = this.main.getByPlaceholder('Search'); @@ -182,6 +170,9 @@ export class ResourcesPage extends BasePage { this.groupByPoolCloseBtn = this.main.getByTestId('btn_ls_item_pool_close'); this.groupByOwnerBtn = this.main.getByTestId('selector_owner'); this.groupByTagSelect = this.main.getByTestId('selector_tag'); + this.selectedGroupByTagItem = this.main.getByTestId('chip_ls_item_tag'); + this.selectedGroupByTagKey = this.selectedGroupByTagItem.getByTestId('chip_ls_item_tag_key'); + this.selectedGroupByTagValue = this.selectedGroupByTagItem.getByTestId('chip_ls_item_tag_value'); //Column selection this.columnsBtn = this.main.getByTestId('btn_columns'); @@ -217,6 +208,39 @@ export class ResourcesPage extends BasePage { this.navigateNextIcon = this.getByAnyTestId('NavigateNextIcon', this.main); } + /** + * 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. + * + * @returns {Promise} Resolves when the page is loaded, filters are reset, and + * the first resource table item is visible. + * + * @example + * // Use in a beforeEach to ensure a clean state before each test + * test.beforeEach(async ({ resourcesPage }) => { + * 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. + */ + async navigateToResourcesPageAndResetFilters(): Promise { + await this.navigateToURL('/resources'); + await this.waitForAllProgressBarsToDisappear(); + await this.waitForCanvas(); + await this.resetFilters(); + await this.waitForPageLoad(); + await this.firstResourceItemInTable.waitFor({ timeout: 15000 }); + } + /** * Clicks the "Expenses" tab on the Resources page. * This method interacts with the `tabExpensesBtn` locator and waits for the canvas to update. @@ -225,8 +249,13 @@ export class ResourcesPage extends BasePage { */ async clickExpensesTab(wait = true): Promise { debugLog('Clicking ExpensesTab'); - await this.tabExpensesBtn.click(); - if (wait) await this.waitForCanvas(); + if ((await this.tabExpensesBtn.getAttribute('aria-selected')) !== 'true') { + await this.tabExpensesBtn.click(); + } + if (wait) { + await this.waitForCanvas(); + await this.waitForAllProgressBarsToDisappear(); + } } /** @@ -238,7 +267,10 @@ export class ResourcesPage extends BasePage { async clickResourceCountTab(wait = true): Promise { debugLog('Clicking Resource Count tab'); await this.tabResourceCountBtn.click(); - if (wait) await this.waitForCanvas(); + if (wait) { + await this.waitForCanvas(); + await this.waitForAllProgressBarsToDisappear(); + } } /** @@ -250,7 +282,39 @@ export class ResourcesPage extends BasePage { async clickTagsTab(wait = true): Promise { debugLog('Clicking Tags Tab'); await this.tabTagsBtn.click(); - if (wait) await this.waitForCanvas(); + if (wait) { + await this.waitForCanvas(); + await this.waitForAllProgressBarsToDisappear(); + } + } + + /** + * Clicks the "Meta" tab on the Resources page. + * + * This method interacts with the `tabMetaBtn` locator and optionally waits for + * the canvas to finish rendering and all progress bars to disappear after the tab + * is clicked. + * + * @param {boolean} [wait=true] - Whether to wait for the canvas and progress bars after clicking. + * Set to `false` when chaining multiple tab interactions without needing to wait between them. + * @returns {Promise} Resolves when the tab is clicked and the optional wait is complete. + * + * @example + * // Click the Meta tab and wait for the chart to render + * await resourcesPage.clickMetaTab(); + * + * @example + * // Click without waiting, e.g. when chaining with a subsequent selection + * await resourcesPage.clickMetaTab(false); + * await resourcesPage.selectMetaCategorizeBy('Region'); + */ + async clickMetaTab(wait = true): Promise { + debugLog('Clicking Meta Tab'); + await this.tabMetaBtn.click(); + if (wait) { + await this.waitForCanvas(); + await this.waitForAllProgressBarsToDisappear(); + } } /** @@ -265,73 +329,73 @@ export class ResourcesPage extends BasePage { } /** - * Resets all filters on the Resources page. - * - * This method checks if the "Reset Filters" button is visible, clicks it to reset all filters, - * and optionally waits for the page loader to disappear and the canvas to update. + * 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 {boolean} [wait=true] - Whether to wait for the page loader to disappear and the canvas to load after resetting the filters. - * @returns {Promise} Resolves when the filters are reset and the optional wait is complete. + * @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. + * @returns {Promise} Resolves when the option is selected and the optional wait is complete. */ - async resetFilters(wait: boolean = true): Promise { - if (await this.resetFiltersBtn.isVisible()) { - debugLog('Resetting all filters'); - await this.resetFiltersBtn.click(); - if (wait) { - await this.waitForAllProgressBarsToDisappear(); - await this.waitForCanvas(); - } + async selectCategorizeBy(option: string, wait: boolean = true): Promise { + await this.selectFromComboBox(this.categorizeBySelect, option); + if (wait) { + await this.waitForCanvas(); + await this.waitForAllProgressBarsToDisappear(); } } /** - * Applies the "Billing only" filter on the Resources page. - * This method clicks the activity filter, selects the "Billing only" option, and applies the filter. - * It logs the action and waits for the canvas to update. + * Selects an option from the "Categorize By" dropdown on the Meta tab of the Resources page. * - * @returns {Promise} Resolves when the filter is applied and the canvas is updated. - */ - async clickActivityFilterBillingOnlyOptionAndApply(): Promise { - await this.activityFilter.click(); - await this.billingOnlyOption.click(); - await this.filterApplyButton.click(); - await this.waitForCanvas(); - } - - /** - * Clicks the "Show More Filters" button on the Resources page. - * This method interacts with the `showMoreFiltersBtn` locator to expand the filters section. + * This method uses the `metaCategorizeBySelect` locator to select the specified option + * and optionally waits for the canvas to update and all progress bars to disappear + * after the selection. * - * @returns {Promise} Resolves when the button is clicked. - */ - async clickShowMoreFilters(): Promise { - await this.showMoreFiltersBtn.click(); - } - - /** - * Toggles the visibility of the legend on the Resources page. - * This method interacts with the `showLegend` locator and logs the action. + * @param {string} option - The option to select from the Meta Categorize By dropdown. + * @param {boolean} [wait=true] - Whether to wait for the canvas and progress bars after selection. + * @returns {Promise} Resolves when the option is selected and the optional wait is complete. * - * @returns {Promise} Resolves when the legend visibility is toggled. + * @example + * // Select a meta categorize by option and wait for the chart to update + * await resourcesPage.selectMetaCategorizeBy('Service name'); + * + * @example + * // Select without waiting, e.g. when chaining multiple selections + * await resourcesPage.selectMetaCategorizeBy('Region', false); */ - async clickShowLegend(): Promise { - await this.showLegend.click(); + async selectMetaCategorizeBy(option: string, wait: boolean = true): Promise { + await this.selectFromComboBox(this.metaCategorizeBySelect, option); + if (wait) { + await this.waitForCanvas(); + await this.waitForAllProgressBarsToDisappear(); + } } /** - * 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. + * Selects an option from the "Breakdown Type" dropdown on the Meta tab of the Resources page. * - * @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. + * This method uses the `breakdownTypeSelect` locator to select the specified option + * and optionally waits for the canvas to update and all progress bars to disappear + * after the selection. + * + * @param {string} option - The option to select from the Breakdown Type dropdown. + * @param {boolean} [wait=true] - Whether to wait for the canvas and progress bars after selection. * @returns {Promise} Resolves when the option is selected and the optional wait is complete. + * + * @example + * // Select a breakdown type and wait for the chart to update + * await resourcesPage.selectBreakdownType('Daily'); + * + * @example + * // Select without waiting, e.g. when chaining multiple selections + * await resourcesPage.selectBreakdownType('Weekly', false); */ - async selectCategorizeBy(option: string, wait: boolean = true): Promise { - await this.selectFromComboBox(this.categorizeBySelect, option); + async selectBreakdownType(option: string, wait: boolean = true): Promise { + await this.selectFromComboBox(this.breakdownTypeSelect, option); if (wait) { - await this.waitForPageLoad(); await this.waitForCanvas(); + await this.waitForAllProgressBarsToDisappear(); } } @@ -346,6 +410,7 @@ export class ResourcesPage extends BasePage { async selectExpenses(option: string): Promise { await this.selectFromComboBox(this.expensesSelect, option); await this.waitForCanvas(); + await this.waitForAllProgressBarsToDisappear(); } /** @@ -358,16 +423,6 @@ export class ResourcesPage extends BasePage { await this.groupByPoolBtn.click(); } - /** - * Closes the "Group by Pool" section on the Resources page. - * This method interacts with the `groupByPoolCloseBtn` locator. - * - * @returns {Promise} Resolves when the section is closed. - */ - async clickGroupByPoolClose(): Promise { - await this.groupByPoolCloseBtn.click(); - } - /** * Clicks the "Group by Owner" button on the Resources page. * This method interacts with the `groupByOwnerBtn` locator. @@ -378,16 +433,6 @@ export class ResourcesPage extends BasePage { await this.groupByOwnerBtn.click(); } - /** - * Closes the "Group by Owner" section on the Resources page. - * This method interacts with the `groupByOwnerCloseBtn` locator. - * - * @returns {Promise} Resolves when the section is closed. - */ - async clickGroupByOwnerClose(): Promise { - await this.groupByOwnerCloseBtn.click(); - } - /** * Selects a tag from the "Group by Tag" dropdown on the Resources page. * This method interacts with the `groupByTagSelect` locator and selects the specified tag. @@ -414,6 +459,16 @@ export class ResourcesPage extends BasePage { await this.columnsBtn.click(); } + /** + * Toggles the visibility of the legend on the Resources page. + * This method interacts with the `showLegend` locator and logs the action. + * + * @returns {Promise} Resolves when the legend visibility is toggled. + */ + async toggleShowLegend(): Promise { + await this.showLegend.click(); + } + /** * Toggles a specific column in the table on the Resources page. * This method interacts with various column toggle locators based on the provided toggle name. @@ -491,6 +546,7 @@ export class ResourcesPage extends BasePage { */ async clearGrouping(): Promise { await this.clearIcon.click(); + await this.clearIcon.waitFor({ state: 'hidden' }); } /** @@ -503,4 +559,141 @@ export class ResourcesPage extends BasePage { const value = await this.resourceCountValue.textContent(); // Get the text content of the resource count element return parseInt(value); // Parse the text content into an integer and return it } + + /** + * Fills in the perspective name in the save perspective modal. + * + * This method types the given name into the "Save as" input field and then clicks + * the modal background to close any open dropdowns and ensure the Save button + * becomes enabled before proceeding. + * + * @param {string} perspectiveName - The name to enter into the "Save as" input field. + * @returns {Promise} Resolves when the name has been filled and the modal has been clicked. + * + * @example + * // Fill in the perspective name without immediately saving + * await resourcesPage.fillSavePerspectiveName('My Perspective'); + * await expect(resourcesPage.savePerspectiveSaveBtn).toBeEnabled(); + * + * @remarks + * - This method is called internally by `savePerspective` as part of the full save workflow. + * - The modal click after filling is required to dismiss any open combo box dropdowns + * and trigger validation, which enables the Save button. + */ + async fillSavePerspectiveName(perspectiveName: string): Promise { + await this.savePerspectiveSaveAsInput.fill(perspectiveName); + // Click the modal to close the dropdowns and ensure the Save button is enabled + await this.savePerspectiveSideModal.click(); + } + + /** + * Saves a new perspective with the specified name. + * + * This method performs the save perspective workflow: + * 1. Fills in the perspective name in the "Save as" input field + * 2. Clicks the save perspective side modal to ensure focus + * 3. Clicks the Save button to complete the save operation + * + * @param {string} perspectiveName - The name to give the new perspective. + * @returns {Promise} A promise that resolves when the perspective is saved. + * + * @example + * // Save a new perspective named "Development Resources" + * await resourcesPage.savePerspective("Development Resources"); + * + * @remarks + * This method assumes the save perspective modal is already open. Make sure to + * open the modal (e.g., by clicking the "Save perspective" button) before calling this method. + */ + async savePerspective(perspectiveName: string): Promise { + await this.fillSavePerspectiveName(perspectiveName); + await this.savePerspectiveSaveBtn.click(); + } + + /** + * Retrieves a perspective button locator by its name from the perspectives side modal. + * + * This method searches for a button with the specified name within the perspectives + * side modal and returns a Playwright locator that can be used to interact with it. + * + * @param {string} name - The name of the perspective button to find. + * @returns {Promise} A promise that resolves to the locator for the perspective button. + * + * @example + * // Get a perspective button by name + * const perspectiveBtn = await resourcesPage.getPerspectivesButtonByName("Q1 Resources"); + * await perspectiveBtn.click(); + * + * @example + * // Use with applyPerspective method + * const perspectiveBtn = await resourcesPage.getPerspectivesButtonByName("Production View"); + * await resourcesPage.applyPerspective(perspectiveBtn); + * + * @remarks + * This method returns a locator, not an element handle. The locator can be used + * for assertions or interactions with the perspective button. + */ + async getPerspectivesButtonByName(name: string): Promise { + return this.perspectivesSideModal.getByRole('button', { name: name }); + } + + /** + * Applies a perspective by clicking its button and confirming the application. + * + * This method performs the complete perspective application workflow: + * 1. Opens the perspectives side modal by clicking the Perspectives button + * 2. Clicks the specified perspective button to select it + * 3. Clicks the Apply button to apply the perspective + * 4. Waits for the Apply button to be hidden (modal closes) + * 5. Waits for the canvas to re-render with the new perspective + * 6. Waits for all progress bars to disappear + * + * @param {Locator} perspectiveButton - The Playwright locator for the perspective button to apply. + * @returns {Promise} A promise that resolves when the perspective is fully applied and the page is loaded. + * + * @example + * // Apply a perspective by name + * const perspectiveBtn = await resourcesPage.getPerspectivesButtonByName("My Perspective"); + * await resourcesPage.applyPerspective(perspectiveBtn); + * + * @example + * // Apply and verify the perspective was applied + * const perspectiveBtn = await resourcesPage.getPerspectivesButtonByName("Cost Overview"); + * await resourcesPage.applyPerspective(perspectiveBtn); + * await expect(resourcesPage.expensesBreakdownChart).toBeVisible(); + * + * @remarks + * This method includes waits to ensure the perspective is fully loaded before + * proceeding. It waits for both the canvas rendering and any progress indicators + * to complete, ensuring the page is in a stable state for subsequent interactions. + */ + async applyPerspective(perspectiveButton: Locator): Promise { + await this.perspectivesBtn.click(); + await perspectiveButton.click(); + await this.perspectivesApplyBtn.click(); + await this.perspectivesApplyBtn.waitFor({ state: 'hidden' }); + await this.waitForCanvas(); + await this.waitForAllProgressBarsToDisappear(); + } + + /** + * Returns a locator for the overwrite confirmation message in the save perspective modal. + * + * This message is displayed when a perspective with the given name already exists, + * warning the user that the existing perspective will be overwritten with the new options. + * + * @param {string} perspectiveName - The name of the perspective that will be overwritten. + * @returns {Locator} A Playwright locator for the overwrite warning message element. + * + * @example + * // Assert the overwrite warning is visible before saving + * await expect(resourcesPage.getPerspectiveOverwriteMessage('My Perspective')).toBeVisible(); + * + * @remarks + * Uses exact text matching, so the perspective name must match precisely + * (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 }); + } } diff --git a/e2etests/tests/anomalies-tests.spec.ts b/e2etests/tests/anomalies-tests.spec.ts index 4d70d6c3a..85ebfc0d8 100644 --- a/e2etests/tests/anomalies-tests.spec.ts +++ b/e2etests/tests/anomalies-tests.spec.ts @@ -31,36 +31,48 @@ test.describe('[MPT-14737] Anomalies Tests', { tag: ['@ui', '@anomalies'] }, () }); test('[231429] Anomalies page components', async ({ anomaliesPage }) => { - await expect.soft(anomaliesPage.heading).toHaveText('Anomaly detection'); - await expect.soft(anomaliesPage.addBtn).toBeVisible(); - await expect.soft(anomaliesPage.searchInput).toBeVisible(); - await expect.soft(anomaliesPage.defaultExpenseAnomalyLink).toBeVisible(); - await expect.soft(anomaliesPage.defaultExpenseAnomalyCanvas).toBeVisible(); - await expect - .soft(anomaliesPage.defaultExpenseAnomalyDescription) - .toHaveText('Daily expenses must not exceed the average amount for the last 7 days by 30%.'); - await expect.soft(anomaliesPage.defaultResourceCountAnomalyShowResourcesBtn).toBeVisible(); - await expect.soft(anomaliesPage.defaultResourceCountAnomalyLink).toBeVisible(); - await expect.soft(anomaliesPage.defaultResourceCountAnomalyCanvas).toBeVisible(); - await expect - .soft(anomaliesPage.defaultResourceCountAnomalyDescription) - .toHaveText('Daily resource count must not exceed the average amount for the last 7 days by 30%.'); - await expect(anomaliesPage.defaultResourceCountAnomalyShowResourcesBtn).toBeVisible(); + await test.step('Verify page header components', async () => { + await expect.soft(anomaliesPage.heading).toHaveText('Anomaly detection'); + await expect.soft(anomaliesPage.addBtn).toBeVisible(); + await expect.soft(anomaliesPage.searchInput).toBeVisible(); + }); + + await test.step('Verify default expense anomaly components', async () => { + await expect.soft(anomaliesPage.defaultExpenseAnomalyLink).toBeVisible(); + await expect.soft(anomaliesPage.defaultExpenseAnomalyCanvas).toBeVisible(); + await expect + .soft(anomaliesPage.defaultExpenseAnomalyDescription) + .toHaveText('Daily expenses must not exceed the average amount for the last 7 days by 30%.'); + await expect.soft(anomaliesPage.defaultExpenseAnomalyShowResourcesBtn).toBeVisible(); + }); + + await test.step('Verify default resource count anomaly components', async () => { + await expect.soft(anomaliesPage.defaultResourceCountAnomalyLink).toBeVisible(); + await expect.soft(anomaliesPage.defaultResourceCountAnomalyCanvas).toBeVisible(); + await expect + .soft(anomaliesPage.defaultResourceCountAnomalyDescription) + .toHaveText('Daily resource count must not exceed the average amount for the last 7 days by 30%.'); + await expect(anomaliesPage.defaultResourceCountAnomalyShowResourcesBtn).toBeVisible(); + }); }); test('[231432] Verify navigation of link and show resources button', async ({ anomaliesPage, resourcesPage }) => { - await anomaliesPage.waitForAllProgressBarsToDisappear(); - await anomaliesPage.clickLocator(anomaliesPage.defaultExpenseAnomalyLink); - await expect.soft(anomaliesPage.anomalyDetectionPolicyHeading).toHaveText('Anomaly detection policy'); - await expect.soft(anomaliesPage.policyDetailsNameValue).toHaveText('Default - expense anomaly'); - await expect.soft(anomaliesPage.policyDetailsTypeValue).toHaveText('Expenses'); - await expect.soft(anomaliesPage.policyDetailsEvaluationPeriodValue).toHaveText('7 days'); - await expect.soft(anomaliesPage.policyDetailsThresholdValue).toHaveText('30%'); - - await anomaliesPage.clickLocator(anomaliesPage.anomalyDetectionBreadcrumb); - await anomaliesPage.waitForAllProgressBarsToDisappear(); - await anomaliesPage.clickLocator(anomaliesPage.defaultExpenseAnomalyShowResourcesBtn); - await expect(resourcesPage.heading).toBeVisible(); + await test.step('Navigate to policy details and verify values', async () => { + await anomaliesPage.waitForAllProgressBarsToDisappear(); + await anomaliesPage.click(anomaliesPage.defaultExpenseAnomalyLink); + await expect.soft(anomaliesPage.anomalyDetectionPolicyHeading).toHaveText('Anomaly detection policy'); + await expect.soft(anomaliesPage.policyDetailsNameValue).toHaveText('Default - expense anomaly'); + await expect.soft(anomaliesPage.policyDetailsTypeValue).toHaveText('Expenses'); + await expect.soft(anomaliesPage.policyDetailsEvaluationPeriodValue).toHaveText('7 days'); + await expect.soft(anomaliesPage.policyDetailsThresholdValue).toHaveText('30%'); + }); + + await test.step('Navigate back and verify Show Resources button navigates to Resources page', async () => { + await anomaliesPage.click(anomaliesPage.anomalyDetectionBreadcrumb); + await anomaliesPage.waitForAllProgressBarsToDisappear(); + await anomaliesPage.click(anomaliesPage.defaultExpenseAnomalyShowResourcesBtn); + await expect(resourcesPage.heading).toBeVisible(); + }); }); test( @@ -157,67 +169,92 @@ test.describe('[MPT-14737] Anomalies Tests', { tag: ['@ui', '@anomalies'] }, () ); test('[231431] Anomalies page search function', async ({ anomaliesPage }) => { - await anomaliesPage.searchAnomaly('expense'); - await expect.soft(anomaliesPage.defaultExpenseAnomalyLink).toBeVisible(); - await expect.soft(anomaliesPage.defaultResourceCountAnomalyLink).toBeHidden(); + await test.step('Search by "expense" shows only expense anomaly', async () => { + await anomaliesPage.searchAnomaly('expense'); + await expect.soft(anomaliesPage.defaultExpenseAnomalyLink).toBeVisible(); + await expect.soft(anomaliesPage.defaultResourceCountAnomalyLink).toBeHidden(); + }); - await anomaliesPage.searchAnomaly('resource'); - await expect.soft(anomaliesPage.defaultResourceCountAnomalyLink).toBeVisible(); - await expect.soft(anomaliesPage.defaultExpenseAnomalyLink).toBeHidden(); + await test.step('Search by "resource" shows only resource count anomaly', async () => { + await anomaliesPage.searchAnomaly('resource'); + await expect.soft(anomaliesPage.defaultResourceCountAnomalyLink).toBeVisible(); + await expect.soft(anomaliesPage.defaultExpenseAnomalyLink).toBeHidden(); + }); - await anomaliesPage.searchAnomaly('non-existent anomaly'); - await expect.soft(anomaliesPage.defaultExpenseAnomalyLink).toBeHidden(); - await expect.soft(anomaliesPage.defaultResourceCountAnomalyLink).toBeHidden(); + await test.step('Search by non-existent term hides all anomalies', async () => { + await anomaliesPage.searchAnomaly('non-existent anomaly'); + await expect.soft(anomaliesPage.defaultExpenseAnomalyLink).toBeHidden(); + await expect.soft(anomaliesPage.defaultResourceCountAnomalyLink).toBeHidden(); + }); - await anomaliesPage.searchAnomaly('30%'); - await expect.soft(anomaliesPage.defaultExpenseAnomalyLink).toBeVisible(); - await expect.soft(anomaliesPage.defaultResourceCountAnomalyLink).toBeVisible(); + await test.step('Search by "30%" shows all anomalies', async () => { + await anomaliesPage.searchAnomaly('30%'); + await expect.soft(anomaliesPage.defaultExpenseAnomalyLink).toBeVisible(); + await expect.soft(anomaliesPage.defaultResourceCountAnomalyLink).toBeVisible(); + }); }); test('[231433] Add a resource count anomaly detection policy', { tag: '@p1' }, async ({ anomaliesPage, anomaliesCreatePage }) => { - await anomaliesPage.clickAddBtn(); const policyName = `E2E Test - Resource Count Anomaly - ${Date.now()}`; - const policyId = await anomaliesCreatePage.addNewAnomalyPolicy(policyName, 'Resource count', '14', '25'); - anomalyPolicyId.push(policyId); + await test.step('Create a new resource count anomaly policy', async () => { + await anomaliesPage.clickAddBtn(); + const policyId = await anomaliesCreatePage.addNewAnomalyPolicy(policyName, 'Resource count', '14', '25'); + anomalyPolicyId.push(policyId); + }); - await expect.soft(anomaliesPage.policyLinkByName(policyName)).toBeVisible(); - await expect - .soft(anomaliesPage.policyDescriptionByName(policyName)) - .toHaveText('Daily resource count must not exceed the average amount for the last 14 days by 25%.'); - await expect.soft(anomaliesPage.policyFilterByName(policyName)).toHaveText('-'); + await test.step('Verify policy is visible with correct details', async () => { + await expect.soft(anomaliesPage.policyLinkByName(policyName)).toBeVisible(); + await expect + .soft(anomaliesPage.policyDescriptionByName(policyName)) + .toHaveText('Daily resource count must not exceed the average amount for the last 14 days by 25%.'); + await expect.soft(anomaliesPage.policyFilterByName(policyName)).toHaveText('-'); + }); }); test('[231434] Add an expenses anomaly detection policy with filter', async ({ anomaliesPage, anomaliesCreatePage }) => { - await anomaliesPage.clickAddBtn(); const policyName = `E2E Test - Expense Anomaly - ${Date.now()}`; - const policyId = await anomaliesCreatePage.addNewAnomalyPolicy( - policyName, - 'Expenses', - '10', - '20', - anomaliesCreatePage.suggestionsFilter, - 'Assigned to me' - ); - anomalyPolicyId.push(policyId); - - await expect.soft(anomaliesPage.policyLinkByName(policyName)).toBeVisible(); - await expect - .soft(anomaliesPage.policyDescriptionByName(policyName)) - .toHaveText('Daily expenses must not exceed the average amount for the last 10 days by 20%.'); - await expect.soft(anomaliesPage.policyFilterByName(policyName)).toHaveText(`Owner: ${await anomaliesPage.getUserNameByEnvironment()}`); + await test.step('Create a new expenses anomaly policy with a filter', async () => { + await anomaliesPage.clickAddBtn(); + const policyId = await anomaliesCreatePage.addNewAnomalyPolicy( + policyName, + 'Expenses', + '10', + '20', + anomaliesCreatePage.suggestionsFilter, + 'Assigned to me' + ); + anomalyPolicyId.push(policyId); + }); + + await test.step('Verify policy is visible with correct details and filter', async () => { + await expect.soft(anomaliesPage.policyLinkByName(policyName)).toBeVisible(); + await expect + .soft(anomaliesPage.policyDescriptionByName(policyName)) + .toHaveText('Daily expenses must not exceed the average amount for the last 10 days by 20%.'); + await expect + .soft(anomaliesPage.policyFilterByName(policyName)) + .toHaveText(`Owner: ${await anomaliesPage.getUserNameByEnvironment()}`); + }); }); test('[231441] Verify delete policy functions correctly', async ({ anomaliesPage, anomaliesCreatePage }) => { - await anomaliesPage.clickAddBtn(); const policyName = `E2E Test - Delete Anomaly Policy - ${Date.now()}`; - await anomaliesCreatePage.addNewAnomalyPolicy(policyName, 'Expenses', '5', '15'); - await anomaliesPage.policyLinkByName(policyName).waitFor(); - await anomaliesPage.deleteAnomalyPolicy(policyName); + await test.step('Create a new anomaly policy', async () => { + await anomaliesPage.clickAddBtn(); + await anomaliesCreatePage.addNewAnomalyPolicy(policyName, 'Expenses', '5', '15'); + await anomaliesPage.policyLinkByName(policyName).waitFor(); + }); + + await test.step('Delete the anomaly policy', async () => { + await anomaliesPage.deleteAnomalyPolicy(policyName); + }); - await expect.soft(anomaliesPage.policyLinkByName(policyName)).toBeHidden(); + await test.step('Verify the policy is no longer visible', async () => { + await expect.soft(anomaliesPage.policyLinkByName(policyName)).toBeHidden(); + }); }); test.afterAll(async ({}) => { @@ -290,7 +327,7 @@ test.describe('[MPT-14737] Mocked Anomalies Tests', { tag: ['@ui', '@anomalies'] await anomaliesPage.navigateToURL(); await test.step('Category: Region', async () => { - await anomaliesPage.clickLocator(anomaliesPage.defaultExpenseAnomalyLink); + await anomaliesPage.click(anomaliesPage.defaultExpenseAnomalyLink); await anomaliesPage.waitForAllProgressBarsToDisappear(); await anomaliesPage.waitForCanvas(); await anomaliesPage.selectCategorizeBy('Region'); @@ -347,55 +384,67 @@ test.describe('[MPT-14737] Mocked Anomalies Tests', { tag: ['@ui', '@anomalies'] test('[231436] Verify Chart export for each expenses option by comparing downloaded png', async ({ anomaliesPage }) => { test.fixme(process.env.CI === '1', 'Tests do not work in CI. It appears that the png comparison is unsupported on linux'); - let actualPath = path.resolve('tests', 'downloads', 'anomaly-expenses-service-daily-chart-export.png'); - let expectedPath = path.resolve('tests', 'expected', 'expected-anomaly-expenses-service-daily-chart-export.png'); - let diffPath = path.resolve('tests', 'downloads', 'diff-anomaly-expenses-service-daily-chart-export.png'); + let actualPath: string; + let expectedPath: string; + let diffPath: string; let match: boolean; await anomaliesPage.page.clock.setFixedTime(new Date('2025-11-11T14:11:00Z')); await anomaliesPage.navigateToURL(); - - await anomaliesPage.clickLocator(anomaliesPage.defaultExpenseAnomalyLink); + await anomaliesPage.click(anomaliesPage.defaultExpenseAnomalyLink); await anomaliesPage.waitForAllProgressBarsToDisappear(); await anomaliesPage.waitForCanvas(); - await anomaliesPage.downloadFile(anomaliesPage.exportChartBtn, actualPath); - match = await comparePngImages(expectedPath, actualPath, diffPath); - expect.soft(match).toBe(true); - - actualPath = path.resolve('tests', 'downloads', 'anomaly-expenses-service-daily-chart-no-legend-export.png'); - expectedPath = path.resolve('tests', 'expected', 'expected-anomaly-expenses-service-daily-chart-no-legend-export.png'); - diffPath = path.resolve('tests', 'downloads', 'diff-anomaly-expenses-service-daily-chart-no-legend-export.png'); - - await anomaliesPage.clickShowLegend(); - await anomaliesPage.downloadFile(anomaliesPage.exportChartBtn, actualPath); - match = await comparePngImages(expectedPath, actualPath, diffPath); - expect.soft(match).toBe(true); - - actualPath = path.resolve('tests', 'downloads', 'anomaly-expenses-service-weekly-chart-export.png'); - expectedPath = path.resolve('tests', 'expected', 'expected-anomaly-expenses-service-weekly-chart-export.png'); - diffPath = path.resolve('tests', 'downloads', 'diff-anomaly-expenses-service-weekly-chart-export.png'); - - await anomaliesPage.clickShowLegend(); - await anomaliesPage.selectExpenses('Weekly'); - await anomaliesPage.downloadFile(anomaliesPage.exportChartBtn, actualPath); - match = await comparePngImages(expectedPath, actualPath, diffPath); - expect.soft(match).toBe(true); - - actualPath = path.resolve('tests', 'downloads', 'anomaly-expenses-service-monthly-chart-export.png'); - expectedPath = path.resolve('tests', 'expected', 'expected-anomaly-expenses-service-monthly-chart-export.png'); - diffPath = path.resolve('tests', 'downloads', 'diff-anomaly-expenses-service-monthly-chart-export.png'); - - await anomaliesPage.selectExpenses('Monthly'); - await anomaliesPage.downloadFile(anomaliesPage.exportChartBtn, actualPath); - match = await comparePngImages(expectedPath, actualPath, diffPath); - expect.soft(match).toBe(true); + + await test.step('Expenses: Daily (with legend)', async () => { + actualPath = path.resolve('tests', 'downloads', 'anomaly-expenses-service-daily-chart-export.png'); + expectedPath = path.resolve('tests', 'expected', 'expected-anomaly-expenses-service-daily-chart-export.png'); + diffPath = path.resolve('tests', 'downloads', 'diff-anomaly-expenses-service-daily-chart-export.png'); + + await anomaliesPage.downloadFile(anomaliesPage.exportChartBtn, actualPath); + match = await comparePngImages(expectedPath, actualPath, diffPath); + expect.soft(match).toBe(true); + }); + + await test.step('Expenses: Daily (no legend)', async () => { + actualPath = path.resolve('tests', 'downloads', 'anomaly-expenses-service-daily-chart-no-legend-export.png'); + expectedPath = path.resolve('tests', 'expected', 'expected-anomaly-expenses-service-daily-chart-no-legend-export.png'); + diffPath = path.resolve('tests', 'downloads', 'diff-anomaly-expenses-service-daily-chart-no-legend-export.png'); + + await anomaliesPage.clickShowLegend(); + await anomaliesPage.downloadFile(anomaliesPage.exportChartBtn, actualPath); + match = await comparePngImages(expectedPath, actualPath, diffPath); + expect.soft(match).toBe(true); + }); + + await test.step('Expenses: Weekly', async () => { + actualPath = path.resolve('tests', 'downloads', 'anomaly-expenses-service-weekly-chart-export.png'); + expectedPath = path.resolve('tests', 'expected', 'expected-anomaly-expenses-service-weekly-chart-export.png'); + diffPath = path.resolve('tests', 'downloads', 'diff-anomaly-expenses-service-weekly-chart-export.png'); + + await anomaliesPage.clickShowLegend(); + await anomaliesPage.selectExpenses('Weekly'); + await anomaliesPage.downloadFile(anomaliesPage.exportChartBtn, actualPath); + match = await comparePngImages(expectedPath, actualPath, diffPath); + expect.soft(match).toBe(true); + }); + + await test.step('Expenses: Monthly', async () => { + actualPath = path.resolve('tests', 'downloads', 'anomaly-expenses-service-monthly-chart-export.png'); + expectedPath = path.resolve('tests', 'expected', 'expected-anomaly-expenses-service-monthly-chart-export.png'); + diffPath = path.resolve('tests', 'downloads', 'diff-anomaly-expenses-service-monthly-chart-export.png'); + + await anomaliesPage.selectExpenses('Monthly'); + await anomaliesPage.downloadFile(anomaliesPage.exportChartBtn, actualPath); + match = await comparePngImages(expectedPath, actualPath, diffPath); + expect.soft(match).toBe(true); + }); }); test('[231439] Verify detected anomalies are displayed in the table correctly', async ({ anomaliesPage }) => { await anomaliesPage.page.clock.setFixedTime(new Date('2025-11-11T14:11:00Z')); await anomaliesPage.navigateToURL(); - await anomaliesPage.clickLocator(anomaliesPage.defaultExpenseAnomalyLink); + await anomaliesPage.click(anomaliesPage.defaultExpenseAnomalyLink); await anomaliesPage.waitForAllProgressBarsToDisappear(); expect.soft(await anomaliesPage.getViolatedAtTextByIndex(1)).toBe('10/12/2025 08:55 PM'); @@ -415,10 +464,10 @@ test.describe('[MPT-14737] Mocked Anomalies Tests', { tag: ['@ui', '@anomalies'] await anomaliesPage.page.clock.setFixedTime(new Date('2025-11-11T14:11:00Z')); await anomaliesPage.navigateToURL(); - await anomaliesPage.clickLocator(anomaliesPage.defaultExpenseAnomalyLink); + await anomaliesPage.click(anomaliesPage.defaultExpenseAnomalyLink); await anomaliesPage.waitForAllProgressBarsToDisappear(); - await anomaliesPage.clickLocator(anomaliesPage.showResourcesBtn.first()); + await anomaliesPage.click(anomaliesPage.showResourcesBtn.first()); await expect(resourcesPage.heading).toBeVisible(); }); }); diff --git a/e2etests/tests/cloud-accounts-tests.spec.ts b/e2etests/tests/cloud-accounts-tests.spec.ts index 4f60e6243..84f411fef 100644 --- a/e2etests/tests/cloud-accounts-tests.spec.ts +++ b/e2etests/tests/cloud-accounts-tests.spec.ts @@ -16,7 +16,7 @@ import { OrganizationsResponse, OrganizationThemeSettingsResponse, } from '../mocks/cloud-accounts-page.mocks'; -import { getCurrentUTCTimestamp } from '../utils/date-range-utils'; +import { getCurrentUTCTimestamp, getTimestampWithVariance } from '../utils/date-range-utils'; test.describe('Cloud Accounts Tests', { tag: ['@ui', '@cloud-accounts'] }, () => { test.describe.configure({ mode: 'serial' }); @@ -285,7 +285,14 @@ test.describe( const eventText = await disconnectEvent.textContent(); debugLog(`Disconnect event text: ${eventText}`); - expect.soft(eventText).toContain(`${timestamp} UTC`); + + // Generate timestamps with ±1 minute variance + const timestamps = getTimestampWithVariance(timestamp); + debugLog(`Checking for timestamps: ${timestamps.join(', ')}`); + + // Assert that the event text contains at least one of the timestamps + const hasMatchingTimestamp = timestamps.some(ts => eventText.includes(`${ts} UTC`)); + expect.soft(hasMatchingTimestamp, `Event should contain one of the timestamps: ${timestamps.join(', ')}`).toBe(true); }); await test.step('Add new cloud account and ensure that the events log includes account and pool creation', async () => { @@ -298,18 +305,27 @@ test.describe( await eventsPage.navigateToURL(); await eventsPage.waitForAllProgressBarsToDisappear(); + const timestamps = getTimestampWithVariance(timestamp); + debugLog(`Checking for timestamps: ${timestamps.join(', ')}`); + const creationEvent = eventsPage.getEventByMultipleTexts([`Cloud account ${awsAccountName}`, 'created']); await expect.soft(creationEvent).toBeVisible(); const eventText = await creationEvent.textContent(); debugLog(`Creation event text: ${eventText}`); - expect.soft(eventText).toContain(`${timestamp} UTC`); + + // Assert that the event text contains at least one of the timestamps + const hasMatchingTimestamp = timestamps.some(ts => eventText.includes(`${ts} UTC`)); + expect.soft(hasMatchingTimestamp, `Event should contain one of the timestamps: ${timestamps.join(', ')}`).toBe(true); const poolCreationEvent = eventsPage.getEventByMultipleTexts([`Rule for ${awsAccountName}`, `created for pool ${awsAccountName}`]); await expect.soft(poolCreationEvent).toBeVisible(); const poolEventText = await poolCreationEvent.textContent(); debugLog(`Pool Creation event text: ${poolEventText}`); - expect.soft(poolEventText).toContain(`${timestamp} UTC`); + + // Assert that the pool event text contains at least one of the timestamps (reusing same timestamps array) + const hasMatchingPoolTimestamp = timestamps.some(ts => poolEventText.includes(`${ts} UTC`)); + expect.soft(hasMatchingPoolTimestamp, `Event should contain one of the timestamps: ${timestamps.join(', ')}`).toBe(true); expect.soft(poolEventText).toContain(process.env.DEFAULT_USER_EMAIL); }); }); diff --git a/e2etests/tests/invitation-flow-tests.spec.ts b/e2etests/tests/invitation-flow-tests.spec.ts index 1218cedab..19e008a80 100644 --- a/e2etests/tests/invitation-flow-tests.spec.ts +++ b/e2etests/tests/invitation-flow-tests.spec.ts @@ -4,6 +4,7 @@ import { test } from '../fixtures/page.fixture'; import { expect } from '@playwright/test'; import { generateRandomEmail } from '../utils/random-data-generator'; import { debugLog } from '../utils/debug-logging'; +import { getCurrentUTCTimestamp } from '../utils/date-range-utils'; const verificationCode = '123456'; let invitationEmail: string; @@ -399,17 +400,19 @@ test.describe( test('[232868] Invite a new user and verify the event is logged', async ({ mainMenu, usersPage, usersInvitePage, eventsPage }) => { invitationEmail = generateRandomEmail(); + let date: string; await test.step('Invite a new user to the organisation', async () => { await usersPage.navigateToURL(); await usersPage.clickInviteBtn(); + + date = getCurrentUTCTimestamp(); await usersInvitePage.inviteUser(invitationEmail); await usersInvitePage.userInvitedAlert.waitFor(); await usersInvitePage.userInvitedAlertCloseButton.click(); }); await test.step('Navigate to Events page and verify invitation event is logged', async () => { - const date = new Date().toLocaleDateString('en-US'); // Get current date in US format (M/D/YYYY) debugLog(`Current date: ${date}`); await mainMenu.clickEvents(); await eventsPage.filterByEventLevel('Info'); diff --git a/e2etests/tests/perspective-tests.spec.ts b/e2etests/tests/perspective-tests.spec.ts new file mode 100644 index 000000000..c783e7b84 --- /dev/null +++ b/e2etests/tests/perspective-tests.spec.ts @@ -0,0 +1,345 @@ +/* eslint-disable playwright/no-conditional-in-test, playwright/no-conditional-expect */ +import { test } from '../fixtures/page.fixture'; +import { expect, Locator } from '@playwright/test'; +import { debugLog } from '../utils/debug-logging'; + +test.describe('[MPT-18579] Perspective Tests', { tag: ['@ui', '@resources', '@perspectives', '@slow'] }, () => { + test.describe.configure({ mode: 'default' }); + test.use({ restoreSession: true }); + test.slow(); + + test('[232963] User can create an Expenses perspective and the chart options are saved and applied correctly', async ({ + resourcesPage, + perspectivesPage, + }) => { + await resourcesPage.navigateToResourcesPageAndResetFilters(); + + const filter = 'Region'; + const filterOption = 'West Europe'; + const categorizeBy = 'Resource type'; + const groupByTag = 'costcenter'; + const perspectiveName = `Test Perspective ${new Date().getTime()}`; + + 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.selectGroupByTag(groupByTag); + await resourcesPage.click(resourcesPage.savePerspectiveBtn); + }); + + await test.step('Verify perspective criteria matches those selected', async () => { + await expect.soft(resourcesPage.savePerspectiveBreakDownByValue).toHaveText('Expenses'); + await expect.soft(resourcesPage.savePerspectiveCategorizeByValue).toHaveText(categorizeBy); + await expect.soft(resourcesPage.savePerspectiveGroupByTypeValue).toContainText('Tag'); + await expect.soft(resourcesPage.savePerspectiveGroupByValue).toHaveText(groupByTag); + await expect.soft(resourcesPage.savePerspectiveFiltersValue).toContainText(filter); + await expect.soft(resourcesPage.savePerspectiveFiltersOptionValue).toContainText(filterOption); + }); + + let perspectiveBtn: Locator; + await test.step('Save perspective and verify it appears in the perspectives list', async () => { + await resourcesPage.savePerspective(perspectiveName); + await resourcesPage.click(resourcesPage.perspectivesBtn); + perspectiveBtn = await resourcesPage.getPerspectivesButtonByName(perspectiveName); + await expect(perspectiveBtn).toBeVisible(); + }); + + await test.step('Navigate to perspective page and validate the perspective in the table', async () => { + await resourcesPage.perspectivesSeeAllPerspectivesLink.click(); + const perspectiveRow = await perspectivesPage.getTableRowByPerspectiveName(perspectiveName); + await expect.soft(perspectiveRow.locator(perspectivesPage.breakdownByColumn)).toHaveText('Expenses'); + await expect.soft(perspectiveRow.locator(perspectivesPage.categorizeByColumn)).toHaveText(categorizeBy); + await expect.soft(perspectiveRow.locator(perspectivesPage.groupByColumn)).toContainText('Tag'); + await expect.soft(perspectiveRow.locator(perspectivesPage.groupByColumn)).toContainText(groupByTag); + await expect.soft(perspectiveRow.locator(perspectivesPage.filtersColumn)).toContainText(filter); + await expect.soft(perspectiveRow.locator(perspectivesPage.filtersColumn)).toContainText(filterOption); + }); + + await test.step('Return to the resources page and return to default view', async () => { + await resourcesPage.navigateToResourcesPageAndResetFilters(); + await resourcesPage.selectCategorizeBy('Service'); + expect.soft(await resourcesPage.selectedComboBoxOption(resourcesPage.categorizeBySelect)).toBe('Service'); + await expect.soft(resourcesPage.clearIcon).toBeHidden(); + }); + + await test.step('Apply perspective and validate the chart options are applied correctly', async () => { + await resourcesPage.applyPerspective(perspectiveBtn); + const activeFilter = resourcesPage.getActiveFilter(); + await expect.soft(activeFilter).toHaveText(`${filter} (${filterOption})`); + + expect.soft(await resourcesPage.isAriaSelected(resourcesPage.tabExpensesBtn)).toBe(true); + expect.soft(await resourcesPage.selectedComboBoxOption(resourcesPage.categorizeBySelect)).toBe(categorizeBy); + await expect.soft(resourcesPage.selectedGroupByTagItem).toBeVisible(); + await expect.soft(resourcesPage.selectedGroupByTagKey).toContainText('Tag:'); + await expect.soft(resourcesPage.selectedGroupByTagValue).toHaveText(groupByTag); + }); + }); + + test('[232964] User can create perspective for resource count and the perspective is saved and applied correctly', async ({ + resourcesPage, + }) => { + await resourcesPage.navigateToResourcesPageAndResetFilters(); + + const filter = 'Recommendations'; + const filterOption = 'With recommendations'; + const categorizeBy = 'Region'; + const perspectiveName = `Test Perspective ${new Date().getTime()}`; + + await test.step('Select options to save as a perspective', async () => { + await resourcesPage.selectFilterByText(filter, filterOption); + await resourcesPage.clickResourceCountTab(); + await resourcesPage.selectCategorizeBy(categorizeBy); + await resourcesPage.click(resourcesPage.savePerspectiveBtn); + }); + + await test.step('Verify perspective criteria matches those selected', async () => { + await expect.soft(resourcesPage.savePerspectiveBreakDownByValue).toHaveText('Resource count'); + await expect.soft(resourcesPage.savePerspectiveCategorizeByValue).toHaveText(categorizeBy); + await expect.soft(resourcesPage.savePerspectiveFiltersValue).toContainText(filter); + await expect.soft(resourcesPage.savePerspectiveFiltersOptionValue).toContainText(filterOption); + }); + + await test.step('Save perspective', async () => { + await resourcesPage.savePerspective(perspectiveName); + }); + + await test.step('Return to the resources page and return to default view', async () => { + await resourcesPage.navigateToResourcesPageAndResetFilters(); + await resourcesPage.selectCategorizeBy('Service'); + expect.soft(await resourcesPage.selectedComboBoxOption(resourcesPage.categorizeBySelect)).toBe('Service'); + await expect.soft(resourcesPage.clearIcon).toBeHidden(); + }); + + await test.step('Apply perspective and validate the chart options are applied correctly', async () => { + await resourcesPage.applyPerspective(await resourcesPage.getPerspectivesButtonByName(perspectiveName)); + + const activeFilter = resourcesPage.getActiveFilter(); + await expect.soft(activeFilter).toHaveText(`${filter} (${filterOption})`); + expect.soft(await resourcesPage.isAriaSelected(resourcesPage.tabResourceCountBtn)).toBe(true); + expect.soft(await resourcesPage.selectedComboBoxOption(resourcesPage.categorizeBySelect)).toBe(categorizeBy); + }); + }); + + test('[232965] User can create a perspective a Tags chart is saved and applied correctly', async ({ resourcesPage }) => { + await resourcesPage.navigateToResourcesPageAndResetFilters(); + + const filter = 'Pool'; + const filterOption = 'Marketplace (Dev)'; + const perspectiveName = `Test Perspective ${new Date().getTime()}`; + + await test.step('Select options to save as a perspective', async () => { + await resourcesPage.selectFilterByText(filter, filterOption); + await resourcesPage.clickTagsTab(); + await resourcesPage.click(resourcesPage.savePerspectiveBtn); + }); + + await test.step('Verify perspective criteria matches those selected', async () => { + await expect.soft(resourcesPage.savePerspectiveBreakDownByValue).toHaveText('Tags'); + await expect.soft(resourcesPage.savePerspectiveFiltersValue).toContainText(filter); + await expect.soft(resourcesPage.savePerspectiveFiltersOptionValue).toContainText(filterOption); + }); + + await test.step('Save perspective', async () => { + await resourcesPage.savePerspective(perspectiveName); + }); + + await test.step('Return to the resources page and return to default view', async () => { + await resourcesPage.navigateToResourcesPageAndResetFilters(); + await resourcesPage.selectCategorizeBy('Service'); + expect.soft(await resourcesPage.selectedComboBoxOption(resourcesPage.categorizeBySelect)).toBe('Service'); + await expect.soft(resourcesPage.clearIcon).toBeHidden(); + }); + + await test.step('Apply perspective and validate the chart options are applied correctly', async () => { + await resourcesPage.applyPerspective(await resourcesPage.getPerspectivesButtonByName(perspectiveName)); + + const activeFilter = resourcesPage.getActiveFilter(); + await expect.soft(activeFilter).toHaveText(`${filter} (${filterOption})`); + expect.soft(await resourcesPage.isAriaSelected(resourcesPage.tabTagsBtn)).toBe(true); + }); + }); + + test('[232966] User can create a perspective for the Meta chart and the perspective is saved and applied correctly', async ({ + resourcesPage, + }) => { + await resourcesPage.navigateToResourcesPageAndResetFilters(); + + const categorizeBy = 'OS'; + const breakdownType = 'Count'; + const perspectiveName = `Test Perspective ${new Date().getTime()}`; + + await test.step('Select options to save as a perspective', async () => { + await resourcesPage.clickMetaTab(); + await resourcesPage.selectMetaCategorizeBy(categorizeBy); + await resourcesPage.selectBreakdownType(breakdownType); + await resourcesPage.click(resourcesPage.savePerspectiveBtn); + }); + + await test.step('Verify perspective criteria matches those selected', async () => { + await expect.soft(resourcesPage.savePerspectiveBreakDownByValue).toHaveText('Meta'); + await expect.soft(resourcesPage.savePerspectiveCategorizeByValue).toHaveText(categorizeBy); + await expect.soft(resourcesPage.savePerspectiveNoFiltersValue).toHaveText('-'); + }); + + await test.step('Save perspective', async () => { + await resourcesPage.savePerspective(perspectiveName); + }); + + await test.step('Return to the resources page and return to default view', async () => { + await resourcesPage.navigateToResourcesPageAndResetFilters(); + await resourcesPage.selectCategorizeBy('Service'); + expect.soft(await resourcesPage.selectedComboBoxOption(resourcesPage.categorizeBySelect)).toBe('Service'); + await expect.soft(resourcesPage.clearIcon).toBeHidden(); + }); + + await test.step('Apply perspective and validate the chart options are applied correctly', async () => { + await resourcesPage.applyPerspective(await resourcesPage.getPerspectivesButtonByName(perspectiveName)); + + await expect.soft(resourcesPage.getActiveFilter()).toBeHidden(); + expect.soft(await resourcesPage.isAriaSelected(resourcesPage.tabMetaBtn)).toBe(true); + expect.soft(await resourcesPage.selectedComboBoxOption(resourcesPage.metaCategorizeBySelect)).toBe(categorizeBy); + }); + }); + + test('[232967] User can create a perspective and delete it via the perspectives table', async ({ resourcesPage, perspectivesPage }) => { + await perspectivesPage.navigateToURL(); + const initialPerspectivesCount = await perspectivesPage.getPerspectivesCount(); + debugLog(`Initial perspectives count: ${initialPerspectivesCount}`); + + await resourcesPage.navigateToResourcesPageAndResetFilters(); + + const perspectiveName = `Test Perspective ${new Date().getTime()}`; + + await test.step('Create and save a perspective', async () => { + await resourcesPage.clickExpensesTab(); + await resourcesPage.selectGroupByTag('environment'); + await resourcesPage.click(resourcesPage.savePerspectiveBtn); + await resourcesPage.savePerspective(perspectiveName); + }); + + await test.step('Navigate to perspectives page and delete the perspective', async () => { + await resourcesPage.click(resourcesPage.perspectivesBtn); + await resourcesPage.perspectivesSeeAllPerspectivesLink.click(); + await perspectivesPage.deletePerspective(perspectiveName); + }); + + await test.step('Validate the perspective is deleted and the perspectives count is updated', async () => { + await expect.soft(await perspectivesPage.getTableRowByPerspectiveName(perspectiveName)).toBeHidden(); + if (initialPerspectivesCount === 0) await expect.soft(perspectivesPage.noPerspectivesMessage).toBeVisible(); + }); + + await test.step('That the perspectives button is hidden if initial perspective count was zero', async () => { + await resourcesPage.navigateToURL(); + if (initialPerspectivesCount === 0) { + await expect.soft(resourcesPage.perspectivesBtn).toBeHidden(); + } else { + await resourcesPage.click(resourcesPage.perspectivesBtn); + await expect.soft(await resourcesPage.getPerspectivesButtonByName(perspectiveName)).toBeHidden(); + } + }); + }); + + test('[232969] User can create a perspective with multiple filters', async ({ resourcesPage, perspectivesPage }) => { + await resourcesPage.navigateToResourcesPageAndResetFilters(); + + const filter1 = 'Region'; + const filterOption1 = 'West Europe'; + const filter2 = 'Recommendations'; + const filterOption2 = 'With recommendations'; + const perspectiveName = `Test Perspective ${new Date().getTime()}`; + + await test.step('Select options to save as a perspective', async () => { + await resourcesPage.selectFilterByText(filter1, filterOption1); + await resourcesPage.selectFilterByText(filter2, filterOption2); + await resourcesPage.click(resourcesPage.savePerspectiveBtn); + await resourcesPage.savePerspective(perspectiveName); + }); + + await test.step('Navigate to perspective page and validate the perspective in the table', async () => { + await resourcesPage.click(resourcesPage.perspectivesBtn); + await resourcesPage.perspectivesSeeAllPerspectivesLink.click(); + const perspectiveRow = await perspectivesPage.getTableRowByPerspectiveName(perspectiveName); + await expect.soft(perspectiveRow.locator(perspectivesPage.filtersColumn)).toContainText(filter1); + await expect.soft(perspectiveRow.locator(perspectivesPage.filtersColumn)).toContainText(filterOption1); + await expect.soft(perspectiveRow.locator(perspectivesPage.filtersColumn)).toContainText(filter2); + await expect.soft(perspectiveRow.locator(perspectivesPage.filtersColumn)).toContainText(filterOption2); + }); + + await test.step('Return to the resources page and return to default view', async () => { + await resourcesPage.navigateToResourcesPageAndResetFilters(); + await resourcesPage.selectCategorizeBy('Service'); + expect.soft(await resourcesPage.selectedComboBoxOption(resourcesPage.categorizeBySelect)).toBe('Service'); + await expect.soft(resourcesPage.clearIcon).toBeHidden(); + }); + + await test.step('Apply perspective and validate the chart options are applied correctly', async () => { + await resourcesPage.applyPerspective(await resourcesPage.getPerspectivesButtonByName(perspectiveName)); + + await expect.soft(resourcesPage.getActiveFilter().filter({ hasText: `${filter1} (${filterOption1})` })).toBeVisible(); + await expect.soft(resourcesPage.getActiveFilter().filter({ hasText: `${filter2} (${filterOption2})` })).toBeVisible(); + }); + }); + + test('[232970] Creating a perspective with an existing name shows a message stating that the original will be overwritten', async ({ + resourcesPage, + }) => { + await resourcesPage.navigateToResourcesPageAndResetFilters(); + + const filter1 = 'Region'; + const filterOption1 = 'West Europe'; + const filter2 = 'Recommendations'; + const filterOption2 = 'With recommendations'; + const perspectiveName = `Test Perspective ${new Date().getTime()}`; + + await test.step('Create and save a perspective', async () => { + await resourcesPage.selectFilterByText(filter1, filterOption1); + await resourcesPage.click(resourcesPage.savePerspectiveBtn); + await resourcesPage.savePerspective(perspectiveName); + }); + + await test.step('Return to the resources page and return to default view', async () => { + await resourcesPage.navigateToResourcesPageAndResetFilters(); + }); + + await test.step('Attempt to create another perspective with the same name and validate the overwrite message is displayed', async () => { + await resourcesPage.selectFilterByText(filter2, filterOption2); + await resourcesPage.click(resourcesPage.savePerspectiveBtn); + await resourcesPage.fillSavePerspectiveName(perspectiveName); + + await expect(resourcesPage.getPerspectiveOverwriteMessage(perspectiveName)).toBeVisible(); + }); + + await test.step('Save the perspective', async () => { + await resourcesPage.click(resourcesPage.savePerspectiveSaveBtn); + await resourcesPage.savePerspectiveSaveBtn.waitFor({ state: 'detached' }); + await resourcesPage.waitForAllProgressBarsToDisappear(); + }); + + await test.step('Reset filters and apply perspective to validate the updated filters are applied', async () => { + await resourcesPage.resetFilters(); + await resourcesPage.applyPerspective(await resourcesPage.getPerspectivesButtonByName(perspectiveName)); + + await expect(resourcesPage.getActiveFilter()).toHaveCount(1); + await expect.soft(resourcesPage.getActiveFilter().filter({ hasText: `${filter1} (${filterOption1})` })).toBeHidden(); + await expect.soft(resourcesPage.getActiveFilter().filter({ hasText: `${filter2} (${filterOption2})` })).toBeVisible(); + }); + }); + + test('[232968] No perspectives message is displayed and perspectives button is hidden if there are no perspectives', async ({ + resourcesPage, + perspectivesPage, + }) => { + await test.step('Navigate to perspectives page and delete all perspectives', async () => { + await perspectivesPage.navigateToURL(); + await perspectivesPage.waitForAllProgressBarsToDisappear(); + await perspectivesPage.deleteAllPerspectives(); + await expect.soft(perspectivesPage.noPerspectivesMessage).toBeVisible(); + }); + + await test.step('Navigate to resources page and validate perspectives button is hidden', async () => { + await resourcesPage.navigateToURL(); + await expect(resourcesPage.perspectivesBtn).toBeHidden(); + }); + }); +}); diff --git a/e2etests/tests/policies-tests.spec.ts b/e2etests/tests/policies-tests.spec.ts index 65d0ce16a..0d35ac6ee 100644 --- a/e2etests/tests/policies-tests.spec.ts +++ b/e2etests/tests/policies-tests.spec.ts @@ -84,7 +84,7 @@ test.describe('[MPT-16366] Policies Tests', { tag: ['@ui', '@policies'] }, () => }); await test.step('Navigate to the created policy details page', async () => { - await policiesPage.clickLocator(targetPolicyRow.locator('//a')); + await policiesPage.click(targetPolicyRow.locator('//a')); await policiesPage.policyDetailsDiv.waitFor(); }); @@ -117,7 +117,7 @@ test.describe('[MPT-16366] Policies Tests', { tag: ['@ui', '@policies'] }, () => }); await test.step('Navigate to the created policy details page', async () => { - await policiesPage.clickLocator(targetPolicyRow.locator('//a')); + await policiesPage.click(targetPolicyRow.locator('//a')); await policiesPage.policyDetailsDiv.waitFor(); }); @@ -152,7 +152,7 @@ test.describe('[MPT-16366] Policies Tests', { tag: ['@ui', '@policies'] }, () => }); await test.step('Navigate to the created policy details page', async () => { - await policiesPage.clickLocator(targetPolicyRow.locator('//a')); + await policiesPage.click(targetPolicyRow.locator('//a')); await policiesPage.policyDetailsDiv.waitFor(); }); @@ -178,7 +178,7 @@ test.describe('[MPT-16366] Policies Tests', { tag: ['@ui', '@policies'] }, () => await test.step('Navigate to the created policy details page', async () => { await targetPolicyRow.waitFor(); - await policiesPage.clickLocator(targetPolicyRow.locator('//a')); + await policiesPage.click(targetPolicyRow.locator('//a')); await policiesPage.policyDetailsDiv.waitFor(); }); diff --git a/e2etests/tests/recommendations-tests.spec.ts b/e2etests/tests/recommendations-tests.spec.ts index 543330f38..a51a1e1f1 100644 --- a/e2etests/tests/recommendations-tests.spec.ts +++ b/e2etests/tests/recommendations-tests.spec.ts @@ -40,17 +40,21 @@ test.describe('[MPT-11310] Recommendations page tests', { tag: ['@ui', '@recomme test('[230597] Verify Data Source selection works correctly', async ({ recommendationsPage }) => { const dataSource = process.env.USE_LIVE_DEMO === 'true' ? 'Azure QA' : 'CPA (Development and Test)'; - await recommendationsPage.selectDataSource(dataSource); - await recommendationsPage.clickFirstSeeAllButton(); - - const cells = await recommendationsPage.modalColumn2.all(); - for (const cell of cells) { - const link = - process.env.USE_LIVE_DEMO === 'true' - ? cell.locator(recommendationsPage.azureQALink) - : cell.locator(recommendationsPage.cpaDevelopmentAndTestLink); - await expect.soft(link).toBeVisible(); - } + await test.step(`Select data source: ${dataSource}`, async () => { + await recommendationsPage.selectDataSource(dataSource); + await recommendationsPage.clickFirstSeeAllButton(); + }); + + await test.step('Verify all modal cells show the correct data source link', async () => { + const cells = await recommendationsPage.modalColumn2.all(); + for (const cell of cells) { + const link = + process.env.USE_LIVE_DEMO === 'true' + ? cell.locator(recommendationsPage.azureQALink) + : cell.locator(recommendationsPage.cpaDevelopmentAndTestLink); + await expect.soft(link).toBeVisible(); + } + }); }); //It appears that environments don't have the correct permissions to run S3 Duplicate checks, so marking this as FIXME for now. @@ -283,86 +287,115 @@ test.describe('[MPT-11310] Recommendations page tests', { tag: ['@ui', '@recomme }); test('[230520] Verify all cards display critical icon when Critical category selected', async ({ recommendationsPage }) => { - await recommendationsPage.selectCategory('Critical'); - await recommendationsPage.allCardHeadings.last().waitFor(); - const count = await recommendationsPage.allCardHeadings.count(); - const actualHeadings = await recommendationsPage.allCardHeadings.allTextContents(); - debugLog(`Actual heading texts: ${actualHeadings}`); - debugLog(`Number of card headings found: ${count}`); - const criticalIconCount = await recommendationsPage.allCriticalIcon.count(); - debugLog(`Number of critical icons found: ${criticalIconCount}`); - expect.soft(criticalIconCount).toBe(count); + let count: number; + let actualHeadings: string[]; + let criticalIconCount: number; + + await test.step('Select Critical category and verify every card has a critical icon', async () => { + await recommendationsPage.selectCategory('Critical'); + await recommendationsPage.allCardHeadings.last().waitFor(); + count = await recommendationsPage.allCardHeadings.count(); + actualHeadings = await recommendationsPage.allCardHeadings.allTextContents(); + debugLog(`Actual heading texts: ${actualHeadings}`); + debugLog(`Number of card headings found: ${count}`); + criticalIconCount = await recommendationsPage.allCriticalIcon.count(); + debugLog(`Number of critical icons found: ${criticalIconCount}`); + expect.soft(criticalIconCount).toBe(count); + }); - await recommendationsPage.clickTableButton(); - await recommendationsPage.allNameTableButtons.nth(criticalIconCount - 1).waitFor(); - expect.soft(await recommendationsPage.allNameTableButtons.count()).toBe(criticalIconCount); - const buttonNames = await recommendationsPage.allNameTableButtons.allTextContents(); + await test.step('Switch to table view and verify row count matches card count', async () => { + await recommendationsPage.clickTableButton(); + await recommendationsPage.allNameTableButtons.nth(criticalIconCount - 1).waitFor(); + expect.soft(await recommendationsPage.allNameTableButtons.count()).toBe(criticalIconCount); + }); - const expectedSorted = actualHeadings.map((t: string) => t.trim()).sort(); - const buttonNamesSorted = buttonNames.map((t: string) => t.trim()).sort(); + await test.step('Verify table row names match card headings', async () => { + const buttonNames = await recommendationsPage.allNameTableButtons.allTextContents(); + const expectedSorted = actualHeadings.map((t: string) => t.trim()).sort(); + const buttonNamesSorted = buttonNames.map((t: string) => t.trim()).sort(); + expect.soft(buttonNamesSorted).toEqual(expectedSorted); + }); - expect.soft(buttonNamesSorted).toEqual(expectedSorted); - const allStatuses = await recommendationsPage.statusColumn.allTextContents(); - for (const status of allStatuses) { - expect(status.trim()).toBe('Critical'); - } + await test.step('Verify all table rows have Critical status', async () => { + const allStatuses = await recommendationsPage.statusColumn.allTextContents(); + for (const status of allStatuses) { + expect(status.trim()).toBe('Critical'); + } + }); }); test('[230521] Verify that only cards with See Item buttons are displayed when Non-empty category selected', async ({ recommendationsPage, }) => { - await recommendationsPage.selectCategory('Non-empty'); - await recommendationsPage.allCardHeadings.last().waitFor(); - const count = await recommendationsPage.allCardHeadings.count(); - const actualHeadings = await recommendationsPage.allCardHeadings.allTextContents(); - debugLog(`Actual heading texts: ${actualHeadings}`); - debugLog(`Number of card headings found: ${count}`); - - const seeAllBtnCount = await recommendationsPage.allSeeAllBtns.count(); - debugLog(`Number of See Item buttons found: ${seeAllBtnCount}`); - expect.soft(seeAllBtnCount).toBe(count); - - await recommendationsPage.clickTableButton(); - await recommendationsPage.allNameTableButtons.nth(seeAllBtnCount - 1).waitFor(); - expect.soft(await recommendationsPage.allNameTableButtons.count()).toBe(seeAllBtnCount); - const buttonNames = await recommendationsPage.allNameTableButtons.allTextContents(); + let count: number; + let actualHeadings: string[]; + let seeAllBtnCount: number; + + await test.step('Select Non-empty category and verify every card has a See All button', async () => { + await recommendationsPage.selectCategory('Non-empty'); + await recommendationsPage.allCardHeadings.last().waitFor(); + count = await recommendationsPage.allCardHeadings.count(); + actualHeadings = await recommendationsPage.allCardHeadings.allTextContents(); + debugLog(`Actual heading texts: ${actualHeadings}`); + debugLog(`Number of card headings found: ${count}`); + seeAllBtnCount = await recommendationsPage.allSeeAllBtns.count(); + debugLog(`Number of See Item buttons found: ${seeAllBtnCount}`); + expect.soft(seeAllBtnCount).toBe(count); + }); - const expectedSorted = actualHeadings.map((t: string) => t.trim()).sort(); - const buttonNamesSorted = buttonNames.map((t: string) => t.trim()).sort(); + await test.step('Switch to table view and verify row count matches card count', async () => { + await recommendationsPage.clickTableButton(); + await recommendationsPage.allNameTableButtons.nth(seeAllBtnCount - 1).waitFor(); + expect.soft(await recommendationsPage.allNameTableButtons.count()).toBe(seeAllBtnCount); + }); - expect(buttonNamesSorted).toEqual(expectedSorted); + await test.step('Verify table row names match card headings', async () => { + const buttonNames = await recommendationsPage.allNameTableButtons.allTextContents(); + const expectedSorted = actualHeadings.map((t: string) => t.trim()).sort(); + const buttonNamesSorted = buttonNames.map((t: string) => t.trim()).sort(); + expect(buttonNamesSorted).toEqual(expectedSorted); + }); }); test('[230523] Verify filtering by applicable service works correctly', async ({ recommendationsPage }) => { - await recommendationsPage.selectApplicableService('RDS'); - - await recommendationsPage.allCardHeadings.last().waitFor(); - const actualHeadings = await recommendationsPage.allCardHeadings.allTextContents(); - const count = await recommendationsPage.allCardHeadings.count(); - debugLog(`Actual heading texts: ${actualHeadings}`); - debugLog(`Number of card headings found: ${count}`); - - const awsRDSIconsInGrid = recommendationsPage.cardsGrid.locator(recommendationsPage.aws_RDS_Icon); - await awsRDSIconsInGrid.nth(count - 1).waitFor(); - const rdsCount = await awsRDSIconsInGrid.count(); - debugLog(`Number of RDS cards found: ${rdsCount}`); - expect.soft(rdsCount).toBe(count); - - await recommendationsPage.clickTableButton(); - await recommendationsPage.allNameTableButtons.nth(rdsCount - 1).waitFor(); - expect.soft(await recommendationsPage.allNameTableButtons.count()).toBe(rdsCount); - const buttonNames = await recommendationsPage.allNameTableButtons.allTextContents(); + let count: number; + let actualHeadings: string[]; + let rdsCount: number; + + await test.step('Select RDS applicable service and verify every card has an RDS icon', async () => { + await recommendationsPage.selectApplicableService('RDS'); + await recommendationsPage.allCardHeadings.last().waitFor(); + actualHeadings = await recommendationsPage.allCardHeadings.allTextContents(); + count = await recommendationsPage.allCardHeadings.count(); + debugLog(`Actual heading texts: ${actualHeadings}`); + debugLog(`Number of card headings found: ${count}`); + const awsRDSIconsInGrid = recommendationsPage.cardsGrid.locator(recommendationsPage.aws_RDS_Icon); + await awsRDSIconsInGrid.nth(count - 1).waitFor(); + rdsCount = await awsRDSIconsInGrid.count(); + debugLog(`Number of RDS cards found: ${rdsCount}`); + expect.soft(rdsCount).toBe(count); + }); - const expectedSorted = actualHeadings.map((t: string) => t.trim()).sort(); - const buttonNamesSorted = buttonNames.map((t: string) => t.trim()).sort(); + await test.step('Switch to table view and verify row count matches card count', async () => { + await recommendationsPage.clickTableButton(); + await recommendationsPage.allNameTableButtons.nth(rdsCount - 1).waitFor(); + expect.soft(await recommendationsPage.allNameTableButtons.count()).toBe(rdsCount); + }); - expect.soft(buttonNamesSorted).toEqual(expectedSorted); + await test.step('Verify table row names match card headings', async () => { + const buttonNames = await recommendationsPage.allNameTableButtons.allTextContents(); + const expectedSorted = actualHeadings.map((t: string) => t.trim()).sort(); + const buttonNamesSorted = buttonNames.map((t: string) => t.trim()).sort(); + expect.soft(buttonNamesSorted).toEqual(expectedSorted); + }); - const cells = await recommendationsPage.applicableServicesColumn.all(); - for (const cell of cells) { - const icon = cell.locator(recommendationsPage.aws_RDS_Icon); - await expect.soft(icon).toBeVisible(); - } + await test.step('Verify all applicable service column cells show the RDS icon', async () => { + const cells = await recommendationsPage.applicableServicesColumn.all(); + for (const cell of cells) { + const icon = cell.locator(recommendationsPage.aws_RDS_Icon); + await expect.soft(icon).toBeVisible(); + } + }); }); const cardEntries = [ diff --git a/e2etests/tests/resources-tests.spec.ts b/e2etests/tests/resources-tests.spec.ts index 6cd722182..cce0377b5 100644 --- a/e2etests/tests/resources-tests.spec.ts +++ b/e2etests/tests/resources-tests.spec.ts @@ -45,12 +45,7 @@ test.describe('[MPT-11957] Resources page tests', { tag: ['@ui', '@resources'] } test.beforeEach('Login admin user', async ({ resourcesPage }) => { await test.step('Login admin user', async () => { - await resourcesPage.navigateToURL(); - await resourcesPage.waitForAllProgressBarsToDisappear(); - await resourcesPage.waitForCanvas(); - await resourcesPage.resetFilters(); - await resourcesPage.waitForPageLoad(); - await resourcesPage.firstResourceItemInTable.waitFor({ timeout: 15000 }); + await resourcesPage.navigateToResourcesPageAndResetFilters(); }); }); @@ -81,10 +76,6 @@ test.describe('[MPT-11957] Resources page tests', { tag: ['@ui', '@resources'] } resourcesPage.metaFilter, resourcesPage.paidNetworkTrafficFromFilter, resourcesPage.paidNetworkTrafficToFilter, - // Kubernetes filters are temporarily disabled - // resourcesPage.k8sNodeFilter, - // resourcesPage.k8sServiceFilter, - // resourcesPage.k8sNamespaceFilter, ]; for (const filter of expectedFilters) { @@ -191,7 +182,7 @@ test.describe('[MPT-11957] Resources page tests', { tag: ['@ui', '@resources'] } }); await test.step('Filter by Billing only', async () => { - await resourcesPage.clickActivityFilterBillingOnlyOptionAndApply(); + await resourcesPage.selectFilterByText('Activity', 'Billing only'); await expect.soft(resourcesPage.activityFilter).toContainText('(Billing only)'); }); @@ -775,7 +766,7 @@ test.describe('[MPT-11957] Resources page mocked tests', { tag: ['@ui', '@resour diffPath = path.resolve('tests', 'downloads', 'diff-expenses-chart-export-without-legend.png'); await test.step('Toggle Show Legend and verify the chart without legend', async () => { - await resourcesPage.clickShowLegend(); + await resourcesPage.toggleShowLegend(); await resourcesPage.downloadFile(resourcesPage.exportChartBtn, actualPath); match = await comparePngImages(expectedPath, actualPath, diffPath); expect.soft(match).toBe(true); diff --git a/e2etests/tests/tagging-policy-tests.spec.ts b/e2etests/tests/tagging-policy-tests.spec.ts index e2f72e310..604352da7 100644 --- a/e2etests/tests/tagging-policy-tests.spec.ts +++ b/e2etests/tests/tagging-policy-tests.spec.ts @@ -138,7 +138,7 @@ test.describe('[MPT-17042] Tagging Policy Tests', { tag: ['@ui', '@tagging-polic await test.step('Navigate to the created policy details page', async () => { await targetPolicyRow.waitFor(); - await taggingPoliciesPage.clickLocator(targetPolicyRow.locator('//a')); + await taggingPoliciesPage.click(targetPolicyRow.locator('//a')); await taggingPoliciesPage.policyDetailsDiv.waitFor(); }); diff --git a/e2etests/utils/date-range-utils.ts b/e2etests/utils/date-range-utils.ts index 53ba00774..40c2ba71f 100644 --- a/e2etests/utils/date-range-utils.ts +++ b/e2etests/utils/date-range-utils.ts @@ -212,3 +212,68 @@ export function getCurrentUTCTimestamp(): string { }) .replace(',', ''); } + +/** + * Generates an array of timestamp strings with +1 minute variance. + * + * This function takes a base timestamp string and returns an array containing: + * - The original timestamp + * - The timestamp 1 minute later + * + * This is useful for comparing event log timestamps where the timestamp is captured + * before the event occurs, so the logged timestamp might be slightly later. + * + * @param {string} timestamp - The base timestamp string in format "MM/DD/YYYY HH:MM AM/PM". + * @returns {string[]} An array of two timestamp strings (original, +1 min). + * + * @example + * // Returns ["02/26/2026 09:23 AM", "02/26/2026 09:24 AM"] + * const timestamps = getTimestampWithVariance("02/26/2026 09:23 AM"); + * + * @example + * // Use in an assertion to check if event contains any of the timestamps + * const timestamps = getTimestampWithVariance(capturedTimestamp); + * const hasMatch = timestamps.some(ts => eventText.includes(`${ts} UTC`)); + * expect(hasMatch).toBe(true); + */ +export function getTimestampWithVariance(timestamp: string): string[] { + // Parse the timestamp string "MM/DD/YYYY HH:MM AM/PM" + const datePart = timestamp.split(' ')[0]; // "02/26/2026" + const timePart = timestamp.split(' ')[1]; // "09:23" + const ampm = timestamp.split(' ')[2]; // "AM" or "PM" + + const [month, day, year] = datePart.split('/').map(Number); + let [hours, minutes] = timePart.split(':').map(Number); + + // Convert to 24-hour format for calculation + if (ampm === 'PM' && hours !== 12) { + hours += 12; + } else if (ampm === 'AM' && hours === 12) { + hours = 0; + } + + // Create a Date object + const baseDate = new Date(Date.UTC(year, month - 1, day, hours, minutes)); + + // Generate timestamps: original and +1 minute + const timestamps: string[] = []; + + for (let offset of [0, 1]) { + const newDate = new Date(baseDate.getTime() + offset * 60 * 1000); + const formatted = newDate + .toLocaleString('en-US', { + timeZone: 'UTC', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: true, + }) + .replace(',', ''); + timestamps.push(formatted); + } + + return timestamps; +} +