fix: Fix flaky E2E tests (#2013)

## Summary

This PR reduces flakiness in some E2E tests by

1. Updating Search filters to have test ids which are specific to both the column/key and the value, since identical values across columns caused strict mode failures
2. Updating the Saved Search tests to use more web first assertions

### Screenshots or video


### How to test locally or on Vercel

### References



- Linear Issue:
- Related PRs:
This commit is contained in:
Drew Davis 2026-03-30 14:28:42 -04:00 committed by GitHub
parent e6a0455aa5
commit 853da16ad3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 140 additions and 93 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
fix: Fix flaky E2E tests

View file

@ -100,6 +100,7 @@ export function cleanedFacetName(key: string): string {
}
type FilterCheckboxProps = {
columnName: string;
label: string;
value?: 'included' | 'excluded' | false;
pinned: boolean;
@ -160,6 +161,7 @@ const FilterPercentage = ({ percentage, isLoading }: FilterPercentageProps) => {
};
const FilterCheckbox = ({
columnName,
value,
label,
pinned,
@ -171,10 +173,11 @@ const FilterCheckbox = ({
percentage,
isPercentageLoading,
}: FilterCheckboxProps) => {
const testIdPrefix = `filter-checkbox-${columnName}-${label}`;
return (
<div
className={cx(classes.filterCheckbox, className)}
data-testid={`filter-checkbox-${label}`}
data-testid={testIdPrefix}
>
<Group
gap={8}
@ -189,7 +192,7 @@ const FilterCheckbox = ({
// taken care by the onClick in the group
}}
indeterminate={value === 'excluded'}
data-testid={`filter-checkbox-input-${label}`}
data-testid={`${testIdPrefix}-input`}
/>
<Tooltip
openDelay={label.length > 22 ? 0 : 1500}
@ -234,14 +237,14 @@ const FilterCheckbox = ({
<TextButton
onClick={onClickOnly}
label="Only"
data-testid={`filter-only-${label}`}
data-testid={`${testIdPrefix}-only`}
/>
)}
{onClickExclude && (
<TextButton
onClick={onClickExclude}
label="Exclude"
data-testid={`filter-exclude-${label}`}
data-testid={`${testIdPrefix}-exclude`}
/>
)}
<ActionIcon
@ -252,14 +255,14 @@ const FilterCheckbox = ({
aria-label={pinned ? 'Unpin field' : 'Pin field'}
role="checkbox"
aria-checked={pinned}
data-testid={`filter-pin-${label}`}
data-testid={`${testIdPrefix}-pin`}
>
{pinned ? <IconPinFilled size={12} /> : <IconPin size={12} />}
</ActionIcon>
</div>
{pinned && (
<Center me="1px">
<IconPinFilled size={12} data-testid={`filter-pin-${label}-pinned`} />
<IconPinFilled size={12} data-testid={`${testIdPrefix}-pin-pinned`} />
</Center>
)}
</div>
@ -755,6 +758,7 @@ export const FilterGroup = ({
{displayedOptions.map(option => (
<FilterCheckbox
key={option.value.toString()}
columnName={name}
label={option.label}
pinned={isPinned(option.value)}
className={

View file

@ -411,7 +411,7 @@ describe('FilterGroup', () => {
it('should sort options alphabetically by default', () => {
renderWithMantine(<FilterGroup {...defaultProps} />);
const options = screen.getAllByTestId(/filter-checkbox-input/);
const options = screen.getAllByTestId(/filter-checkbox-.+-input/);
expect(options).toHaveLength(3);
const labels = screen.getAllByText(/apple|banana|zebra/);
expect(labels[0]).toHaveTextContent('apple');
@ -430,7 +430,7 @@ describe('FilterGroup', () => {
/>,
);
const options = screen.getAllByTestId(/filter-checkbox-input/);
const options = screen.getAllByTestId(/filter-checkbox-.+-input/);
expect(options).toHaveLength(3);
const labels = screen.getAllByText(/apple|banana|zebra/);
expect(labels[0]).toHaveTextContent('apple');
@ -459,7 +459,7 @@ describe('FilterGroup', () => {
/>,
);
const options = screen.getAllByTestId(/filter-checkbox-input/);
const options = screen.getAllByTestId(/filter-checkbox-.+-input/);
expect(options).toHaveLength(3);
const labels = screen.getAllByText(/apple|banana|zebra/);
expect(labels[0]).toHaveTextContent('banana'); // Selected
@ -492,7 +492,7 @@ describe('FilterGroup', () => {
);
await userEvent.click(showPercentages);
const options = screen.getAllByTestId(/filter-checkbox-input/);
const options = screen.getAllByTestId(/filter-checkbox-.+-input/);
expect(options).toHaveLength(3);
const labels = screen.getAllByText(/%/);
expect(labels[0]).toHaveTextContent('~99%'); // apple
@ -511,7 +511,7 @@ describe('FilterGroup', () => {
/>,
);
const options = screen.getAllByTestId(/filter-checkbox-input/);
const options = screen.getAllByTestId(/filter-checkbox-.+-input/);
expect(options).toHaveLength(3);
const labels = screen.getAllByText(/apple|banana|zebra/);
expect(labels[0]).toHaveTextContent('apple'); // included first
@ -542,7 +542,7 @@ describe('FilterGroup', () => {
);
// Should show MAX_FILTER_GROUP_ITEMS (10) by default
let options = screen.getAllByTestId(/filter-checkbox-input/);
let options = screen.getAllByTestId(/filter-checkbox-.+-input/);
expect(options).toHaveLength(10);
// Selected items should be visible even if they would be beyond MAX_FILTER_GROUP_ITEMS
@ -555,7 +555,7 @@ describe('FilterGroup', () => {
await userEvent.click(showMoreButton);
// Should show all items
options = screen.getAllByTestId(/filter-checkbox-input/);
options = screen.getAllByTestId(/filter-checkbox-.+-input/);
expect(options).toHaveLength(15);
});

View file

@ -49,50 +49,59 @@ export class FilterComponent {
}
/**
* Get checkbox for a specific filter value
* Get checkbox for a specific filter value within a column
* @param columnName - e.g., 'ServiceName', 'SeverityText'
* @param valueName - e.g., 'info', 'error', 'debug'
*/
getFilterCheckbox(valueName: string) {
return this.page.getByTestId(`filter-checkbox-${valueName}`);
getFilterCheckbox(columnName: string, valueName: string) {
return this.page.getByTestId(`filter-checkbox-${columnName}-${valueName}`);
}
/**
* Get checkbox input element
* Get checkbox input element within a column
*/
getFilterCheckboxInput(valueName: string) {
return this.page.getByTestId(`filter-checkbox-input-${valueName}`);
getFilterCheckboxInput(columnName: string, valueName: string) {
return this.page.getByTestId(
`filter-checkbox-${columnName}-${valueName}-input`,
);
}
/**
* Apply/select a filter value
*/
async applyFilter(valueName: string) {
const checkbox = this.getFilterCheckbox(valueName);
async applyFilter(columnName: string, valueName: string) {
const checkbox = this.getFilterCheckbox(columnName, valueName);
await checkbox.click();
}
/**
* Exclude a filter value (invert the filter)
*/
async excludeFilter(valueName: string) {
const filterCheckbox = this.getFilterCheckbox(valueName);
await this.scrollAndClick(filterCheckbox, `filter-exclude-${valueName}`);
async excludeFilter(columnName: string, valueName: string) {
const filterCheckbox = this.getFilterCheckbox(columnName, valueName);
await this.scrollAndClick(
filterCheckbox,
`filter-checkbox-${columnName}-${valueName}-exclude`,
);
}
/**
* Pin a filter value to persist it
*/
async pinFilter(valueName: string) {
const filterCheckbox = this.getFilterCheckbox(valueName);
await this.scrollAndClick(filterCheckbox, `filter-pin-${valueName}`);
async pinFilter(columnName: string, valueName: string) {
const filterCheckbox = this.getFilterCheckbox(columnName, valueName);
await this.scrollAndClick(
filterCheckbox,
`filter-checkbox-${columnName}-${valueName}-pin`,
);
}
/**
* Clear/unselect a filter
*/
async clearFilter(valueName: string) {
const input = this.getFilterCheckboxInput(valueName);
const checkbox = this.getFilterCheckbox(valueName);
async clearFilter(columnName: string, valueName: string) {
const input = this.getFilterCheckboxInput(columnName, valueName);
const checkbox = this.getFilterCheckbox(columnName, valueName);
await checkbox.click();
await input.click();
}
@ -160,8 +169,11 @@ export class FilterComponent {
/**
* Check if filter checkbox is indeterminate (excluded state)
*/
async isFilterExcluded(valueName: string): Promise<boolean> {
const input = this.getFilterCheckboxInput(valueName);
async isFilterExcluded(
columnName: string,
valueName: string,
): Promise<boolean> {
const input = this.getFilterCheckboxInput(columnName, valueName);
const indeterminate = await input.getAttribute('data-indeterminate');
return indeterminate === 'true';
}
@ -170,7 +182,9 @@ export class FilterComponent {
* Get all filter values for a specific filter group
*/
getFilterValues(filterGroupName: string) {
return this.page.getByTestId(`filter-checkbox-${filterGroupName}`);
return this.page.getByTestId(
new RegExp(`^filter-checkbox-${filterGroupName}-`),
);
}
/**
@ -223,7 +237,9 @@ export class FilterComponent {
// Wait for initial facet options to load
const group = this.getFilterGroup(filterGroupName);
await group
.locator('[data-testid^="filter-checkbox-input-"]')
.locator(
`[data-testid^="filter-checkbox-${filterGroupName}-"][data-testid$="-input"]`,
)
.first()
.waitFor({ state: 'visible', timeout: 10000 });
@ -232,7 +248,7 @@ export class FilterComponent {
const visible: string[] = [];
for (const value of candidates) {
if (visible.length >= count) break;
const input = this.getFilterCheckboxInput(value);
const input = this.getFilterCheckboxInput(filterGroupName, value);
if (await input.isVisible()) visible.push(value);
}
if (visible.length < count) {

View file

@ -65,9 +65,9 @@ test.describe('Saved Search Functionality', () => {
await test.step('Verify custom SELECT is preserved', async () => {
const selectEditor = searchPage.getSELECTEditor();
const selectContent = await selectEditor.textContent();
expect(selectContent).toContain('upper(ServiceName) as service_name');
await expect(selectEditor).toContainText(
'upper(ServiceName) as service_name',
);
});
},
);
@ -107,10 +107,10 @@ test.describe('Saved Search Functionality', () => {
await test.step('Verify different source has its own default SELECT', async () => {
const selectEditor = searchPage.getSELECTEditor();
const selectContent = await selectEditor.textContent();
expect(selectContent).not.toContain('lower(Body) as body_lower');
expect(selectContent).toMatch(/Timestamp/i);
await expect(selectEditor).not.toContainText(
'lower(Body) as body_lower',
);
await expect(selectEditor).toContainText('Timestamp');
});
await test.step('Navigate back to saved search', async () => {
@ -120,11 +120,11 @@ test.describe('Saved Search Functionality', () => {
await test.step('Verify saved search SELECT is restored', async () => {
const selectEditor = searchPage.getSELECTEditor();
const selectContent = await selectEditor.textContent();
// Verifies the fix: SELECT restores to saved search's custom value
expect(selectContent).toContain('lower(Body) as body_lower');
expect(selectContent).toContain('Timestamp, Body, lower(Body)');
await expect(selectEditor).toContainText('lower(Body) as body_lower');
await expect(selectEditor).toContainText(
'Timestamp, Body, lower(Body)',
);
});
},
);
@ -153,12 +153,10 @@ test.describe('Saved Search Functionality', () => {
await test.step('Verify SELECT changed to the new source default', async () => {
const selectEditor = searchPage.getSELECTEditor();
const selectContent = await selectEditor.textContent();
expect(selectContent).not.toContain(
await expect(selectEditor).not.toContainText(
'lower(ServiceName) as service_name',
);
expect(selectContent).toMatch(/Timestamp/i);
await expect(selectEditor).toContainText('Timestamp');
});
await test.step('Switch back to original source via dropdown', async () => {
@ -170,9 +168,9 @@ test.describe('Saved Search Functionality', () => {
await test.step('Verify SELECT is search custom SELECT', async () => {
const selectEditor = searchPage.getSELECTEditor();
const selectContent = await selectEditor.textContent();
expect(selectContent).toContain('lower(ServiceName) as service_name');
await expect(selectEditor).toContainText(
'lower(ServiceName) as service_name',
);
});
},
);
@ -240,8 +238,7 @@ test.describe('Saved Search Functionality', () => {
// Verify ORDER BY is restored
const orderByEditor = searchPage.getOrderByEditor();
const orderByContent = await orderByEditor.textContent();
expect(orderByContent).toContain('ServiceName ASC');
await expect(orderByEditor).toContainText('ServiceName ASC');
// Verify search results are visible (search executed automatically)
await searchPage.table.waitForRowsToPopulate();
@ -295,10 +292,10 @@ test.describe('Saved Search Functionality', () => {
// Verify SELECT content
const selectEditor = searchPage.getSELECTEditor();
const selectContent = await selectEditor.textContent();
expect(selectContent).toContain('upper(ServiceName) as service_name');
expect(selectContent).toContain('Timestamp, Body');
await expect(selectEditor).toContainText(
'upper(ServiceName) as service_name',
);
await expect(selectEditor).toContainText('Timestamp, Body');
});
},
);
@ -320,8 +317,7 @@ test.describe('Saved Search Functionality', () => {
await test.step('Verify default SELECT is loaded', async () => {
const selectEditor = searchPage.getSELECTEditor();
const selectContent = await selectEditor.textContent();
expect(selectContent).toContain(
await expect(selectEditor).toContainText(
'Timestamp, ServiceName, SeverityText, Body',
);
});
@ -338,8 +334,7 @@ test.describe('Saved Search Functionality', () => {
await test.step('Verify default SELECT is loaded', async () => {
const selectEditor = searchPage.getSELECTEditor();
const selectContent = await selectEditor.textContent();
expect(selectContent).toContain(
await expect(selectEditor).toContainText(
'Timestamp, ServiceName, SeverityText, Body',
);
});
@ -484,11 +479,16 @@ test.describe('Saved Search Functionality', () => {
appliedFilterValue = picked;
// Apply the filter
await searchPage.filters.applyFilter(appliedFilterValue);
await searchPage.filters.applyFilter(
'SeverityText',
appliedFilterValue,
);
// Verify filter is checked
const filterInput =
searchPage.filters.getFilterCheckboxInput(appliedFilterValue);
const filterInput = searchPage.filters.getFilterCheckboxInput(
'SeverityText',
appliedFilterValue,
);
await expect(filterInput).toBeChecked();
// Submit search to apply filters
@ -516,8 +516,10 @@ test.describe('Saved Search Functionality', () => {
await searchPage.filters.openFilterGroup('SeverityText');
// Verify filter is not checked
const filterInput =
searchPage.filters.getFilterCheckboxInput(appliedFilterValue);
const filterInput = searchPage.filters.getFilterCheckboxInput(
'SeverityText',
appliedFilterValue,
);
await expect(filterInput).not.toBeChecked();
});
@ -532,8 +534,10 @@ test.describe('Saved Search Functionality', () => {
await searchPage.filters.openFilterGroup('SeverityText');
// Verify filter is checked again
const filterInput =
searchPage.filters.getFilterCheckboxInput(appliedFilterValue);
const filterInput = searchPage.filters.getFilterCheckboxInput(
'SeverityText',
appliedFilterValue,
);
await expect(filterInput).toBeChecked();
});
},
@ -565,7 +569,7 @@ test.describe('Saved Search Functionality', () => {
await test.step('Create saved search with one filter', async () => {
await searchPage.filters.openFilterGroup(firstFilterGroup);
await searchPage.filters.applyFilter(firstFilter);
await searchPage.filters.applyFilter(firstFilterGroup, firstFilter);
await searchPage.submitButton.click();
await searchPage.table.waitForRowsToPopulate(true);
@ -578,7 +582,7 @@ test.describe('Saved Search Functionality', () => {
await test.step('Update saved search with second filter', async () => {
await searchPage.filters.openFilterGroup(secondFilterGroup);
await searchPage.filters.applyFilter(secondFilter);
await searchPage.filters.applyFilter(secondFilterGroup, secondFilter);
await searchPage.submitButton.click();
await searchPage.table.waitForRowsToPopulate(true);
@ -600,10 +604,16 @@ test.describe('Saved Search Functionality', () => {
await searchPage.filters.openFilterGroup(firstFilterGroup);
await searchPage.filters.openFilterGroup(secondFilterGroup);
await expect(
searchPage.filters.getFilterCheckboxInput(firstFilter),
searchPage.filters.getFilterCheckboxInput(
firstFilterGroup,
firstFilter,
),
).toBeChecked();
await expect(
searchPage.filters.getFilterCheckboxInput(secondFilter),
searchPage.filters.getFilterCheckboxInput(
secondFilterGroup,
secondFilter,
),
).toBeChecked();
});
},

View file

@ -4,21 +4,24 @@ import { expect, test } from '../../utils/base-test';
test.describe('Search Filters', { tag: ['@search'] }, () => {
let searchPage: SearchPage;
// Using known seeded data - 'info' severity always exists in test data
const TEST_FILTER_GROUP = 'SeverityText';
const TEST_FILTER_VALUE = 'info';
test.beforeEach(async ({ page }) => {
searchPage = new SearchPage(page);
await searchPage.goto();
await searchPage.filters.openFilterGroup('SeverityText');
await searchPage.filters.openFilterGroup(TEST_FILTER_GROUP);
});
test('Should apply filters', async () => {
// Apply the filter using component method
const filterInput =
searchPage.filters.getFilterCheckboxInput(TEST_FILTER_VALUE);
const filterInput = searchPage.filters.getFilterCheckboxInput(
TEST_FILTER_GROUP,
TEST_FILTER_VALUE,
);
await expect(filterInput).toBeVisible();
await searchPage.filters.applyFilter(TEST_FILTER_VALUE);
await searchPage.filters.applyFilter(TEST_FILTER_GROUP, TEST_FILTER_VALUE);
// Verify filter is checked
await expect(filterInput).toBeChecked();
@ -29,47 +32,56 @@ test.describe('Search Filters', { tag: ['@search'] }, () => {
test('Should exclude filters', async () => {
// Use filter component to exclude the filter
await searchPage.filters.excludeFilter(TEST_FILTER_VALUE);
await searchPage.filters.excludeFilter(
TEST_FILTER_GROUP,
TEST_FILTER_VALUE,
);
// Verify filter shows as excluded using web-first assertion
const isExcluded =
await searchPage.filters.isFilterExcluded(TEST_FILTER_VALUE);
const isExcluded = await searchPage.filters.isFilterExcluded(
TEST_FILTER_GROUP,
TEST_FILTER_VALUE,
);
expect(isExcluded).toBe(true);
});
test('Should clear filters', async () => {
await searchPage.filters.clearFilter(TEST_FILTER_VALUE);
await searchPage.filters.clearFilter(TEST_FILTER_GROUP, TEST_FILTER_VALUE);
// Verify filter is no longer checked
const filterInput =
searchPage.filters.getFilterCheckboxInput(TEST_FILTER_VALUE);
const filterInput = searchPage.filters.getFilterCheckboxInput(
TEST_FILTER_GROUP,
TEST_FILTER_VALUE,
);
await expect(filterInput).not.toBeChecked();
});
test('Should search for and apply filters', async () => {
const filterName = 'SeverityText';
await searchPage.filters.openFilterGroup(filterName);
await searchPage.filters.searchFilterValues(filterName, 'test');
const searchInput = searchPage.filters.getFilterSearchInput(filterName);
await searchPage.filters.openFilterGroup(TEST_FILTER_GROUP);
await searchPage.filters.searchFilterValues(TEST_FILTER_GROUP, 'test');
const searchInput =
searchPage.filters.getFilterSearchInput(TEST_FILTER_GROUP);
await expect(searchInput).toHaveValue('test');
await searchPage.filters.clearFilterSearch(filterName);
await searchPage.filters.clearFilterSearch(TEST_FILTER_GROUP);
await expect(searchInput).toHaveValue('');
});
test('Should pin filter and verify it persists after reload', async () => {
await searchPage.filters.pinFilter(TEST_FILTER_VALUE);
await searchPage.filters.pinFilter(TEST_FILTER_GROUP, TEST_FILTER_VALUE);
// Reload page and verify filter persists
await searchPage.page.reload();
// Verify filter checkbox is still visible
const filterCheckbox =
searchPage.filters.getFilterCheckbox(TEST_FILTER_VALUE);
const filterCheckbox = searchPage.filters.getFilterCheckbox(
TEST_FILTER_GROUP,
TEST_FILTER_VALUE,
);
await expect(filterCheckbox).toBeVisible();
//verify there is a pin icon
const pinIcon = searchPage.page.getByTestId(
`filter-pin-${TEST_FILTER_VALUE}-pinned`,
`filter-checkbox-${TEST_FILTER_GROUP}-${TEST_FILTER_VALUE}-pin-pinned`,
);
await expect(pinIcon).toBeVisible();
});