From 5c1d839c4e9451dd90f68dde2f1076b5218a8867 Mon Sep 17 00:00:00 2001 From: Steve Churchill Date: Wed, 4 Mar 2026 12:41:28 +0000 Subject: [PATCH 1/2] Add Perspective tests and general refactor Move filter-related locators and helper methods from BaseCreatePage and ResourcesPage into BasePage to centralize filter handling and avoid duplication. Add numerous utility improvements and documentation in BasePage (viewport fitting, getByAnyTestId, combo-box helpers, enhanced canvas waiters, aria/bringToFront helpers, screenshot delay, reset/select filter helpers and getActiveFilter). Update ResourcesPage to use centralized filters and add perspectives/save-perspective locators and helpers (navigation, reset, meta selectors, group-by/tag helpers). Add CloudAccounts navigation helper, refine tab-click logic to avoid redundant clicks and wait for canvases/progress bars, and expand PerspectivesPage with row selection, deletion and bulk-delete utilities. Remove the now-unneeded CreateAnomalyPage and a couple of redundant page methods to reduce duplication. --- e2etests/pages/anomalies-create-page.ts | 2 +- e2etests/pages/base-create-page.ts | 96 ---- e2etests/pages/base-page.ts | 491 +++++++++++++++++-- e2etests/pages/cloud-accounts-page.ts | 26 +- e2etests/pages/create-anomaly-page.ts | 23 - e2etests/pages/events-page.ts | 10 - e2etests/pages/perspectives-page.ts | 159 +++++- e2etests/pages/resources-page.ts | 435 +++++++++++----- e2etests/tests/anomalies-tests.spec.ts | 265 ++++++---- e2etests/tests/cloud-accounts-tests.spec.ts | 24 +- e2etests/tests/invitation-flow-tests.spec.ts | 5 +- e2etests/tests/perspective-tests.spec.ts | 340 +++++++++++++ e2etests/tests/policies-tests.spec.ts | 8 +- e2etests/tests/recommendations-tests.spec.ts | 183 ++++--- e2etests/tests/resources-tests.spec.ts | 15 +- e2etests/tests/tagging-policy-tests.spec.ts | 2 +- e2etests/utils/date-range-utils.ts | 65 +++ 17 files changed, 1625 insertions(+), 524 deletions(-) delete mode 100644 e2etests/pages/create-anomaly-page.ts create mode 100644 e2etests/tests/perspective-tests.spec.ts 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..2d14e6335 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,150 @@ 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' }); + } + } } } diff --git a/e2etests/pages/resources-page.ts b/e2etests/pages/resources-page.ts index 2a34c6d01..bf0b98180 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,15 @@ export class ResourcesPage extends BasePage { this.navigateNextIcon = this.getByAnyTestId('NavigateNextIcon', this.main); } + 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 +225,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 +243,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 +258,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 +305,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 +386,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 +399,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 +409,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 +435,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 +522,7 @@ export class ResourcesPage extends BasePage { */ async clearGrouping(): Promise { await this.clearIcon.click(); + await this.clearIcon.waitFor({ state: 'hidden' }); } /** @@ -503,4 +535,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..6f986ba03 --- /dev/null +++ b/e2etests/tests/perspective-tests.spec.ts @@ -0,0 +1,340 @@ +/* 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('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.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; +} + From 0c39163e1a7e73c0c02de46b13969b37c380c537 Mon Sep 17 00:00:00 2001 From: Steve Churchill Date: Wed, 4 Mar 2026 14:12:43 +0000 Subject: [PATCH 2/2] Add waits and cleanup to e2e resource/perspective tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add explicit progress-bar waits and minor test improvements to reduce flakiness. Changes include: - Call waitForAllProgressBarsToDisappear after deleting perspectives in PerspectivesPage. - Add a detailed JSDoc for ResourcesPage.navigateToResourcesPageAndResetFilters describing its behavior (navigate to /resources, wait for progress bars, canvas render, reset filters, and wait for first resource item — 15s timeout) and recommending its use in beforeEach to get a clean state. - Update a test title capitalization and add a test step to return to the resources page and reset filters between perspective operations. - Add waitForAllProgressBarsToDisappear before deleting all perspectives in the perspective cleanup step. These changes aim to stabilize E2E tests by ensuring pages are fully loaded and filters are reset before interacting with UI elements. --- e2etests/pages/perspectives-page.ts | 1 + e2etests/pages/resources-page.ts | 24 ++++++++++++++++++++++++ e2etests/tests/perspective-tests.spec.ts | 7 ++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/e2etests/pages/perspectives-page.ts b/e2etests/pages/perspectives-page.ts index 2d14e6335..0268d32f2 100644 --- a/e2etests/pages/perspectives-page.ts +++ b/e2etests/pages/perspectives-page.ts @@ -168,6 +168,7 @@ export class PerspectivesPage extends BasePage { 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 bf0b98180..34f39635e 100644 --- a/e2etests/pages/resources-page.ts +++ b/e2etests/pages/resources-page.ts @@ -208,6 +208,30 @@ 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(); diff --git a/e2etests/tests/perspective-tests.spec.ts b/e2etests/tests/perspective-tests.spec.ts index 6f986ba03..c783e7b84 100644 --- a/e2etests/tests/perspective-tests.spec.ts +++ b/e2etests/tests/perspective-tests.spec.ts @@ -240,7 +240,7 @@ test.describe('[MPT-18579] Perspective Tests', { tag: ['@ui', '@resources', '@pe }); }); - test('[232969] user can create a perspective with multiple filters', async ({ resourcesPage, perspectivesPage }) => { + test('[232969] User can create a perspective with multiple filters', async ({ resourcesPage, perspectivesPage }) => { await resourcesPage.navigateToResourcesPageAndResetFilters(); const filter1 = 'Region'; @@ -298,6 +298,10 @@ test.describe('[MPT-18579] Perspective Tests', { tag: ['@ui', '@resources', '@pe 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); @@ -328,6 +332,7 @@ test.describe('[MPT-18579] Perspective Tests', { tag: ['@ui', '@resources', '@pe }) => { 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(); });