Skip to content

Commit 7f9fd4e

Browse files
frano-mclaudeCopilot
authored
fix: rewrite flaky anvil catalog filter e2e test (#4763)
* fix: rewrite flaky anvil catalog filter e2e test (#4751) 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) <noreply@anthropic.com> * Update e2e/anvil-catalog/constants.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: rename DB_GAP_ID to DBGAP_ID (#4751) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Fran McDade <18710366+frano-m@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 6f58a02 commit 7f9fd4e

5 files changed

Lines changed: 268 additions & 193 deletions

File tree

Lines changed: 260 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,267 @@
1-
import { test } from "@playwright/test";
1+
import { expect, Locator, Page, test } from "@playwright/test";
22
import {
3-
testDeselectFiltersThroughSearchBar,
4-
testSelectFiltersThroughSearchBar,
5-
} from "../testFunctions";
6-
import {
7-
ANVIL_CATALOG_FILTERS,
8-
ANVIL_CATALOG_TABS,
9-
CONSENT_CODE_INDEX,
10-
DBGAP_ID_INDEX,
11-
TERRA_WORKSPACE_INDEX,
12-
} from "./anvilcatalog-tabs";
13-
14-
const filterList = [CONSENT_CODE_INDEX, DBGAP_ID_INDEX, TERRA_WORKSPACE_INDEX];
15-
16-
test('Check that selecting filters through the "Search all Filters" textbox works correctly on the Consortia tab', async ({
17-
page,
18-
}) => {
19-
await testSelectFiltersThroughSearchBar(
20-
page,
21-
ANVIL_CATALOG_TABS.CONSORTIA,
22-
filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i])
23-
);
24-
});
3+
KEYBOARD_KEYS,
4+
MUI_CLASSES,
5+
TEST_IDS,
6+
} from "../features/common/constants";
7+
import { ANVIL_CATALOG_CATEGORY_NAMES } from "./constants";
258

26-
test('Check that selecting filters through the "Search all Filters" textbox works correctly on the Studies tab', async ({
27-
page,
28-
}) => {
29-
await testSelectFiltersThroughSearchBar(
30-
page,
31-
ANVIL_CATALOG_TABS.STUDIES,
32-
filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i])
33-
);
34-
});
9+
const ENTITIES = [
10+
{ name: "Consortia", url: "/data/consortia" },
11+
{ name: "Studies", url: "/data/studies" },
12+
{ name: "Workspaces", url: "/data/workspaces" },
13+
];
3514

36-
test('Check that selecting filters through the "Search all Filters" textbox works correctly on the Workspaces tab', async ({
37-
page,
38-
}) => {
39-
await testSelectFiltersThroughSearchBar(
40-
page,
41-
ANVIL_CATALOG_TABS.WORKSPACES,
42-
filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i])
43-
);
44-
});
15+
const FACET_NAMES = [
16+
ANVIL_CATALOG_CATEGORY_NAMES.CONSENT_CODE,
17+
ANVIL_CATALOG_CATEGORY_NAMES.DBGAP_ID,
18+
ANVIL_CATALOG_CATEGORY_NAMES.TERRA_WORKSPACE_NAME,
19+
];
4520

