From 76573754ca7686680667791d6997f891c034e956 Mon Sep 17 00:00:00 2001 From: Jonah Paten Date: Thu, 22 Aug 2024 20:53:33 -0700 Subject: [PATCH] anvil-cmg filter tests (#4068) (#4071) * feat: first draft of anvil-cmg filter tests (#4068) * fix: refactored and debugged filter tests (#4068) * fix: added extra check to testFilterPresence (#4068) * fix: refactored tests (#4068) * fix: refactored tests (#4068) * fix: refactored tests (#4068) * fix: attempted fix for filter test issues (#4068) * fix: switched setchecked to click action (#4068) * fix: fixed typo that caused tests to fail, added clear all test (#4068) * fix: shortened certain tests that would timeout (#4068) * fix: fixed redundant code and bad timeouts (#4068) * fix: made all variables camelcase in filter tests (#4068) * fix: updated variable names and created constants (#4068) * fix: updated variable names, created constants, fixed bug in persistence test on webkit (#4068) * fix: updated variable names and tsdocs for e2e tests (#4068) * fix: fixed filter persistence test errors (#4068) --- explorer/e2e/anvil/anvil-filters.spec.ts | 173 +++++++++++ explorer/e2e/anvil/anvil-tabs.ts | 18 +- explorer/e2e/testFunctions.ts | 361 +++++++++++++++++++++-- explorer/e2e/testInterfaces.ts | 1 + 4 files changed, 529 insertions(+), 24 deletions(-) create mode 100644 explorer/e2e/anvil/anvil-filters.spec.ts diff --git a/explorer/e2e/anvil/anvil-filters.spec.ts b/explorer/e2e/anvil/anvil-filters.spec.ts new file mode 100644 index 000000000..12f8d5b6f --- /dev/null +++ b/explorer/e2e/anvil/anvil-filters.spec.ts @@ -0,0 +1,173 @@ +import { expect, test } from "@playwright/test"; +import { + filterRegex, + getFirstRowNthColumnCellLocator, + testClearAll, + testFilterCounts, + testFilterPersistence, + testFilterPresence, + testFilterTags, +} from "../testFunctions"; +import { + anvilFilterNames, + anvilTabs, + anvilTabTestOrder, + BIOSAMPLE_TYPE_INDEX, + CONSENT_GROUP_INDEX, + DATASET_INDEX, + DATA_MODALITY_INDEX, + DIAGNOSIS_INDEX, + FILE_FORMAT_INDEX, + REPORTED_ETHNICITY_INDEX, +} from "./anvil-tabs"; + +const FILTER_INDEX_LIST = [ + DATA_MODALITY_INDEX, + 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, anvilTabs.datasets, anvilFilterNames); +}); + +test("Check that all filters exist on the Donors tab and are clickable", async ({ + page, +}) => { + await testFilterPresence(page, anvilTabs.donors, anvilFilterNames); +}); + +test("Check that all filters exist on the BioSamples tab and are clickable", async ({ + page, +}) => { + await testFilterPresence(page, anvilTabs.biosamples, anvilFilterNames); +}); + +test("Check that all filters exist on the Activities tab and are clickable", async ({ + page, +}) => { + await testFilterPresence(page, anvilTabs.activities, anvilFilterNames); +}); + +test("Check that all filters exist on the Files tab and are clickable", async ({ + page, +}) => { + await testFilterPresence(page, anvilTabs.files, anvilFilterNames); +}); + +test("Check that the first 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, +}) => { + test.setTimeout(120000); + // Goto the datasets tab + await page.goto(anvilTabs.datasets.url); + await expect( + page.getByRole("tab").getByText(anvilTabs.datasets.tabName) + ).toBeVisible(); + + // Select a filter + await page + .getByRole("button") + .getByText( + filterRegex( + anvilFilterNames[Math.floor(Math.random() * anvilFilterNames.length)] + ) + ) + .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(); + } + 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, +}) => { + test.setTimeout(120000); + const result = await testFilterPersistence( + page, + anvilFilterNames[FILE_FORMAT_INDEX], + anvilTabTestOrder.map((x) => anvilTabs[x]) + ); + if (!result) { + test.fail(); + } +}); + +test("Check that filter menu counts match actual counts on the Datasets tab", async ({ + page, +}) => { + test.setTimeout(120000); + const result = await testFilterCounts( + page, + anvilTabs.datasets, + FILTER_INDEX_LIST.map((x) => anvilFilterNames[x]), + anvilTabs.datasets.maxPages ?? 0 + ); + if (!result) { + test.fail(); + } +}); + +test("Check that filter menu counts match actual counts on the Activities tab", async ({ + page, +}) => { + test.setTimeout(120000); + await testFilterCounts( + page, + anvilTabs.activities, + FILTER_INDEX_LIST.map((x) => anvilFilterNames[x]), + anvilTabs.activities.maxPages ?? 0 + ); +}); + +test("Check that the filter tags match the selected filter for an arbitrary filter on the Files tab", async ({ + page, +}) => { + test.setTimeout(120000); + await testFilterTags( + page, + anvilTabs.files, + FILTER_INDEX_LIST_SHORT.map((x) => anvilFilterNames[x]) + ); +}); + +test("Check that the filter tags match the selected filter for an arbitrary filter on the BioSamples tab", async ({ + page, +}) => { + test.setTimeout(120000); + await testFilterTags( + page, + anvilTabs.biosamples, + FILTER_INDEX_LIST_SHORT.map((x) => anvilFilterNames[x]) + ); +}); + +test("Check that the clear all button functions on the files tab", async ({ + page, +}) => { + test.setTimeout(120000); + await testClearAll( + page, + anvilTabs.files, + FILTER_INDEX_LIST_SHORT.map((x) => anvilFilterNames[x]) + ); +}); diff --git a/explorer/e2e/anvil/anvil-tabs.ts b/explorer/e2e/anvil/anvil-tabs.ts index 59502299f..800190506 100644 --- a/explorer/e2e/anvil/anvil-tabs.ts +++ b/explorer/e2e/anvil/anvil-tabs.ts @@ -5,7 +5,7 @@ import { TabDescription, } from "../testInterfaces"; -export const filters: string[] = [ +export const anvilFilterNames: string[] = [ "Anatomical Site", "BioSample Type", "Consent Group", @@ -18,10 +18,22 @@ export const filters: string[] = [ "Phenotypic Sex", "Reported Ethnicity", ]; +export const ANATOMICAL_SITE_INDEX = 0; +export const BIOSAMPLE_TYPE_INDEX = 1; +export const CONSENT_GROUP_INDEX = 2; +export const DATA_MODALITY_INDEX = 3; +export const DATASET_INDEX = 4; +export const DIAGNOSIS_INDEX = 5; +export const FILE_FORMAT_INDEX = 6; +export const IDENTIFIER_INDEX = 7; +export const ORGANISM_TYPE_INDEX = 8; +export const PHENOTYPIC_SEX_INDEX = 9; +export const REPORTED_ETHNICITY_INDEX = 10; export const anvilTabs: AnvilCMGTabCollection = { activities: { emptyFirstColumn: false, + maxPages: 25, preselectedColumns: [ { name: "Document Id", sortable: true }, { name: "Activity Type", sortable: true }, @@ -40,6 +52,7 @@ export const anvilTabs: AnvilCMGTabCollection = { }, biosamples: { emptyFirstColumn: false, + maxPages: 25, preselectedColumns: [ { name: "BioSample Id", sortable: true }, { name: "Anatomical Site", sortable: true }, @@ -57,6 +70,7 @@ export const anvilTabs: AnvilCMGTabCollection = { }, datasets: { emptyFirstColumn: false, + maxPages: 25, preselectedColumns: [ { name: "Dataset", sortable: true }, { name: "Access", sortable: false }, @@ -75,6 +89,7 @@ export const anvilTabs: AnvilCMGTabCollection = { }, donors: { emptyFirstColumn: false, + maxPages: 25, preselectedColumns: [ { name: "Donor Id", sortable: true }, { name: "Organism Type", sortable: true }, @@ -89,6 +104,7 @@ export const anvilTabs: AnvilCMGTabCollection = { }, files: { emptyFirstColumn: true, + maxPages: 25, preselectedColumns: [ { name: "Name", sortable: true }, { name: "File Format", sortable: true }, diff --git a/explorer/e2e/testFunctions.ts b/explorer/e2e/testFunctions.ts index aa95cb5a6..ef4f9021c 100644 --- a/explorer/e2e/testFunctions.ts +++ b/explorer/e2e/testFunctions.ts @@ -1,8 +1,34 @@ -import { expect, Page } from "@playwright/test"; +import { expect, Locator, Page } from "@playwright/test"; import { TabDescription } from "./testInterfaces"; /* eslint-disable sonarjs/no-duplicate-string -- ignoring duplicate strings here */ -// Run the "Expect each tab to appear as selected when the corresponding url is accessed" test + +/** + * 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 getFirstRowNthColumnCellLocator = ( + page: Page, + columnIndex: number +): Locator => { + return page + .getByRole("rowgroup") + .nth(1) + .getByRole("row") + .nth(0) + .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 + * @param page - a Playwright page object + * @param tab - the Tab object to check + * @param otherTabs - an array of the other Tab objects for this configuration + */ export async function testUrl( page: Page, tab: TabDescription, @@ -22,6 +48,11 @@ export async function testUrl( } // Run the "Expect each tab to become selected, to go to the correct url, and to show all of its columns when selected" test +/** + * Checks that all preselected columns listed in the tab object are visible in the correct order + * @param page - a Playwright page object + * @param tab - the tab object to test + */ export async function testTab(page: Page, tab: TabDescription): Promise { await expect( page @@ -49,6 +80,14 @@ export async function testTab(page: Page, tab: TabDescription): Promise { } } +/** + * Checks that sorting the tab does not cause the first and last row to break. + * This test does not check whether the sort order is correct. + * This test assumes that this is an Azul explorer with pagination rather than + * a catalog, so that the last element is visible without excessive scrolling. + * @param page - a Playwright page object + * @param tab - the tab to check + */ export async function testSortAzul( page: Page, tab: TabDescription @@ -63,25 +102,22 @@ export async function testSortAzul( ) { // Get the column position, taking into account that some tabs start with a non-text first column if (tab.preselectedColumns[columnPosition].sortable) { - const workColumnPosition: number = tab.emptyFirstColumn + const columnIndex: number = tab.emptyFirstColumn ? columnPosition + 1 : columnPosition; // Locators for the first and last cells in a particular column position on the page - const firstElementTextLocator = page - .getByRole("rowgroup") - .nth(1) - .getByRole("row") - .nth(0) - .getByRole("cell") - .nth(workColumnPosition); + const firstElementTextLocator = getFirstRowNthColumnCellLocator( + page, + columnIndex + ); const lastElementTextLocator = page .getByRole("rowgroup") .nth(1) .getByRole("row") .last() .getByRole("cell") - .nth(workColumnPosition); - // Locator for the sort buttonf + .nth(columnIndex); + // Locator for the sort button for the tab const columnSortLocator = page .getByRole("columnheader", { exact: true, @@ -108,6 +144,14 @@ export async function testSortAzul( } } +/** + * Checks that sorting the tab does not cause the first row of the table to break. + * This test does not check whether the sort order is correct. + * This test assumes that this is a catalog explorer without pagination, + * so it only checks the first element of the table. + * @param page - a Playwright page object + * @param tab - the tab to check + */ export async function testSortCatalog( page: Page, tab: TabDescription @@ -122,17 +166,14 @@ export async function testSortCatalog( ) { // Get the column position, taking into account that some tabs start with a non-text first column if (tab.preselectedColumns[columnPosition].sortable) { - const workColumnPosition: number = tab.emptyFirstColumn + const columnIndex: number = tab.emptyFirstColumn ? columnPosition + 1 : columnPosition; // Locators for the first and last cells in a particular column position on the page - const firstElementTextLocator = page - .getByRole("rowgroup") - .nth(1) - .getByRole("row") - .nth(0) - .getByRole("cell") - .nth(workColumnPosition); + const firstElementTextLocator = getFirstRowNthColumnCellLocator( + page, + columnIndex + ); // Locator for the sort button const columnSortLocator = page .getByRole("columnheader", { @@ -140,12 +181,10 @@ export async function testSortCatalog( name: tab.preselectedColumns[columnPosition].name, }) .getByRole("button"); - await expect(firstElementTextLocator).toBeVisible(); - // Click to sort await columnSortLocator.click(); - // Expect the first and cells to still be visible + // Expect the first cell to still be visible await expect(firstElementTextLocator).toBeVisible(); // Click again await columnSortLocator.click(); @@ -156,6 +195,13 @@ export async function testSortCatalog( } } +/** + * Check that all of the selectable columns specified in the tab object + * are initially not selected in the "Edit Columns" menu, and that selecting + * them causes them to appear in the correct order + * @param page - a Playwright page object + * @param tab - the tab object to check + */ export async function testSelectableColumns( page: Page, tab: TabDescription @@ -185,6 +231,12 @@ export async function testSelectableColumns( ); } +/** + * Checks that the preselected columns specified in the tab object appear + * in the "Edit Columns" menu and that their checkbox is checked and disabled + * @param page - the Playwright page object + * @param tab - the tab object to test + */ export async function testPreSelectedColumns( page: Page, tab: TabDescription @@ -207,4 +259,267 @@ export async function testPreSelectedColumns( } } +/** + * Returns a regex that matches the sidebar filter buttons + * This is useful for selecting a filter from the sidebar + * @param filterName - the name of the filter to match + * @returns a regular expression matching "[filterName] ([n])" + */ +export const filterRegex = (filterName: string): RegExp => + new RegExp(filterName + "\\s+\\([0-9]+\\)\\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(page.getByRole("tab").getByText(tab.tabName)).toBeVisible(); + for (const filterName of filterNames) { + // Check that each filter is visible and clickable + await expect(page.getByText(filterRegex(filterName))).toBeVisible(); + await page.getByText(filterRegex(filterName)).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 getNamedFilterButtonLocator = ( + page: Page, + filterOptionName: string +): Locator => { + return page + .getByRole("button") + .filter({ has: page.getByRole("checkbox"), hasText: filterOptionName }); +}; + +/** + * Get a locator for the first filter option. Requires a filter menu to be open + * @param page - a Playwright page object + * @returns a Playwright locator to the filter button + */ +export const getFirstFilterButtonLocator = (page: Page): Locator => { + return page + .getByRole("button") + .filter({ has: page.getByRole("checkbox") }) + .first(); +}; + +/** + * Cheks 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. + * @returns false if the test should fail, and true if the test passes + */ +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 filterToSelectLocator = await getFirstFilterButtonLocator(page); + await expect(filterToSelectLocator.getByRole("checkbox")).not.toBeChecked(); + await filterToSelectLocator.getByRole("checkbox").click(); + const filterNameMatch = (await filterToSelectLocator.innerText()) + .trim() + .match(/^\S*/); + if (filterNameMatch == null) { + // This means that the selected filter did not have any non-whitespace text + // associated with it, making the test impossible to complete. + console.log("ERROR: Filter name is blank, so the test cannot continue"); + return false; + } + const filterName = (filterNameMatch ?? [""])[0]; + 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 page + .getByRole("tab") + .getByText(tab.tabName, { exact: true }) + .dispatchEvent("click"); + await expect(page.getByText(filterRegex(testFilterName))).toBeVisible(); + await page.getByText(filterRegex(testFilterName)).dispatchEvent("click"); + await page.waitForLoadState("load"); + const previouslySelected = getNamedFilterButtonLocator(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 + await page + .getByRole("tab") + .getByText(tabOrder[0].tabName, { exact: true }) + .dispatchEvent("click"); + await expect(getFirstRowNthColumnCellLocator(page, 0)).toBeVisible(); + await page.getByText(filterRegex(testFilterName)).dispatchEvent("click"); + const previouslySelected = getFirstFilterButtonLocator(page); + await expect(previouslySelected).toContainText(filterName, { + useInnerText: true, + }); + await expect(previouslySelected.getByRole("checkbox").first()).toBeChecked(); + return true; +} + +/** + * 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 + await page.getByText(filterRegex(filterName)).dispatchEvent("click"); + // Get the number associated with the first filter button, and select it + await page.waitForLoadState("load"); + const filterButton = getFirstFilterButtonLocator(page); + const filterNumbers = (await filterButton.innerText()).split("\n"); + const filterNumber = + filterNumbers.map((x) => Number(x)).find((x) => !isNaN(x) && x !== 0) ?? + -1; + if (filterNumber < 0) { + console.log("ERROR: The number associated with the filter is negative"); + return false; + } + // Check the filter + await filterButton.getByRole("checkbox").dispatchEvent("click"); + await page.waitForLoadState("load"); + // Exit the filter menu + await page.locator("body").click(); + await expect(page.getByRole("checkbox")).toHaveCount(0); + // 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; +} + +/** + * 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 { + page.goto(tab.url); + for (const filterName of filterNames) { + // Select a filter + await page.getByText(filterRegex(filterName)).dispatchEvent("click"); + await page.waitForLoadState("load"); + const firstFilterButtonLocator = getFirstFilterButtonLocator(page); + // Get the name of the selected filter + const firstFilterName = + (await firstFilterButtonLocator.innerText()) + .split("\n") + .find((x) => x.length > 0) ?? ""; + // Click the selected filter and exit the filter menu + await firstFilterButtonLocator.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 = page + .locator("#sidebar-positioner") + .getByText(firstFilterName); + 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( + firstFilterButtonLocator.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 fitlers to check + */ +export async function testClearAll( + page: Page, + tab: TabDescription, + filterNames: string[] +): Promise { + await page.goto(tab.url); + const selectedFilterNamesList = []; + for (const filterName of filterNames) { + // Select the passed filter names + await page.getByText(filterRegex(filterName)).dispatchEvent("click"); + await getFirstFilterButtonLocator(page).getByRole("checkbox").click(); + await expect( + getFirstFilterButtonLocator(page).getByRole("checkbox") + ).toBeChecked(); + selectedFilterNamesList.push( + (await getFirstFilterButtonLocator(page).innerText()) + .split("\n") + .find((x) => x.length > 0) ?? "" + ); + 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.locator("#sidebar-positioner").getByText(filterName) + ).toHaveCount(0); + } + for (let i = 0; i < filterNames.length; i++) { + await page.getByText(filterRegex(filterNames[i])).dispatchEvent("click"); + await expect( + getNamedFilterButtonLocator(page, selectedFilterNamesList[i]).getByRole( + "checkbox" + ) + ).not.toBeChecked(); + await page.locator("body").click(); + } +} /* eslint-enable sonarjs/no-duplicate-string -- Checking duplicate strings again*/ diff --git a/explorer/e2e/testInterfaces.ts b/explorer/e2e/testInterfaces.ts index 74cfcbddf..ee5d786c3 100644 --- a/explorer/e2e/testInterfaces.ts +++ b/explorer/e2e/testInterfaces.ts @@ -1,5 +1,6 @@ export interface TabDescription { emptyFirstColumn: boolean; + maxPages?: number; preselectedColumns: columnDescription[]; selectableColumns: columnDescription[]; tabName: string;