diff --git a/e2e/anvil-catalog/anvilcatalog-filters.spec.ts b/e2e/anvil-catalog/anvilcatalog-filters.spec.ts index 145be65dd..6f584601f 100644 --- a/e2e/anvil-catalog/anvilcatalog-filters.spec.ts +++ b/e2e/anvil-catalog/anvilcatalog-filters.spec.ts @@ -1,9 +1,18 @@ -import { expect, Locator, Page, test } from "@playwright/test"; +import type { Locator } from "@playwright/test"; +import { expect, test } from "@playwright/test"; +import { TEST_IDS } from "../features/common/constants"; import { - KEYBOARD_KEYS, - MUI_CLASSES, - TEST_IDS, -} from "../features/common/constants"; + closeAutocompletePopper, + closeFilterPopover, + expectFilterItemNotSelected, + expectFilterItemSelected, + fillSearchAllFilters, + filterTag, + getFirstOptionName, + namedFilterItem, + openFilterDropdown, + selectFirstOption, +} from "../features/common/filters"; import { ANVIL_CATALOG_CATEGORY_NAMES } from "./constants"; const ENTITIES = [ @@ -36,8 +45,7 @@ test.describe("AnVIL Catalog filter search", () => { // Open filter dropdown, note first option name, close await openFilterDropdown(filters, filterName); const optionName = await getFirstOptionName(page); - await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); - await expectFilterPopoverClosed(page); + await closeFilterPopover(page); // Search for the option and select it await fillSearchAllFilters(searchAllFilters, optionName); @@ -45,8 +53,7 @@ test.describe("AnVIL Catalog filter search", () => { await expectFilterItemNotSelected(filterItem); await filterItem.click(); await expectFilterItemSelected(filterItem); - await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); - await expectAutocompletePopperClosed(page); + await closeAutocompletePopper(page); // Verify filter tag appeared await expect(filterTag(filters, optionName)).toBeVisible(); @@ -69,8 +76,7 @@ test.describe("AnVIL Catalog filter search", () => { await expectFilterItemSelected(filterItem); await filterItem.click(); await expectFilterItemNotSelected(filterItem); - await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); - await expectAutocompletePopperClosed(page); + await closeAutocompletePopper(page); // Verify filter tag disappeared await expect(filterTag(filters, optionName)).not.toBeVisible(); @@ -79,205 +85,3 @@ test.describe("AnVIL Catalog filter search", () => { }); } }); - -/* ——————————————————————————— helpers ——————————————————————————— */ - -/** - * 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, "\\$&"); -} - -/** - * 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. - * @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 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. - */ -async function fillSearchAllFilters( - searchAllFilters: Locator, - text: string -): Promise { - await expectAutocompletePopperClosed(searchAllFilters.page()); - const input = searchAllFilters.getByRole("combobox"); - await input.fill(text); - await expectAutocompletePopperOpen(searchAllFilters.page()); -} - -/** - * Returns a locator for the filter popover. - * @param page - Page. - * @returns A locator for the filter popover. - */ -function filterPopover(page: Page): Locator { - return page.getByTestId(TEST_IDS.FILTER_POPOVER); -} - -/** - * 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 in the open popover. - * @param page - Page. - * @returns A locator for the first filter item. - */ -function firstFilterItem(page: Page): Locator { - return filterPopover(page).getByTestId(TEST_IDS.FILTER_ITEM).first(); -} - -/** - * 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(page: Page): Promise { - return extractOptionName(firstFilterItem(page)); -} - -/** - * 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(); -} - -/** - * 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 { - await expectFilterPopoverClosed(filters.page()); - const button = filters.getByText(filterRegex(filterName)); - await expect(button).toBeVisible(); - await button.dispatchEvent("click"); - await expectFilterPopoverOpen(filters.page()); -} - -/** - * Returns a locator for a named filter item in the open filter popover. - * @param page - Page. - * @param optionName - The display name of the filter option. - * @returns A locator for the matching filter item. - */ -function namedPopoverFilterItem(page: Page, optionName: string): Locator { - return filterPopover(page) - .getByTestId(TEST_IDS.FILTER_ITEM) - .filter({ hasText: new RegExp(`^${escapeRegExp(optionName)}\\s*\\d+\\s*`) }) - .first(); -} - -/** - * Opens a sidebar filter dropdown, selects its first option, and returns the - * 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. - * @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 expectFilterItemNotSelected(option); - await option.click(); - // Re-locate by name rather than position since the list may re-sort after - // selection, causing the positional `.first()` locator to resolve to a - // different (non-selected) element. - await expectFilterItemSelected(namedPopoverFilterItem(page, name)); - await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); - await expectFilterPopoverClosed(page); - return name; -} diff --git a/e2e/anvil/anvil-filters.spec.ts b/e2e/anvil/anvil-filters.spec.ts index cbc0a6593..91093d694 100644 --- a/e2e/anvil/anvil-filters.spec.ts +++ b/e2e/anvil/anvil-filters.spec.ts @@ -1,157 +1,264 @@ +import type { Locator, Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; +import { MUI_CLASSES, TEST_IDS } from "../features/common/constants"; import { - filterRegex, - getFirstRowNthColumnCellLocator, - testClearAll, - testFilterCounts, - testFilterPersistence, - testFilterPresence, - testFilterTags, -} from "../testFunctions"; -import { - ANVIL_FILTER_NAMES, - ANVIL_TAB_TEST_ORDER, - ANVIL_TABS, - BIOSAMPLE_TYPE_INDEX, - CONSENT_GROUP_INDEX, - DATASET_INDEX, - DIAGNOSIS_INDEX, - FILE_FORMAT_INDEX, - REPORTED_ETHNICITY_INDEX, -} from "./anvil-tabs"; - -const FILTER_INDEX_LIST = [ - DATASET_INDEX, - DIAGNOSIS_INDEX, - REPORTED_ETHNICITY_INDEX, - FILE_FORMAT_INDEX, - CONSENT_GROUP_INDEX, -]; -const FILTER_INDEX_LIST_SHORT = [ - BIOSAMPLE_TYPE_INDEX, - FILE_FORMAT_INDEX, - DIAGNOSIS_INDEX, -]; - -test("Check that all filters exist on the Datasets tab and are clickable", async ({ - page, -}) => { - await testFilterPresence(page, ANVIL_TABS.DATASETS, ANVIL_FILTER_NAMES); -}); + closeFilterPopover, + expectFilterItemNotSelected, + expectFilterItemSelected, + filterPopover, + filterTag, + firstFilterItem, + namedPopoverFilterItem, + openFilterDropdown, + selectFirstOption, +} from "../features/common/filters"; +import { ANVIL_CMG_CATEGORY_NAMES } from "./constants"; -test("Check that all filters exist on the Donors tab and are clickable", async ({ - page, -}) => { - await testFilterPresence(page, ANVIL_TABS.DONORS, ANVIL_FILTER_NAMES); -}); +const ENTITIES = { + ACTIVITIES: { name: "Activities", url: "/activities" }, + BIOSAMPLES: { name: "BioSamples", url: "/biosamples" }, + DATASETS: { name: "Datasets", url: "/datasets" }, + DONORS: { name: "Donors", url: "/donors" }, + FILES: { name: "Files", url: "/files" }, +}; -test("Check that all filters exist on the BioSamples tab and are clickable", async ({ - page, -}) => { - await testFilterPresence(page, ANVIL_TABS.BIOSAMPLES, ANVIL_FILTER_NAMES); -}); +const FACET_NAMES = Object.values(ANVIL_CMG_CATEGORY_NAMES); -test("Check that all filters exist on the Activities tab and are clickable", async ({ - page, -}) => { - await testFilterPresence(page, ANVIL_TABS.ACTIVITIES, ANVIL_FILTER_NAMES); -}); +test.describe("AnVIL CMG filters", () => { + // Two representative entities: the filter sidebar is the same component + // across all entity pages, so per-entity coverage of the sidebar UI itself + // is mostly redundant. Files and Donors give us opposite-shape data backing + // (file-level rows vs donor-level rows) for catching entity-specific empties. + for (const entity of [ENTITIES.FILES, ENTITIES.DONORS]) { + test(`every filter on the ${entity.name} page is listed and opens`, async ({ + page, + }) => { + await goToEntity(page, entity.url); + const filters = await getFilters(page); -test("Check that all filters exist on the Files tab and are clickable", async ({ - page, -}) => { - await testFilterPresence(page, ANVIL_TABS.FILES, ANVIL_FILTER_NAMES); -}); + // Strict match — catches both missing AND unexpected filters. + const names = await getFilterNames(filters); + expect([...names].sort()).toEqual([...FACET_NAMES].sort()); -test("Check that an arbitrary filter on the Datasets tab creates at least one checkbox, and that checking up to the first five does not cause an error and does not cause there to be no entries in the table", async ({ - page, -}) => { - // Goto the datasets tab - await page.goto(ANVIL_TABS.DATASETS.url); - await expect( - page.getByRole("tab").getByText(ANVIL_TABS.DATASETS.tabName) - ).toBeVisible(); - - // Select a filter - await page - .getByRole("button") - .getByText(filterRegex(ANVIL_FILTER_NAMES[FILTER_INDEX_LIST[0]])) - .click(); - // Expect all checkboxes to be unchecked initially and to work properly - await expect(page.getByRole("checkbox").first()).toBeVisible(); - const allCheckboxes = await page.getByRole("checkbox").all(); - for (let i = 0; i < allCheckboxes.length && i < 5; i++) { - const checkbox = allCheckboxes[i]; - await checkbox.scrollIntoViewIfNeeded(); - await expect(checkbox).not.toBeChecked(); - await checkbox.click(); - await expect(checkbox).toBeChecked(); + // Each opens with at least one option and closes cleanly. + for (const filterName of FACET_NAMES) { + await openFilterDropdown(filters, filterName); + await expect(firstFilterItem(page)).toBeVisible(); + await closeFilterPopover(page); + } + }); } - await page.locator("body").click(); - await expect(getFirstRowNthColumnCellLocator(page, 0)).toBeVisible(); -}); -test("Check that filter checkboxes are persistent across pages on an arbitrary filter", async ({ - page, -}) => { - await testFilterPersistence( + test(`selecting up to 3 options on the File Format filter on the Datasets page leaves the table populated`, async ({ page, - ANVIL_FILTER_NAMES[FILE_FORMAT_INDEX], - ANVIL_TAB_TEST_ORDER.map((x) => ANVIL_TABS[x]) - ); -}); + }) => { + const OPTIONS_TO_TEST = 3; -test("Check that filter menu counts match actual counts on the Datasets tab", async ({ - page, -}) => { - const result = await testFilterCounts( - page, - ANVIL_TABS.DATASETS, - FILTER_INDEX_LIST.map((x) => ANVIL_FILTER_NAMES[x]), - ANVIL_TABS.DATASETS.maxPages ?? 0 - ); - if (!result) { - test.fail(); - } -}); + await goToEntity(page, ENTITIES.DATASETS.url); + const filters = await getFilters(page); + await openFilterDropdown(filters, ANVIL_CMG_CATEGORY_NAMES.FILE_FORMAT); + const items = filterPopover(page).getByTestId(TEST_IDS.FILTER_ITEM); + const itemCount = Math.min(await items.count(), OPTIONS_TO_TEST); + // Within a single facet popover, selecting items doesn't reorder the list, + // so positional locators stay valid across iterations. + for (let i = 0; i < itemCount; i++) { + const item = items.nth(i); + await item.dispatchEvent("click"); + await expectFilterItemSelected(item); + } + await closeFilterPopover(page); + await expect(firstTableCell(page)).toBeVisible(); + }); -test("Check that filter menu counts match actual counts on the Activities tab", async ({ - page, -}) => { - await testFilterCounts( + test(`selecting File Format on the Files page persists across the other pages`, async ({ page, - ANVIL_TABS.ACTIVITIES, - FILTER_INDEX_LIST.map((x) => ANVIL_FILTER_NAMES[x]), - ANVIL_TABS.ACTIVITIES.maxPages ?? 0 - ); -}); + }) => { + await goToEntity(page, ENTITIES.FILES.url); + const filters = await getFilters(page); + const filterValue = await selectFirstOption( + filters, + page, + ANVIL_CMG_CATEGORY_NAMES.FILE_FORMAT + ); + await expect(filterTag(filters, filterValue)).toBeVisible(); + await expect(firstTableCell(page)).toBeVisible(); + + for (const entity of Object.values(ENTITIES)) { + if (entity === ENTITIES.FILES) continue; + await entityByName(page, entity.name).dispatchEvent("click"); + await expect(filterTag(filters, filterValue)).toBeVisible(); + await openFilterDropdown(filters, ANVIL_CMG_CATEGORY_NAMES.FILE_FORMAT); + await expectFilterItemSelected(namedPopoverFilterItem(page, filterValue)); + await closeFilterPopover(page); + } + + await entityByName(page, ENTITIES.FILES.name).dispatchEvent("click"); + await expect(firstTableCell(page)).toBeVisible(); + await expect(filterTag(filters, filterValue)).toBeVisible(); + }); -test("Check that the filter tags match the selected filter for an arbitrary filter on the Files tab", async ({ - page, -}) => { - await testFilterTags( + test(`filter counts on the Activities page match the table results count`, async ({ page, - ANVIL_TABS.FILES, - FILTER_INDEX_LIST_SHORT.map((x) => ANVIL_FILTER_NAMES[x]) - ); -}); + }) => { + const MAX_PAGE_SIZE = 25; + + await goToEntity(page, ENTITIES.ACTIVITIES.url); + const filters = await getFilters(page); + // Selections accumulate across iterations. Each facet's option count is + // computed given all currently-applied filters, so the table's result + // count after clicking an option always equals that option's displayed + // count — regardless of previous selections from other facets. + for (const filterName of [ + ANVIL_CMG_CATEGORY_NAMES.DIAGNOSIS, + ANVIL_CMG_CATEGORY_NAMES.REPORTED_ETHNICITY, + ANVIL_CMG_CATEGORY_NAMES.FILE_FORMAT, + ]) { + await openFilterDropdown(filters, filterName); + const firstItem = firstFilterItem(page); + const itemCount = await readFilterCount(firstItem); + expect(itemCount).toBeGreaterThan(0); + + await firstItem.dispatchEvent("click"); + await expectFilterItemSelected(firstItem); + await closeFilterPopover(page); + await expect(firstTableCell(page)).toBeVisible(); + + const visible = Math.min(itemCount, MAX_PAGE_SIZE); + await expect( + page.getByText(`Results 1 - ${visible} of ${itemCount}`) + ).toBeVisible(); + } + }); -test("Check that the filter tags match the selected filter for an arbitrary filter on the BioSamples tab", async ({ - page, -}) => { - await testFilterTags( + test(`filter tags on the Biosamples page match selected filters and clear them when clicked`, async ({ page, - ANVIL_TABS.BIOSAMPLES, - FILTER_INDEX_LIST_SHORT.map((x) => ANVIL_FILTER_NAMES[x]) - ); -}); + }) => { + await goToEntity(page, ENTITIES.BIOSAMPLES.url); + const filters = await getFilters(page); + for (const filterName of [ + ANVIL_CMG_CATEGORY_NAMES.BIOSAMPLE_TYPE, + ANVIL_CMG_CATEGORY_NAMES.FILE_FORMAT, + ANVIL_CMG_CATEGORY_NAMES.DIAGNOSIS, + ]) { + const filterValue = await selectFirstOption(filters, page, filterName); + + await expect(filterTag(filters, filterValue)).toBeVisible(); + + await filterTag(filters, filterValue).dispatchEvent("click"); + await expect(filterTag(filters, filterValue)).not.toBeVisible(); -test("Check that the clear all button functions on the files tab", async ({ - page, -}) => { - await testClearAll( + await openFilterDropdown(filters, filterName); + await expectFilterItemNotSelected( + namedPopoverFilterItem(page, filterValue) + ); + await closeFilterPopover(page); + } + }); + + test("Clear All on the Files page clears every selected filter", async ({ page, - ANVIL_TABS.FILES, - FILTER_INDEX_LIST_SHORT.map((x) => ANVIL_FILTER_NAMES[x]) - ); + }) => { + // File Format and Dataset are file-level facets: every file row has a real + // value for both, so the first option of each is guaranteed not to be + // "Unspecified" and the two selections produce two distinct filter tags. + // Donor/biosample-level facets (Diagnosis, BioSample Type, etc.) are + // populated transitively and can collapse to a single "Unspecified" entry + // on the Files page, breaking per-name tag assertions. + const FILE_LEVEL_FILTERS = [ + ANVIL_CMG_CATEGORY_NAMES.FILE_FORMAT, + ANVIL_CMG_CATEGORY_NAMES.DATASET, + ]; + + await goToEntity(page, ENTITIES.FILES.url); + const filters = await getFilters(page); + + const filterValues: string[] = []; + + for (const filterName of FILE_LEVEL_FILTERS) { + filterValues.push(await selectFirstOption(filters, page, filterName)); + } + + for (const filterValue of filterValues) { + await expect(filterTag(filters, filterValue)).toBeVisible(); + } + + await page.getByTestId(TEST_IDS.CLEAR_ALL_FILTERS).click(); + + for (const filterValue of filterValues) { + await expect(filterTag(filters, filterValue)).not.toBeVisible(); + } + + for (let i = 0; i < FILE_LEVEL_FILTERS.length; i++) { + await openFilterDropdown(filters, FILE_LEVEL_FILTERS[i]); + await expectFilterItemNotSelected( + namedPopoverFilterItem(page, filterValues[i]) + ); + await closeFilterPopover(page); + } + }); }); + +/* ——————————————————————————— helpers ——————————————————————————— */ + +/** + * Return a locator for an entity tab in the entity tab list. + * @param page - Page. + * @param entityName - The display name of the entity. + * @returns A locator for the matching entity. + */ +function entityByName(page: Page, entityName: string): Locator { + return page.getByRole("tab").getByText(entityName, { exact: true }); +} + +/** + * Return a locator for the first cell in the first data row. + * @param page - Page. + * @returns A locator for the first cell of the first row. + */ +function firstTableCell(page: Page): Locator { + return page.getByTestId(TEST_IDS.TABLE_FIRST_CELL); +} + +/** + * Read the display names of every sidebar filter button. Each button renders + * its name followed by a parenthesized count (e.g. "Dataset (3)"); strip the + * count to get just the name. + * @param filters - The filters container locator. + * @returns The list of filter display names rendered in the sidebar. + */ +async function getFilterNames(filters: Locator): Promise { + const texts = await filters.locator(MUI_CLASSES.BUTTON).allInnerTexts(); + return texts.map((t) => t.trim().replace(/\s?\(\d+\)$/, "")); +} + +/** + * Locate the filters sidebar and wait for it to mount. + * @param page - Page. + * @returns The filters container locator. + */ +async function getFilters(page: Page): Promise { + const filters = page.getByTestId(TEST_IDS.FILTERS); + await filters.waitFor(); + return filters; +} + +/** + * Navigate to an entity page. + * @param page - Page. + * @param url - Entity page URL. + */ +async function goToEntity(page: Page, url: string): Promise { + await page.goto(url); +} + +/** + * Read the trailing count from a filter item, e.g. "T2D\n42" -> 42. + * @param filterItem - A locator for the filter-item element. + * @returns The numeric count shown on the filter item, or NaN if not present. + */ +async function readFilterCount(filterItem: Locator): Promise { + const countText = ( + await filterItem.getByTestId(TEST_IDS.FILTER_COUNT).innerText() + ).trim(); + return Number(countText); +} diff --git a/e2e/anvil/constants.ts b/e2e/anvil/constants.ts index bb12a0b1f..758170013 100644 --- a/e2e/anvil/constants.ts +++ b/e2e/anvil/constants.ts @@ -1,5 +1,21 @@ import { ColumnDescription } from "e2e/testInterfaces"; +export const ANVIL_CMG_CATEGORY_NAMES = { + ACCESS: "Access", + ANATOMICAL_SITE: "Anatomical Site", + BIOSAMPLE_TYPE: "BioSample Type", + CONSENT_GROUP: "Consent Group", + DATASET: "Dataset", + DATA_MODALITY: "Data Modality", + DIAGNOSIS: "Diagnosis", + FILE_FORMAT: "File Format", + IDENTIFIER: "Identifier", + ORGANISM_TYPE: "Organism Type", + PHENOTYPE: "Phenotype", + PHENOTYPIC_SEX: "Phenotypic Sex", + REPORTED_ETHNICITY: "Reported Ethnicity", +}; + export const ANVIL_DATASETS_BACKPAGE_HEADER_NAMES = { CONSENT_GROUP: "Consent group", DATASET_ID: "Dataset Id", @@ -324,29 +340,3 @@ export const ANVIL_FILES_PRESELECTED_COLUMNS_BY_NAME = { sortable: true, }, }; - -export const ANVIL_FILES_SELECTABLE_COLUMNS_BY_NAME = { - [ANVIL_COLUMN_NAMES.PHENOTYPIC_SEX]: { - name: ANVIL_COLUMN_NAMES.PHENOTYPIC_SEX, - pluralizedLabel: - PLURALIZED_METADATA_LABEL[ANVIL_COLUMN_NAMES.PHENOTYPIC_SEX], - sortable: true, - }, - [ANVIL_COLUMN_NAMES.REPORTED_ETHNICITY]: { - name: ANVIL_COLUMN_NAMES.REPORTED_ETHNICITY, - pluralizedLabel: - PLURALIZED_METADATA_LABEL[ANVIL_COLUMN_NAMES.REPORTED_ETHNICITY], - sortable: true, - }, - [ANVIL_COLUMN_NAMES.DIAGNOSIS]: { - name: ANVIL_COLUMN_NAMES.DIAGNOSIS, - pluralizedLabel: PLURALIZED_METADATA_LABEL[ANVIL_COLUMN_NAMES.DIAGNOSIS], - sortable: true, - }, - [ANVIL_COLUMN_NAMES.FILE_DATA_MODALITY]: { - name: ANVIL_COLUMN_NAMES.FILE_DATA_MODALITY, - pluralizedLabel: - PLURALIZED_METADATA_LABEL[ANVIL_COLUMN_NAMES.FILE_DATA_MODALITY], - sortable: true, - }, -}; diff --git a/e2e/features/common/filters.ts b/e2e/features/common/filters.ts new file mode 100644 index 000000000..4852efd6b --- /dev/null +++ b/e2e/features/common/filters.ts @@ -0,0 +1,223 @@ +import type { Locator, Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { KEYBOARD_KEYS, MUI_CLASSES, TEST_IDS } from "./constants"; +import { escapeRegExp } from "./utils"; + +/** + * Closes any open autocomplete popper via the Escape key and waits for unmount. + * @param page - Page. + */ +export async function closeAutocompletePopper(page: Page): Promise { + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); + await expectAutocompletePopperClosed(page); +} + +/** + * Closes any open filter popover via the Escape key and waits for unmount. + * @param page - Page. + */ +export async function closeFilterPopover(page: Page): Promise { + await page.keyboard.press(KEYBOARD_KEYS.ESCAPE); + await expectFilterPopoverClosed(page); +} + +/** + * Waits for the autocomplete popper to be fully unmounted from the DOM. + * @param page - Page. + */ +export 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. + */ +export 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. + */ +export 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. + */ +export 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. + */ +export async function expectFilterPopoverClosed(page: Page): Promise { + await expect(filterPopover(page)).toHaveCount(0); +} + +/** + * Waits for the filter popover to be visible. + * @param page - Page. + */ +export 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. + * @returns The display name of the filter option. + */ +export async function extractOptionName(filterItem: Locator): Promise { + return ( + await filterItem.getByTestId(TEST_IDS.FILTER_TERM).innerText() + ).trim(); +} + +/** + * 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. + */ +export async function fillSearchAllFilters( + searchAllFilters: Locator, + text: string +): Promise { + await expectAutocompletePopperClosed(searchAllFilters.page()); + const input = searchAllFilters.getByRole("combobox"); + await input.fill(text); + await expectAutocompletePopperOpen(searchAllFilters.page()); +} + +/** + * Returns a locator for the filter popover. + * @param page - Page. + * @returns A locator for the filter popover. + */ +export function filterPopover(page: Page): Locator { + return page.getByTestId(TEST_IDS.FILTER_POPOVER); +} + +/** + * 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. + */ +export 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. + */ +export function filterTag(filters: Locator, name: string): Locator { + return filters.locator(MUI_CLASSES.CHIP, { hasText: name }); +} + +/** + * Returns a locator for the first filter item in the open popover. + * @param page - Page. + * @returns A locator for the first filter item. + */ +export function firstFilterItem(page: Page): Locator { + return filterPopover(page).getByTestId(TEST_IDS.FILTER_ITEM).first(); +} + +/** + * Returns the name of the first filter item in the open popover. + * @param page - Page. + * @returns The display name of the first option. + */ +export async function getFirstOptionName(page: Page): Promise { + return extractOptionName(firstFilterItem(page)); +} + +/** + * 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. + */ +export 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(); +} + +/** + * Returns a locator for a named filter item in the open filter popover. + * @param page - Page. + * @param optionName - The display name of the filter option. + * @returns A locator for the matching filter item. + */ +export function namedPopoverFilterItem( + page: Page, + optionName: string +): Locator { + return filterPopover(page) + .getByTestId(TEST_IDS.FILTER_ITEM) + .filter({ hasText: new 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. + */ +export 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 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. + * @returns The display name of the selected option. + */ +export async function selectFirstOption( + filters: Locator, + page: Page, + filterName: string +): Promise { + await openFilterDropdown(filters, filterName); + const option = firstFilterItem(page); + const name = await extractOptionName(option); + await expectFilterItemNotSelected(option); + // dispatchEvent works around a webkit issue where regular click() can be + // swallowed by the popover overlay before reaching the list item. + await option.dispatchEvent("click"); + // Re-locate by name rather than position since the list may re-sort after + // selection, causing the positional `.first()` locator to resolve to a + // different (non-selected) element. + await expectFilterItemSelected(namedPopoverFilterItem(page, name)); + await closeFilterPopover(page); + return name; +} diff --git a/e2e/features/common/utils.ts b/e2e/features/common/utils.ts new file mode 100644 index 000000000..c21c6d307 --- /dev/null +++ b/e2e/features/common/utils.ts @@ -0,0 +1,8 @@ +/** + * Escapes regex special characters in a string. + * @param s - The string to escape. + * @returns A string with all RegExp special characters escaped. + */ +export function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/e2e/testFunctions.ts b/e2e/testFunctions.ts index 618e81dac..cac090774 100644 --- a/e2e/testFunctions.ts +++ b/e2e/testFunctions.ts @@ -35,7 +35,7 @@ const getAllVisibleColumnNames = async (page: Page): Promise => { * @param columnIndex - the zero-indexed column to return * @returns a Playwright locator object to the selected cell **/ -export const getMthRowNthColumnCellLocator = ( +const getMthRowNthColumnCellLocator = ( page: Page, rowIndex: number, columnIndex: number @@ -55,7 +55,7 @@ export const getMthRowNthColumnCellLocator = ( * @param columnIndex - the zero-indexed column to return * @returns a Playwright locator object to the selected cell **/ -export const getFirstRowNthColumnCellLocator = ( +const getFirstRowNthColumnCellLocator = ( page: Page, columnIndex: number ): Locator => { @@ -362,341 +362,6 @@ export async function testPreSelectedColumns( export const filterRegex = (filterName: string): RegExp => new RegExp(escapeRegExp(filterName) + "\\s+\\(\\d+\\)\\s*"); -/** - * Checks that each filter specified in filterNames is visible and can be - * selected on the specified tab - * @param page - a Playwright page object - * @param tab - the tab to check - * @param filterNames - the names of the filters who whose existence should be tested for - */ -export async function testFilterPresence( - page: Page, - tab: TabDescription, - filterNames: string[] -): Promise { - // Goto the selected tab - await page.goto(tab.url); - await expect(getTabByText(page, tab.tabName)).toBeVisible(); - for (const filterName of filterNames) { - // Check that each filter is visible and clickable - await expect(page.getByText(filterRegex(filterName))).toBeVisible(); - // dispatchEvent necessary because the filter menu component sometimes interrupts a click event - await page.getByText(filterRegex(filterName)).dispatchEvent("click"); - await expect(page.getByRole("checkbox").first()).toBeVisible(); - await expect(page.getByRole("checkbox").first()).not.toBeChecked(); - // Check that clicking out of the filter menu causes it to disappear - await page.locator("body").click(); - await expect(page.getByRole("checkbox")).toHaveCount(0); - } -} - -/** - * Get a locator for the specified filter option. Requires a filter menu to be open - * @param page - a Playwright page object - * @param filterOptionName - the name of the filter option - * @returns a Playwright locator to the filter button - */ -export const getNamedFilterOptionLocator = ( - page: Page, - filterOptionName: string -): Locator => { - // The Regex matches a filter name with a number after it, with potential whitespace before and after the number. - // This matches how the innerText in the filter options menu appears to Playwright. - return page.getByRole("button").filter({ - has: page.getByRole("checkbox"), - hasText: RegExp(`^${escapeRegExp(filterOptionName)}\\s*\\d+\\s*`), - }); -}; - -/** - * Get a locator for the nth filter option on the page. - * @param page - a Playwright page object - * @param n - the index of the filter option to get - * @returns - a Playwright locator object for the first filter option on the page - */ -const getNthFilterOptionLocator = (page: Page, n: number): Locator => { - return page - .getByRole("button") - .filter({ has: page.getByRole("checkbox") }) - .nth(n); -}; - -/** - * Get a locator for the first filter option on the page. - * @param page - a Playwright page object - * @returns - a Playwright locator object for the first filter option on the page - */ -export const getFirstFilterOptionLocator = (page: Page): Locator => { - return getNthFilterOptionLocator(page, 0); -}; - -export const getFilterOptionName = async ( - firstFilterOptionLocator: Locator -): Promise => { - // Filter options display as "[text]\n[number]" , sometimes with extra whitespace, so we split on newlines and take the first non-empty string - return ( - (await firstFilterOptionLocator.innerText()) - .split("\n") - .map((x) => x.trim()) - .find((x) => x.length > 0) ?? "" - ); -}; - -const MAX_FILTER_OPTIONS_TO_CHECK = 10; - -interface FilterOptionNameAndLocator { - index: number; - locator: Locator; - name: string; -} - -/** - * Gets the name of the filter option associated with a locator - * @param page - a Playwright Page object, on which a filter must be currently selected - * @returns the innerText of the first nonempty filter option as a promise - */ -const getFirstNonEmptyFilterOptionInfo = async ( - page: Page -): Promise => { - let filterToSelect = ""; - let filterOptionLocator = undefined; - let i = 0; - while (filterToSelect === "" && i < MAX_FILTER_OPTIONS_TO_CHECK) { - // Filter options display as "[text]\n[number]" , sometimes with extra whitespace, so we want the string before the newline - const filterOptionRegex = /^(.*)\n+(\d+)\s*$/; - filterOptionLocator = getNthFilterOptionLocator(page, i); - const filterNameMatch = (await filterOptionLocator.innerText()) - .trim() - .match(filterOptionRegex); - if (filterNameMatch !== null) { - filterToSelect = filterNameMatch[1]; - } - i += 1; - } - if (filterOptionLocator === undefined) { - throw new Error( - "No locator found within the maximum number of filter options" - ); - } - return { index: i - 1, locator: filterOptionLocator, name: filterToSelect }; -}; - -/** - * Checks that selecting a specified filter is persistent across the tabs in tabOrder - * @param page - a Playwright page object - * @param testFilterName - the name of the filter to check - * @param tabOrder - the tabs to check, in order. The filter will be selected on the first tab. - */ -export async function testFilterPersistence( - page: Page, - testFilterName: string, - tabOrder: TabDescription[] -): Promise { - // Start on the first tab in the test order (should be files) - await page.goto(tabOrder[0].url); - // Select the first checkbox on the test filter - await page.getByText(filterRegex(testFilterName)).click(); - const filterToSelectInfo = await getFirstNonEmptyFilterOptionInfo(page); - const filterToSelectLocator = filterToSelectInfo.locator; - const filterName = filterToSelectInfo.name; - const filterIndex = filterToSelectInfo.index; - await expect(filterToSelectLocator.getByRole("checkbox")).not.toBeChecked(); - await filterToSelectLocator.getByRole("checkbox").click(); - await expect(filterToSelectLocator.getByRole("checkbox")).toBeChecked(); - await page.locator("body").click(); - // Expect at least some text to still be visible - await expect(getFirstRowNthColumnCellLocator(page, 0)).toBeVisible(); - // For each tab, check that the selected filter is still checked - for (const tab of tabOrder.slice(1)) { - await getTabByText(page, tab.tabName).dispatchEvent("click"); - await expect(page.getByText(filterRegex(testFilterName))).toBeVisible(); - await page.getByText(filterRegex(testFilterName)).dispatchEvent("click"); - await page.waitForLoadState("load"); - const previouslySelected = getNamedFilterOptionLocator(page, filterName); - await expect(previouslySelected.getByRole("checkbox")).toBeChecked(); - await page.waitForLoadState("load"); - await page.locator("body").click(); - } - // Return to the start tab and confirm that the filter stays checked and that some content is visible - // (dispatchevent necessary because the filter menu sometimes interrupts the click event) - await getTabByText(page, tabOrder[0].tabName).dispatchEvent("click"); - await expect(getFirstRowNthColumnCellLocator(page, 0)).toBeVisible(); - await page.getByText(filterRegex(testFilterName)).dispatchEvent("click"); - const previouslySelected = getNthFilterOptionLocator(page, filterIndex); - await expect(previouslySelected).toContainText(filterName, { - useInnerText: true, - }); - await expect(previouslySelected.getByRole("checkbox").first()).toBeChecked(); -} - -/** - * Test that the counts associated with an array of filter names are reflected - * in the table - * @param page - a Playwright page object - * @param tab - the tab object to test - * @param filterNames - the names of the filters to select, in order - * @param elementsPerPage - the maximum number of elements per page - * @returns false if the test should fail and true if the test should pass - */ -export async function testFilterCounts( - page: Page, - tab: TabDescription, - filterNames: string[], - elementsPerPage: number -): Promise { - await page.goto(tab.url); - // For each arbitrarily selected filter - for (const filterName of filterNames) { - // Select the filter - // (dispatchevent necessary because the filter menu sometimes interrupts the click event) - await page.getByText(filterRegex(filterName)).dispatchEvent("click"); - // Get the number associated with the first filter button, and select it - await page.waitForLoadState("load"); - const firstFilterOption = getFirstFilterOptionLocator(page); - const filterNumbers = (await firstFilterOption.innerText()).split("\n"); - const filterNumber = - filterNumbers - .reverse() - .map((x) => Number(x)) - .find((x) => !isNaN(x) && x !== 0) ?? -1; - if (filterNumber < 0) { - console.log( - "FILTER COUNTS: The number associated with the filter is negative" - ); - return false; - } - // Check the filter - // (dispatchevent necessary because the filter menu sometimes interrupts the click event) - await firstFilterOption.getByRole("checkbox").dispatchEvent("click"); - await page.waitForLoadState("load"); - // Exit the filter menu - await page.locator("body").click(); - await expect(page.getByRole("checkbox")).toHaveCount(0); - // Wait for the table to load - await expect(getFirstRowNthColumnCellLocator(page, 0)).toBeVisible(); - // Expect the displayed count of elements to be 0 - const firstNumber = - filterNumber <= elementsPerPage ? filterNumber : elementsPerPage; - await expect( - page.getByText("Results 1 - " + firstNumber + " of " + filterNumber) - ).toBeVisible(); - } - return true; -} - -const FILTER_CSS_SELECTOR = "#sidebar-positioner"; - -/** - * Get a locator for a named filter tag - * @param page - a Playwright page object - * @param filterTagName - the name of the filter tag to search for - * @returns - a locator for the named filter tag - */ -const getFilterTagLocator = (page: Page, filterTagName: string): Locator => { - return page - .locator(FILTER_CSS_SELECTOR) - .getByText(filterTagName, { exact: true }); -}; - -/** - * Check that the filter tabs appear when a filter is selected and that clicking - * them causes the filter to be deselected - * @param page - a Playwright page objet - * @param tab - the tab to check - * @param filterNames - the names of the filters to check - */ -export async function testFilterTags( - page: Page, - tab: TabDescription, - filterNames: string[] -): Promise { - await page.goto(tab.url); - for (const filterName of filterNames) { - // Select a filter - // (dispatchevent necessary because the filter menu sometimes interrupts the click event) - await page.getByText(filterRegex(filterName)).dispatchEvent("click"); - await page.waitForLoadState("load"); - const firstFilterOptionLocator = getFirstFilterOptionLocator(page); - // Get the name of the selected filter - const firstFilterOptionName = - (await firstFilterOptionLocator.innerText()) - .split("\n") - .find((x) => x.length > 0) ?? ""; - // Click the selected filter and exit the filter menu - await firstFilterOptionLocator.getByRole("checkbox").click(); - await page.waitForLoadState("load"); - await page.locator("body").click(); - await expect(page.getByRole("checkbox")).toHaveCount(0); - // Click the filter tag - const filterTagLocator = getFilterTagLocator(page, firstFilterOptionName); - await expect(filterTagLocator).toBeVisible(); - await filterTagLocator.scrollIntoViewIfNeeded(); - await filterTagLocator.dispatchEvent("click"); - // Expect the tag to disappear when clicked - await expect(filterTagLocator).toHaveCount(0); - // Expect the filter to be deselected in the filter menu - await page.getByText(filterRegex(filterName)).dispatchEvent("click"); - await expect( - firstFilterOptionLocator.getByRole("checkbox") - ).not.toBeChecked(); - await page.locator("body").click(); - } -} - -/** - * Check that selecting some filters then selecting the clear all button causes - * those filters to become deselected - * @param page - a Playwright page object - * @param tab - the tab object to test on - * @param filterNames - the names of the filters to check - */ -export async function testClearAll( - page: Page, - tab: TabDescription, - filterNames: string[] -): Promise { - await page.goto(tab.url); - const selectedFilterNamesList = []; - // Select each filter and get the names of the actual filter text - // (dispatchevent necessary because the filter menu sometimes interrupts the click event) - for (const filterName of filterNames) { - await page.getByText(filterRegex(filterName)).dispatchEvent("click"); - await getFirstFilterOptionLocator(page).getByRole("checkbox").click(); - await expect( - getFirstFilterOptionLocator(page).getByRole("checkbox") - ).toBeChecked(); - selectedFilterNamesList.push( - await getFilterOptionName(getFirstFilterOptionLocator(page)) - ); - await page.locator("body").click(); - } - // Click the "Clear All" button - await page.getByText("Clear All").dispatchEvent("click"); - for (const filterName of selectedFilterNamesList) { - await expect(page.getByText(filterRegex(filterName))).toHaveCount(0); - } - // Ensure that the filters still show as unchecked - for (let i = 0; i < filterNames.length; i++) { - await page.getByText(filterRegex(filterNames[i])).dispatchEvent("click"); - await expect( - getNamedFilterOptionLocator(page, selectedFilterNamesList[i]).getByRole( - "checkbox" - ) - ).not.toBeChecked(); - await page.locator("body").click(); - } -} - -/** - * Escape a string so it can safely be used in a regexp - * @param string - the string to escape - * @returns - A string that has all Regexp special characters escaped - */ -function escapeRegExp(string: string): string { - // Searches for regex special characters and adds backslashes in front of them to escape - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - /** * Make an export request that leaves only the minimal number of checkboxes selected * @param page - a Playwright page object @@ -787,6 +452,16 @@ const hoverAndGetText = async ( return cellText.trim(); }; +/** + * Escape a string so it can safely be used in a regexp + * @param string - the string to escape + * @returns - A string that has all Regexp special characters escaped + */ +function escapeRegExp(string: string): string { + // Searches for regex special characters and adds backslashes in front of them to escape + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + /** * Tests bulk download of a file manifest from the files tab. * @param page - a Playwright page object