46-
test('Check that deselecting filters through the "Search all Filters" textbox works correctly on the Consortia tab', async ({
47-
page,
48-
}) => {
49-
await testDeselectFiltersThroughSearchBar(
50-
page,
51-
ANVIL_CATALOG_TABS.CONSORTIA,
52-
filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i])
53-
);
54-
});
21+
test.describe("AnVIL Catalog filter search", () => {
22+
for (const entity of ENTITIES) {
23+
test.describe(`${entity.name} tab`, () => {
24+
let filters: Locator;
25+
let searchAllFilters: Locator;
5526

56-
test('Check that deselecting filters through the "Search all Filters" textbox works correctly on the Studies tab', async ({
57-
page,
58-
}) => {
59-
await testDeselectFiltersThroughSearchBar(
60-
page,
61-
ANVIL_CATALOG_TABS.STUDIES,
62-
filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i])
63-
);
64-
});
27+
test.beforeEach(async ({ page }) => {
28+
await page.goto(entity.url);
29+
filters = page.getByTestId(TEST_IDS.FILTERS);
30+
searchAllFilters = page.getByTestId(TEST_IDS.SEARCH_ALL_FILTERS);
31+
await filters.waitFor();
32+
});
33+
34+
test("selects filters through search bar", async ({ page }) => {
35+
for (const filterName of FACET_NAMES) {
36+
// Open filter dropdown, note first option name, close
37+
await openFilterDropdown(filters, filterName);
38+
const optionName = await getFirstOptionName(page);
39+
await page.keyboard.press(KEYBOARD_KEYS.ESCAPE);
40+
await expectFilterPopoverClosed(page);
41+
42+
// Search for the option and select it
43+
await fillSearchAllFilters(searchAllFilters, optionName);
44+
const filterItem = namedFilterItem(page, optionName);
45+
await expectFilterItemNotSelected(filterItem);
46+
await filterItem.click();
47+
await expectFilterItemSelected(filterItem);
48+
await page.keyboard.press(KEYBOARD_KEYS.ESCAPE);
49+
await expectAutocompletePopperClosed(page);
6550

66-
test('Check that deselecting filters through the "Search all Filters" textbox works correctly on the Workspaces tab', async ({
67-
page,
68-
}) => {
69-
await testDeselectFiltersThroughSearchBar(
70-
page,
71-
ANVIL_CATALOG_TABS.WORKSPACES,
72-
filterList.map((i: number) => ANVIL_CATALOG_FILTERS[i])
73-
);
51+
// Verify filter tag appeared
52+
await expect(filterTag(filters, optionName)).toBeVisible();
53+
54+
// Clean up: remove filter tag
55+
await filterTag(filters, optionName).dispatchEvent("click");
56+
await expect(filterTag(filters, optionName)).not.toBeVisible();
57+
}
58+
});
59+
60+
test("deselects filters through search bar", async ({ page }) => {
61+
for (const filterName of FACET_NAMES) {
62+
// Select the first option through the dropdown
63+
const optionName = await selectFirstOption(filters, page, filterName);
64+
await expect(filterTag(filters, optionName)).toBeVisible();
65+
66+
// Search for the option and deselect it
67+
await fillSearchAllFilters(searchAllFilters, optionName);
68+
const filterItem = namedFilterItem(page, optionName);
69+
await expectFilterItemSelected(filterItem);
70+
await filterItem.click();
71+
await expectFilterItemNotSelected(filterItem);
72+
await page.keyboard.press(KEYBOARD_KEYS.ESCAPE);
73+
await expectAutocompletePopperClosed(page);
74+
75+
// Verify filter tag disappeared
76+
await expect(filterTag(filters, optionName)).not.toBeVisible();
77+
}
78+
});
79+
});
80+
}
7481
});
82+
83+
/* ——————————————————————————— helpers ——————————————————————————— */
84+
85+
/**
86+
* Escapes regex special characters in a string.
87+
* @param s - The string to escape.
88+
* @returns A string with all RegExp special characters escaped.
89+
*/
90+
function escapeRegExp(s: string): string {
91+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
92+
}
93+
94+
/**
95+
* Waits for the autocomplete popper to be fully unmounted from the DOM.
96+
* @param page - Page.
97+
*/
98+
async function expectAutocompletePopperClosed(page: Page): Promise<void> {
99+
await expect(page.locator(MUI_CLASSES.AUTOCOMPLETE_POPPER)).toHaveCount(0);
100+
}
101+
102+
/**
103+
* Waits for the autocomplete popper to be visible.
104+
* @param page - Page.
105+
*/
106+
async function expectAutocompletePopperOpen(page: Page): Promise<void> {
107+
await expect(page.locator(MUI_CLASSES.AUTOCOMPLETE_POPPER)).toBeVisible();
108+
}
109+
110+
/**
111+
* Asserts that a filter item is not selected.
112+
* @param filterItem - A filter-item locator.
113+
*/
114+
async function expectFilterItemNotSelected(filterItem: Locator): Promise<void> {
115+
await expect(filterItem).not.toHaveClass(/Mui-selected/);
116+
}
117+
118+
/**
119+
* Asserts that a filter item is selected.
120+
* @param filterItem - A filter-item locator.
121+
*/
122+
async function expectFilterItemSelected(filterItem: Locator): Promise<void> {
123+
await expect(filterItem).toHaveClass(/Mui-selected/);
124+
}
125+
126+
/**
127+
* Waits for all filter popovers to be fully unmounted from the DOM.
128+
* @param page - Page.
129+
*/
130+
async function expectFilterPopoverClosed(page: Page): Promise<void> {
131+
await expect(filterPopover(page)).toHaveCount(0);
132+
}
133+
134+
/**
135+
* Waits for the filter popover to be visible.
136+
* @param page - Page.
137+
*/
138+
async function expectFilterPopoverOpen(page: Page): Promise<void> {
139+
await expect(filterPopover(page)).toBeVisible();
140+
}
141+
142+
/**
143+
* Extracts the display name from a filter item element.
144+
* @param filterItem - A locator for the filter-item element.
145+
* @returns The display name of the filter option.
146+
*/
147+
async function extractOptionName(filterItem: Locator): Promise<string> {
148+
return (
149+
await filterItem.getByTestId(TEST_IDS.FILTER_TERM).innerText()
150+
).trim();
151+
}
152+
153+
/**
154+
* Fills the "Search all filters" input and waits for the results to appear.
155+
* @param searchAllFilters - The search-all-filters container locator.
156+
* @param text - The text to type into the search input.
157+
*/
158+
async function fillSearchAllFilters(
159+
searchAllFilters: Locator,
160+
text: string
161+
): Promise<void> {
162+
await expectAutocompletePopperClosed(searchAllFilters.page());
163+
const input = searchAllFilters.getByRole("combobox");
164+
await input.fill(text);
165+
await expectAutocompletePopperOpen(searchAllFilters.page());
166+
}
167+
168+
/**
169+
* Returns a locator for the filter popover.
170+
* @param page - Page.
171+
* @returns A locator for the filter popover.
172+
*/
173+
function filterPopover(page: Page): Locator {
174+
return page.getByTestId(TEST_IDS.FILTER_POPOVER);
175+
}
176+
177+
/**
178+
* Returns a regex matching a sidebar filter button, e.g. "Consent Code (5)".
179+
* @param filterName - The name of the filter.
180+
* @returns A RegExp matching the sidebar button text.
181+
*/
182+
function filterRegex(filterName: string): RegExp {
183+
return new RegExp(escapeRegExp(filterName) + "\\s+\\(\\d+\\)\\s*");
184+
}
185+
186+
/**
187+
* Returns a locator for a filter tag (MuiChip) within the filters container.
188+
* @param filters - The filters container locator.
189+
* @param name - The filter option name to match.
190+
* @returns A locator for the filter tag chip.
191+
*/
192+
function filterTag(filters: Locator, name: string): Locator {
193+
return filters.locator(MUI_CLASSES.CHIP, { hasText: name });
194+
}
195+
196+
/**
197+
* Returns a locator for the first filter item in the open popover.
198+
* @param page - Page.
199+
* @returns A locator for the first filter item.
200+
*/
201+
function firstFilterItem(page: Page): Locator {
202+
return filterPopover(page).getByTestId(TEST_IDS.FILTER_ITEM).first();
203+
}
204+
205+
/**
206+
* Returns the name of the first filter item in the open popover.
207+
* @param page - Page.
208+
* @returns The display name of the first option.
209+
*/
210+
async function getFirstOptionName(page: Page): Promise<string> {
211+
return extractOptionName(firstFilterItem(page));
212+
}
213+
214+
/**
215+
* Returns a locator for a named filter item in the autocomplete popper.
216+
* @param page - Page.
217+
* @param optionName - The display name of the filter option.
218+
* @returns A locator for the matching filter item.
219+
*/
220+
function namedFilterItem(page: Page, optionName: string): Locator {
221+
return page
222+
.locator(MUI_CLASSES.AUTOCOMPLETE_POPPER)
223+
.getByTestId(TEST_IDS.FILTER_ITEM)
224+
.filter({ hasText: RegExp(`^${escapeRegExp(optionName)}\\s*\\d+\\s*`) })
225+
.first();
226+
}
227+
228+
/**
229+
* Opens a filter dropdown by clicking its sidebar button.
230+
* Uses dispatchEvent because the filter menu sometimes intercepts regular clicks.
231+
* @param filters - The filters container locator.
232+
* @param filterName - The name of the sidebar filter to open.
233+
*/
234+
async function openFilterDropdown(
235+
filters: Locator,
236+
filterName: string
237+
): Promise<void> {
238+
await expectFilterPopoverClosed(filters.page());
239+
const button = filters.getByText(filterRegex(filterName));
240+
await expect(button).toBeVisible();
241+
await button.dispatchEvent("click");
242+
await expectFilterPopoverOpen(filters.page());
243+
}
244+
245+
/**
246+
* Opens a sidebar filter dropdown, selects its first option, and returns the
247+
* option name. Waits for the item to be selected before returning.
248+
* @param filters - The filters container locator.
249+
* @param page - Page (needed for popover content).
250+
* @param filterName - The name of the sidebar filter to open.
251+
* @returns The display name of the selected option.
252+
*/
253+
async function selectFirstOption(
254+
filters: Locator,
255+
page: Page,
256+
filterName: string
257+
): Promise<string> {
258+
await openFilterDropdown(filters, filterName);
259+
const option = firstFilterItem(page);
260+
const name = await extractOptionName(option);
261+
await expectFilterItemNotSelected(option);
262+
await option.click();
263+
await expectFilterItemSelected(option);
264+
await page.keyboard.press(KEYBOARD_KEYS.ESCAPE);
265+
await expectFilterPopoverClosed(page);
266+
return name;
267+
}

