From e164914b70dc1c91d6b5fde64558e2cb47e6f405 Mon Sep 17 00:00:00 2001 From: Fran McDade <18710366+frano-m@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:11:52 +1000 Subject: [PATCH 1/4] fix: rewrite flaky anvil catalog filter e2e test (#4751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the filter select/deselect e2e tests to be self-contained, use test IDs and scoped locators, and fix race conditions that caused flaky failures — particularly on webkit where Escape could cancel a pending filter state update. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../anvilcatalog-filters.spec.ts | 279 +++++++++++++----- e2e/anvil-catalog/anvilcatalog-tabs.ts | 19 -- e2e/anvil-catalog/constants.ts | 6 + e2e/features/common/constants.ts | 1 + e2e/testFunctions.ts | 107 ------- 5 files changed, 219 insertions(+), 193 deletions(-) diff --git a/e2e/anvil-catalog/anvilcatalog-filters.spec.ts b/e2e/anvil-catalog/anvilcatalog-filters.spec.ts index fad641cc9..5188354e9 100644 --- a/e2e/anvil-catalog/anvilcatalog-filters.spec.ts +++ b/e2e/anvil-catalog/anvilcatalog-filters.spec.ts @@ -1,74 +1,219 @@ -import { test } from "@playwright/test"; +import { expect, Locator, Page, test } from "@playwright/test"; import { - testDeselectFiltersThroughSearchBar, - testSelectFiltersThroughSearchBar, -} from "../testFunctions"; -import { - ANVIL_CATALOG_FILTERS, - ANVIL_CATALOG_TABS, - CONSENT_CODE_INDEX, - DBGAP_ID_INDEX, - TERRA_WORKSPACE_INDEX, -} from "./anvilcatalog-tabs"; - -const filterList = [CONSENT_CODE_INDEX, DBGAP_ID_INDEX, TERRA_WORKSPACE_INDEX]; - -test('Check that selecting filters through the "Search all Filters" textbox works correctly on the Consortia tab', async ({ - page, -}) => { - await testSelectFiltersThroughSearchBar( - page, - ANVIL_CATALOG_TABS.CONSORTIA, - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) - ); -}); + KEYBOARD_KEYS, + MUI_CLASSES, + TEST_IDS, +} from "../features/common/constants"; +import { ANVIL_CATALOG_CATEGORY_NAMES } from "./constants"; -test('Check that selecting filters through the "Search all Filters" textbox works correctly on the Studies tab', async ({ - page, -}) => { - await testSelectFiltersThroughSearchBar( - page, - ANVIL_CATALOG_TABS.STUDIES, - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) - ); -}); +const ENTITIES = [ + { name: "Consortia", url: "/data/consortia" }, + { name: "Studies", url: "/data/studies" }, + { name: "Workspaces", url: "/data/workspaces" }, +]; -test('Check that selecting filters through the "Search all Filters" textbox works correctly on the Workspaces tab', async ({ - page, -}) => { - await testSelectFiltersThroughSearchBar( - page, - ANVIL_CATALOG_TABS.WORKSPACES, - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) - ); -}); +const FACET_NAMES = [ + ANVIL_CATALOG_CATEGORY_NAMES.CONSENT_CODE, + ANVIL_CATALOG_CATEGORY_NAMES.DB_GAP_ID, + ANVIL_CATALOG_CATEGORY_NAMES.TERRA_WORKSPACE_NAME, +]; -test('Check that deselecting filters through the "Search all Filters" textbox works correctly on the Consortia tab', async ({ - page, -}) => { - await testDeselectFiltersThroughSearchBar( - page, - ANVIL_CATALOG_TABS.CONSORTIA, - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) - ); -}); +test.describe("AnVIL Catalog filter search", () => { + for (const entity of ENTITIES) { + test.describe(`${entity.name} tab`, () => { + let filters: Locator; + let searchAllFilters: Locator; -test('Check that deselecting filters through the "Search all Filters" textbox works correctly on the Studies tab', async ({ - page, -}) => { - await testDeselectFiltersThroughSearchBar( - page, - ANVIL_CATALOG_TABS.STUDIES, - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) - ); -}); + test.beforeEach(async ({ page }) => { + await page.goto(entity.url); + filters = page.getByTestId(TEST_IDS.FILTERS); + searchAllFilters = page.getByTestId(TEST_IDS.SEARCH_ALL_FILTERS); + await filters.waitFor(); + }); + + test("selects filters through search bar", async ({ page }) => { + for (const filterName of FACET_NAMES) { + // Open filter dropdown, note first option name, close + const optionName = await getFirstOptionName( + filters, + page, + filterName + ); + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); + + // Search for the option and select it + await fillSearchAllFilters(searchAllFilters, optionName); + const filterItem = namedFilterItem(page, optionName); + await expect(filterItemCheckbox(filterItem)).not.toBeChecked(); + await filterItem.click(); + + // Verify filter tag appeared before closing the dropdown + await expect(filterTag(filters, optionName)).toBeVisible(); + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); + + // Clean up: remove filter tag + await filterTag(filters, optionName).dispatchEvent("click"); + await expect(filterTag(filters, optionName)).not.toBeVisible(); + } + }); + + test("deselects filters through search bar", async ({ page }) => { + for (const filterName of FACET_NAMES) { + // Select the first option through the dropdown + const optionName = await selectFirstOption(filters, page, filterName); + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); + await expect(filterTag(filters, optionName)).toBeVisible(); + + // Search for the option and deselect it + await fillSearchAllFilters(searchAllFilters, optionName); + const filterItem = namedFilterItem(page, optionName); + await expect(filterItemCheckbox(filterItem)).toBeChecked(); + await filterItem.click(); -test('Check that deselecting filters through the "Search all Filters" textbox works correctly on the Workspaces tab', async ({ - page, -}) => { - await testDeselectFiltersThroughSearchBar( - page, - ANVIL_CATALOG_TABS.WORKSPACES, - filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i]) - ); + // Verify filter tag disappeared before closing the dropdown + await expect(filterTag(filters, optionName)).not.toBeVisible(); + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); + } + }); + }); + } }); + +/** + * Escapes regex special characters in a string. + * @param s - The string to escape. + * @returns A string with all RegExp special characters escaped. + */ +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Extracts the display name from a filter item element. + * @param filterItem - A locator for the filter-item element. + * @returns The display name of the filter option. + */ +async function extractOptionName(filterItem: Locator): Promise { + return ( + await filterItem.getByTestId(TEST_IDS.FILTER_TERM).innerText() + ).trim(); +} + +/** + * Fills the "Search all filters" input with the given text. + * @param searchAllFilters - The search-all-filters container locator. + * @param text - The text to type into the search input. + */ +async function fillSearchAllFilters( + searchAllFilters: Locator, + text: string +): Promise { + const input = searchAllFilters.getByRole("combobox"); + await expect(input).toBeVisible(); + await input.fill(text); +} + +/** + * Returns the checkbox input within a filter item. + * @param filterItem - A filter-item locator. + * @returns A locator for the checkbox input. + */ +function filterItemCheckbox(filterItem: Locator): Locator { + return filterItem.locator("input[type='checkbox']"); +} + +/** + * Returns a regex matching a sidebar filter button, e.g. "Consent Code (5)". + * @param filterName - The name of the filter. + * @returns A RegExp matching the sidebar button text. + */ +function filterRegex(filterName: string): RegExp { + return new RegExp(escapeRegExp(filterName) + "\\s+\\(\\d+\\)\\s*"); +} + +/** + * Returns a locator for a filter tag (MuiChip) within the filters container. + * @param filters - The filters container locator. + * @param name - The filter option name to match. + * @returns A locator for the filter tag chip. + */ +function filterTag(filters: Locator, name: string): Locator { + return filters.locator(MUI_CLASSES.CHIP, { hasText: name }); +} + +/** + * Returns a locator for the first filter item. + * Searches at page level because dropdown popovers render in a portal. + * @param page - Page. + * @returns A locator for the first filter item. + */ +function firstFilterItem(page: Page): Locator { + return page.getByTestId(TEST_IDS.FILTER_ITEM).first(); +} + +/** + * Opens a sidebar filter dropdown and returns the name of its first option. + * @param filters - The filters container locator. + * @param page - Page (needed for popover content). + * @param filterName - The name of the sidebar filter to open. + * @returns The display name of the first option in the dropdown. + */ +async function getFirstOptionName( + filters: Locator, + page: Page, + filterName: string +): Promise { + await openFilterDropdown(filters, filterName); + return extractOptionName(firstFilterItem(page)); +} + +/** + * Returns a locator for a named filter item. + * Searches at page level because the search results dropdown renders in a + * MUI Autocomplete portal outside the search-all-filters container. + * @param page - Page. + * @param optionName - The display name of the filter option. + * @returns A locator for the matching filter item. + */ +function namedFilterItem(page: Page, optionName: string): Locator { + return page + .getByTestId(TEST_IDS.FILTER_ITEM) + .filter({ hasText: RegExp(`^${escapeRegExp(optionName)}\\s*\\d+\\s*`) }) + .first(); +} + +/** + * Opens a filter dropdown by clicking its sidebar button. + * Uses dispatchEvent because the filter menu sometimes intercepts regular clicks. + * @param filters - The filters container locator. + * @param filterName - The name of the sidebar filter to open. + */ +async function openFilterDropdown( + filters: Locator, + filterName: string +): Promise { + const button = filters.getByText(filterRegex(filterName)); + await expect(button).toBeVisible(); + await button.dispatchEvent("click"); +} + +/** + * Opens a sidebar filter dropdown, selects its first option, and returns the + * option name. Waits for the checkbox to be checked before returning. + * @param filters - The filters container locator. + * @param page - Page (needed for popover content). + * @param filterName - The name of the sidebar filter to open. + * @returns The display name of the selected option. + */ +async function selectFirstOption( + filters: Locator, + page: Page, + filterName: string +): Promise { + await openFilterDropdown(filters, filterName); + const option = firstFilterItem(page); + const name = await extractOptionName(option); + await option.click(); + await expect(filterItemCheckbox(option)).toBeChecked(); + return name; +} diff --git a/e2e/anvil-catalog/anvilcatalog-tabs.ts b/e2e/anvil-catalog/anvilcatalog-tabs.ts index 8b11f2250..33d014a37 100644 --- a/e2e/anvil-catalog/anvilcatalog-tabs.ts +++ b/e2e/anvil-catalog/anvilcatalog-tabs.ts @@ -10,25 +10,6 @@ import { } from "./constants"; const ANVIL_CATALOG_SEARCH_FILTERS_PLACEHOLDER_TEXT = "Search all filters..."; -export const ANVIL_CATALOG_FILTERS = [ - "Consent Code", - "Consortium", - "Data Type", - "dbGap Id", - "Disease (indication)", - "Study Design", - "Study", - "Terra Workspace Name", -]; - -export const CONSENT_CODE_INDEX = 0; -export const CONSORTIUM_INDEX = 1; -export const DATA_TYPE_INDEX = 2; -export const DBGAP_ID_INDEX = 3; -export const DISEASE_INDICATION_INDEX = 4; -export const STUDY_DESIGN_INDEX = 5; -export const STUDY_INDEX = 6; -export const TERRA_WORKSPACE_INDEX = 7; export const ANVIL_CATALOG_TABS: AnvilCatalogTabCollection = { CONSORTIA: { diff --git a/e2e/anvil-catalog/constants.ts b/e2e/anvil-catalog/constants.ts index 317a15740..48d83e898 100644 --- a/e2e/anvil-catalog/constants.ts +++ b/e2e/anvil-catalog/constants.ts @@ -1,3 +1,9 @@ +export const ANVIL_CATALOG_CATEGORY_NAMES = { + CONSENT_CODE: "Consent Code", + DB_GAP_ID: "dbGap Id", + TERRA_WORKSPACE_NAME: "Terra Workspace Name", +}; + export const ANVIL_CATALOG_COLUMN_NAMES = { CONSENT_CODE: "Consent Code", CONSENT_CODES: "Consent Codes", diff --git a/e2e/features/common/constants.ts b/e2e/features/common/constants.ts index ee4cc0b06..6a658fcf3 100644 --- a/e2e/features/common/constants.ts +++ b/e2e/features/common/constants.ts @@ -6,6 +6,7 @@ export const MUI_CLASSES = { ALERT: ".MuiAlert-root", BUTTON: ".MuiButton-root", BUTTON_GROUP: ".MuiButtonGroup-root", + CHIP: ".MuiChip-root", FORM_CONTROL: ".MuiFormControl-root", ICON_BUTTON: ".MuiIconButton-root", LIST: ".MuiList-root", diff --git a/e2e/testFunctions.ts b/e2e/testFunctions.ts index 517c7f8ac..618e81dac 100644 --- a/e2e/testFunctions.ts +++ b/e2e/testFunctions.ts @@ -62,25 +62,6 @@ export const getFirstRowNthColumnCellLocator = ( return getMthRowNthColumnCellLocator(page, 0, columnIndex); }; -/** - * Get a locator to the cell in the first row's nth column - * @param page - a Playwright page object - * @param columnIndex - the zero-indexed column to return - * @returns a Playwright locator object to the selected cell - **/ -export const getLastRowNthColumnTextLocator = ( - page: Page, - columnIndex: number -): Locator => { - return page - .getByRole("rowgroup") - .nth(1) - .getByRole("row") - .last() - .getByRole("cell") - .nth(columnIndex); -}; - /** * Tests that the tab url goes to a valid page and that the correct tab (and only * the correct tab) appears selected @@ -716,94 +697,6 @@ function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -/** - * Run a test that gets the first filter option of each of the filters specified in - * filterNames, then attempts to select each through the filter search bar. - * @param page - a Playwright page object - * @param tab - the Tab object to run the test on - * @param filterNames - an array of potential filter names on the selected tab - */ -export async function testSelectFiltersThroughSearchBar( - page: Page, - tab: TabDescription, - filterNames: string[] -): Promise { - await page.goto(tab.url); - for (const filterName of filterNames) { - // Get the first filter option - await expect(page.getByText(filterRegex(filterName))).toBeVisible(); - // (dispatchevent necessary because the filter menu sometimes interrupts the click event) - await page.getByText(filterRegex(filterName)).dispatchEvent("click"); - const firstFilterOptionLocator = getFirstFilterOptionLocator(page); - const filterOptionName = await getFilterOptionName( - firstFilterOptionLocator - ); - await page.locator("body").click(); - // Search for the filter option - const searchFiltersInputLocator = page.getByPlaceholder( - tab.searchFiltersPlaceholderText, - { exact: true } - ); - await expect(searchFiltersInputLocator).toBeVisible(); - await searchFiltersInputLocator.fill(filterOptionName); - // Select a filter option with a matching name - await getNamedFilterOptionLocator(page, filterOptionName).first().click(); - await page.locator("body").click(); - const filterTagLocator = getFilterTagLocator(page, filterOptionName); - // Check the filter tag is selected and click it to reset the filter - await expect(filterTagLocator).toBeVisible(); - await filterTagLocator.dispatchEvent("click"); - } -} - -/** - * Run a test that selects the first filter option of each of the filters specified in - * filterNames, then attempts to deselect each through the filter search bar. - * @param page - a Playwright page object - * @param tab - the Tab object to run the test on - * @param filterNames - an array of potential filter names on the selected tab - */ -export async function testDeselectFiltersThroughSearchBar( - page: Page, - tab: TabDescription, - filterNames: string[] -): Promise { - await page.goto(tab.url); - for (const filterName of filterNames) { - // Select each filter option - await expect(page.getByText(filterRegex(filterName))).toBeVisible(); - // (dispatchevent necessary because the filter menu sometimes interrupts the click event) - await page.getByText(filterRegex(filterName)).dispatchEvent("click"); - const firstFilterOptionLocator = getFirstFilterOptionLocator(page); - const filterOptionName = await getFilterOptionName( - firstFilterOptionLocator - ); - await firstFilterOptionLocator.click(); - // Wait for the checkbox to be checked, confirming the filter state update completed. - await expect(firstFilterOptionLocator.getByRole("checkbox")).toBeChecked(); - await page.waitForLoadState("load"); - await page.locator("body").click(); - // Search for and check the selected filter - const searchFiltersInputLocator = page.getByPlaceholder( - tab.searchFiltersPlaceholderText, - { exact: true } - ); - await expect(searchFiltersInputLocator).toBeVisible(); - await searchFiltersInputLocator.fill(filterOptionName); - const filterOptionLocator = getNamedFilterOptionLocator( - page, - filterOptionName - ); - await expect(filterOptionLocator).toBeVisible(); - const checkboxLocator = filterOptionLocator.getByRole("checkbox"); - await expect(checkboxLocator).toBeChecked(); - await checkboxLocator.click(); - await page.locator("body").click(); - const filterTagLocator = getFilterTagLocator(page, filterOptionName); - await expect(filterTagLocator).not.toBeVisible(); - } -} - /** * Make an export request that leaves only the minimal number of checkboxes selected * @param page - a Playwright page object From 2891b0c598885eded18fdb67462657d11614d7cd Mon Sep 17 00:00:00 2001 From: Fran McDade Date: Thu, 9 Apr 2026 17:38:36 +1000 Subject: [PATCH 2/4] Update e2e/anvil-catalog/constants.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- e2e/anvil-catalog/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/anvil-catalog/constants.ts b/e2e/anvil-catalog/constants.ts index 48d83e898..3e349c970 100644 --- a/e2e/anvil-catalog/constants.ts +++ b/e2e/anvil-catalog/constants.ts @@ -1,6 +1,6 @@ export const ANVIL_CATALOG_CATEGORY_NAMES = { CONSENT_CODE: "Consent Code", - DB_GAP_ID: "dbGap Id", + DBGAP_ID: "dbGap Id", TERRA_WORKSPACE_NAME: "Terra Workspace Name", }; From e81d8b100ccf66b0e56e6b2b67fbb44eba1a37bb Mon Sep 17 00:00:00 2001 From: Fran McDade <18710366+frano-m@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:40:37 +1000 Subject: [PATCH 3/4] fix: rename DB_GAP_ID to DBGAP_ID (#4751) Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/anvil-catalog/anvilcatalog-filters.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/anvil-catalog/anvilcatalog-filters.spec.ts b/e2e/anvil-catalog/anvilcatalog-filters.spec.ts index 5188354e9..d67301c3c 100644 --- a/e2e/anvil-catalog/anvilcatalog-filters.spec.ts +++ b/e2e/anvil-catalog/anvilcatalog-filters.spec.ts @@ -14,7 +14,7 @@ const ENTITIES = [ const FACET_NAMES = [ ANVIL_CATALOG_CATEGORY_NAMES.CONSENT_CODE, - ANVIL_CATALOG_CATEGORY_NAMES.DB_GAP_ID, + ANVIL_CATALOG_CATEGORY_NAMES.DBGAP_ID, ANVIL_CATALOG_CATEGORY_NAMES.TERRA_WORKSPACE_NAME, ]; From b54ac36cfcdf844ae3584bf22140bc6553622b33 Mon Sep 17 00:00:00 2001 From: Fran McDade <18710366+frano-m@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:30:15 +1000 Subject: [PATCH 4/4] fix: scope filter locators to active popover and autocomplete popper (#4751) Fix stale popover issue where MUI keeps old popovers in the DOM during exit animations, causing firstFilterItem to match items from the wrong dropdown. Wait for popovers to fully unmount before opening new ones, scope filter items to the active popover, and scope search results to the autocomplete popper. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../anvilcatalog-filters.spec.ts | 124 ++++++++++++------ e2e/features/common/constants.ts | 1 + 2 files changed, 87 insertions(+), 38 deletions(-) diff --git a/e2e/anvil-catalog/anvilcatalog-filters.spec.ts b/e2e/anvil-catalog/anvilcatalog-filters.spec.ts index d67301c3c..88c39bed0 100644 --- a/e2e/anvil-catalog/anvilcatalog-filters.spec.ts +++ b/e2e/anvil-catalog/anvilcatalog-filters.spec.ts @@ -34,22 +34,22 @@ test.describe("AnVIL Catalog filter search", () => { test("selects filters through search bar", async ({ page }) => { for (const filterName of FACET_NAMES) { // Open filter dropdown, note first option name, close - const optionName = await getFirstOptionName( - filters, - page, - filterName - ); + await openFilterDropdown(filters, filterName); + const optionName = await getFirstOptionName(page); await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); + await expectFilterPopoverClosed(page); // Search for the option and select it await fillSearchAllFilters(searchAllFilters, optionName); const filterItem = namedFilterItem(page, optionName); - await expect(filterItemCheckbox(filterItem)).not.toBeChecked(); + await expectFilterItemNotSelected(filterItem); await filterItem.click(); + await expectFilterItemSelected(filterItem); + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); + await expectAutocompletePopperClosed(page); - // Verify filter tag appeared before closing the dropdown + // Verify filter tag appeared await expect(filterTag(filters, optionName)).toBeVisible(); - await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); // Clean up: remove filter tag await filterTag(filters, optionName).dispatchEvent("click"); @@ -61,24 +61,27 @@ test.describe("AnVIL Catalog filter search", () => { for (const filterName of FACET_NAMES) { // Select the first option through the dropdown const optionName = await selectFirstOption(filters, page, filterName); - await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); await expect(filterTag(filters, optionName)).toBeVisible(); // Search for the option and deselect it await fillSearchAllFilters(searchAllFilters, optionName); const filterItem = namedFilterItem(page, optionName); - await expect(filterItemCheckbox(filterItem)).toBeChecked(); + await expectFilterItemSelected(filterItem); await filterItem.click(); + await expectFilterItemNotSelected(filterItem); + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); + await expectAutocompletePopperClosed(page); - // Verify filter tag disappeared before closing the dropdown + // Verify filter tag disappeared await expect(filterTag(filters, optionName)).not.toBeVisible(); - await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); } }); }); } }); +/* ——————————————————————————— helpers ——————————————————————————— */ + /** * Escapes regex special characters in a string. * @param s - The string to escape. @@ -88,6 +91,54 @@ function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +/** + * Waits for the autocomplete popper to be fully unmounted from the DOM. + * @param page - Page. + */ +async function expectAutocompletePopperClosed(page: Page): Promise { + await expect(page.locator(MUI_CLASSES.AUTOCOMPLETE_POPPER)).toHaveCount(0); +} + +/** + * Waits for the autocomplete popper to be visible. + * @param page - Page. + */ +async function expectAutocompletePopperOpen(page: Page): Promise { + await expect(page.locator(MUI_CLASSES.AUTOCOMPLETE_POPPER)).toBeVisible(); +} + +/** + * Asserts that a filter item is not selected. + * @param filterItem - A filter-item locator. + */ +async function expectFilterItemNotSelected(filterItem: Locator): Promise { + await expect(filterItem).not.toHaveClass(/Mui-selected/); +} + +/** + * Asserts that a filter item is selected. + * @param filterItem - A filter-item locator. + */ +async function expectFilterItemSelected(filterItem: Locator): Promise { + await expect(filterItem).toHaveClass(/Mui-selected/); +} + +/** + * Waits for all filter popovers to be fully unmounted from the DOM. + * @param page - Page. + */ +async function expectFilterPopoverClosed(page: Page): Promise { + await expect(filterPopover(page)).toHaveCount(0); +} + +/** + * Waits for the filter popover to be visible. + * @param page - Page. + */ +async function expectFilterPopoverOpen(page: Page): Promise { + await expect(filterPopover(page)).toBeVisible(); +} + /** * Extracts the display name from a filter item element. * @param filterItem - A locator for the filter-item element. @@ -100,7 +151,7 @@ async function extractOptionName(filterItem: Locator): Promise { } /** - * Fills the "Search all filters" input with the given text. + * Fills the "Search all filters" input and waits for the results to appear. * @param searchAllFilters - The search-all-filters container locator. * @param text - The text to type into the search input. */ @@ -108,18 +159,19 @@ async function fillSearchAllFilters( searchAllFilters: Locator, text: string ): Promise { + await expectAutocompletePopperClosed(searchAllFilters.page()); const input = searchAllFilters.getByRole("combobox"); - await expect(input).toBeVisible(); await input.fill(text); + await expectAutocompletePopperOpen(searchAllFilters.page()); } /** - * Returns the checkbox input within a filter item. - * @param filterItem - A filter-item locator. - * @returns A locator for the checkbox input. + * Returns a locator for the filter popover. + * @param page - Page. + * @returns A locator for the filter popover. */ -function filterItemCheckbox(filterItem: Locator): Locator { - return filterItem.locator("input[type='checkbox']"); +function filterPopover(page: Page): Locator { + return page.getByTestId(TEST_IDS.FILTER_POPOVER); } /** @@ -142,41 +194,32 @@ function filterTag(filters: Locator, name: string): Locator { } /** - * Returns a locator for the first filter item. - * Searches at page level because dropdown popovers render in a portal. + * Returns a locator for the first filter item in the open popover. * @param page - Page. * @returns A locator for the first filter item. */ function firstFilterItem(page: Page): Locator { - return page.getByTestId(TEST_IDS.FILTER_ITEM).first(); + return filterPopover(page).getByTestId(TEST_IDS.FILTER_ITEM).first(); } /** - * Opens a sidebar filter dropdown and returns the name of its first option. - * @param filters - The filters container locator. - * @param page - Page (needed for popover content). - * @param filterName - The name of the sidebar filter to open. - * @returns The display name of the first option in the dropdown. + * Returns the name of the first filter item in the open popover. + * @param page - Page. + * @returns The display name of the first option. */ -async function getFirstOptionName( - filters: Locator, - page: Page, - filterName: string -): Promise { - await openFilterDropdown(filters, filterName); +async function getFirstOptionName(page: Page): Promise { return extractOptionName(firstFilterItem(page)); } /** - * Returns a locator for a named filter item. - * Searches at page level because the search results dropdown renders in a - * MUI Autocomplete portal outside the search-all-filters container. + * Returns a locator for a named filter item in the autocomplete popper. * @param page - Page. * @param optionName - The display name of the filter option. * @returns A locator for the matching filter item. */ function namedFilterItem(page: Page, optionName: string): Locator { return page + .locator(MUI_CLASSES.AUTOCOMPLETE_POPPER) .getByTestId(TEST_IDS.FILTER_ITEM) .filter({ hasText: RegExp(`^${escapeRegExp(optionName)}\\s*\\d+\\s*`) }) .first(); @@ -192,14 +235,16 @@ async function openFilterDropdown( filters: Locator, filterName: string ): Promise { + await expectFilterPopoverClosed(filters.page()); const button = filters.getByText(filterRegex(filterName)); await expect(button).toBeVisible(); await button.dispatchEvent("click"); + await expectFilterPopoverOpen(filters.page()); } /** * Opens a sidebar filter dropdown, selects its first option, and returns the - * option name. Waits for the checkbox to be checked before returning. + * option name. Waits for the item to be selected before returning. * @param filters - The filters container locator. * @param page - Page (needed for popover content). * @param filterName - The name of the sidebar filter to open. @@ -213,7 +258,10 @@ async function selectFirstOption( await openFilterDropdown(filters, filterName); const option = firstFilterItem(page); const name = await extractOptionName(option); + await expectFilterItemNotSelected(option); await option.click(); - await expect(filterItemCheckbox(option)).toBeChecked(); + await expectFilterItemSelected(option); + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); + await expectFilterPopoverClosed(page); return name; } diff --git a/e2e/features/common/constants.ts b/e2e/features/common/constants.ts index 6a658fcf3..8cabac8c5 100644 --- a/e2e/features/common/constants.ts +++ b/e2e/features/common/constants.ts @@ -4,6 +4,7 @@ export const KEYBOARD_KEYS = { export const MUI_CLASSES = { ALERT: ".MuiAlert-root", + AUTOCOMPLETE_POPPER: ".MuiAutocomplete-popper", BUTTON: ".MuiButton-root", BUTTON_GROUP: ".MuiButtonGroup-root", CHIP: ".MuiChip-root",