e2e/anvil-catalog/anvilcatalog-tabs.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,6 @@ import {
1010
} from "./constants";
1111

1212
const ANVIL_CATALOG_SEARCH_FILTERS_PLACEHOLDER_TEXT = "Search all filters...";
13-
export const ANVIL_CATALOG_FILTERS = [
14-
"Consent Code",
15-
"Consortium",
16-
"Data Type",
17-
"dbGap Id",
18-
"Disease (indication)",
19-
"Study Design",
20-
"Study",
21-
"Terra Workspace Name",
22-
];
23-
24-
export const CONSENT_CODE_INDEX = 0;
25-
export const CONSORTIUM_INDEX = 1;
26-
export const DATA_TYPE_INDEX = 2;
27-
export const DBGAP_ID_INDEX = 3;
28-
export const DISEASE_INDICATION_INDEX = 4;
29-
export const STUDY_DESIGN_INDEX = 5;
30-
export const STUDY_INDEX = 6;
31-
export const TERRA_WORKSPACE_INDEX = 7;
3213

3314
export const ANVIL_CATALOG_TABS: AnvilCatalogTabCollection = {
3415
CONSORTIA: {

e2e/anvil-catalog/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
export const ANVIL_CATALOG_CATEGORY_NAMES = {
2+
CONSENT_CODE: "Consent Code",
3+
DBGAP_ID: "dbGap Id",
4+
TERRA_WORKSPACE_NAME: "Terra Workspace Name",
5+
};
6+
17
export const ANVIL_CATALOG_COLUMN_NAMES = {
28
CONSENT_CODE: "Consent Code",
39
CONSENT_CODES: "Consent Codes",

e2e/features/common/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ export const KEYBOARD_KEYS = {
44

55
export const MUI_CLASSES = {
66
ALERT: ".MuiAlert-root",
7+
AUTOCOMPLETE_POPPER: ".MuiAutocomplete-popper",
78
BUTTON: ".MuiButton-root",
89
BUTTON_GROUP: ".MuiButtonGroup-root",
10+
CHIP: ".MuiChip-root",
911
FORM_CONTROL: ".MuiFormControl-root",
1012
ICON_BUTTON: ".MuiIconButton-root",
1113
LIST: ".MuiList-root",

0 commit comments

Comments
 (0)