Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test: make explist e2e sort test more reliable #10067

Merged
merged 4 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions webui/react/src/components/MultiSortMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Button from 'hew/Button';
import { DirectionType, Sort, validSort } from 'hew/DataGrid/DataGrid';
import Dropdown, { MenuItem } from 'hew/Dropdown';
import Icon from 'hew/Icon';
import Select from 'hew/Select';
import Select, { Option } from 'hew/Select';
import { Loadable } from 'hew/utils/loadable';

import { V1ColumnType } from 'services/api-ts-sdk';
Expand Down Expand Up @@ -130,16 +130,23 @@ export const sortMenuItemsForColumn = (
});
};

const DirectionOptions: React.FC<DirectionOptionsProps> = ({ onChange, type, value }) => (
<Select
data-test="direction"
options={optionsByColumnType[type]}
placeholder="Select direction"
value={value}
width="100%"
onChange={(val) => onChange?.(val as DirectionType)}
/>
);
const DirectionOptions: React.FC<DirectionOptionsProps> = ({ onChange, type, value }) => {
const options = optionsByColumnType[type];
return (
<Select
data-test="direction"
placeholder="Select direction"
value={value}
width="100%"
onChange={(val) => onChange?.(val as DirectionType)}>
{options.map((o) => (
<Option data-select-value={o.value} key={o.value}>
{o.label}
</Option>
))}
</Select>
);
};

const ColumnOptions: React.FC<ColumnOptionsProps> = ({
onChange,
Expand Down
190 changes: 112 additions & 78 deletions webui/react/src/e2e/tests/experimentList.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import dayjs from 'dayjs';
import utcPlugin from 'dayjs/plugin/utc';

import { expect, test } from 'e2e/fixtures/global-fixtures';
import { ExperimentRow } from 'e2e/models/components/F_ExperimentList';
import { ProjectDetails } from 'e2e/models/pages/ProjectDetails';
Expand All @@ -6,6 +9,8 @@ import { safeName } from 'e2e/utils/naming';
import { repeatWithFallback } from 'e2e/utils/polling';
import { ExperimentBase } from 'types';

dayjs.extend(utcPlugin);

test.describe('Experiment List', () => {
let projectDetailsPage: ProjectDetails;
// trial click to wait for the element to be stable won't work here
Expand Down Expand Up @@ -442,67 +447,104 @@ test.describe('Experiment List', () => {
});
});
test.describe('Experiment List Multi-sort', () => {
const runScenarioAndValidation = (projectDetailsPage: ProjectDetails) => {
const multiSortMenu = projectDetailsPage.f_experimentList.tableActionBar.multiSortMenu;
const validateByColumn = async (
rows: ExperimentRow[],
colKey: string,
descending: boolean,
) => {
const valuesToCompare = await Promise.all(
rows.map(async (r) => (await r.getCellByColumnName(colKey)).pwLocator.innerText()),
);
// eslint-disable-next-line no-console
console.log(valuesToCompare[0]);

const expectedValues = [...valuesToCompare].sort();
if (descending) {
expectedValues.reverse();
}
expect(valuesToCompare).toEqual(expectedValues);
};
const checkTableOrder = async (firstKey: string, secondKey: string, descending = false) => {
const rows = await projectDetailsPage.f_experimentList.dataGrid.filterRows(
async () => await true,
);
await validateByColumn(rows, firstKey, descending);
await validateByColumn(rows, secondKey, descending);
};
const sortingScenario = async (
firstSortBy: string,
firstSortOrder: string,
secondSortBy: string,
secondSortOrder: string,
scenario: () => Promise<void>,
): Promise<void> => {
await test.step(`Sort by ${firstSortBy} and ${secondSortBy}`, async () => {
type sort = { column: string; direction: 'asc' | 'desc' };

// best-effort column text parsing -- may need to read the actual column defs to be perfect
const parseColumnText = (text: string) => {
if (text === '-') {
return null;
}
const num = Number(text);
if (!Number.isNaN(num)) {
return num;
}
const date = dayjs.utc(text);
if (date.isValid()) {
return date;
}
return text;
};
const testSorts = (sorts: sort[]) => {
const lastSort = sorts[sorts.length - 1];
const sortTextList = sorts
.slice(0, -1)
.map((s) => `${s.column} ${s.direction}`)
.join(', ');

return test(`Sort by ${sortTextList} and ${lastSort.column} ${lastSort.direction}`, async () => {
await test.step('Set Up Sorts', async () => {
const multiSortMenu = projectDetailsPage.f_experimentList.tableActionBar.multiSortMenu;
await multiSortMenu.open();
await multiSortMenu.multiSort.reset.pwLocator.click();
await multiSortMenu.close();
await multiSortMenu.open();

const firstRow = multiSortMenu.multiSort.rows.nth(0);
await firstRow.column.selectMenuOption(firstSortBy);
await firstRow.order.selectMenuOption(firstSortOrder);

await multiSortMenu.multiSort.add.pwLocator.click();

const secondRow = multiSortMenu.multiSort.rows.nth(1);
await secondRow.column.selectMenuOption(secondSortBy);
await secondRow.order.selectMenuOption(secondSortOrder);

for (let i = 0; i < sorts.length; i++) {
const sort = sorts[i];
const sortRow = multiSortMenu.multiSort.rows.nth(i);
await sortRow.column.selectMenuOption(sort.column);
// select order menu item by value not label
await sortRow.order.openMenu();
await sortRow.order._menu.pwLocator
.locator(`[data-select-value="${sort.direction}"]`)
.click();
if (i < sorts.length - 1) {
await multiSortMenu.multiSort.add.pwLocator.click();
}
}
await multiSortMenu.close();
await waitTableStable();
await scenario();
});
};

return { checkTableOrder, sortingScenario };
await test.step('Verify Order', async () => {
const rows = await projectDetailsPage.f_experimentList.dataGrid.filterRows(() =>
Promise.resolve(true),
);
const getValuesForRow = async (r: ExperimentRow) => {
const rowColumnValues = await Promise.all(
sorts.map(async ({ column }) => ({
[column]: await (await r.getCellByColumnName(column)).pwLocator.innerText(),
})),
);
return Object.assign({}, ...rowColumnValues);
};
const rowValues = await Promise.all(rows.map(getValuesForRow));
// sorting all values at the same time to make reported failures easier to decipher
// it'd be nice to use _.orderBy, but it's harder to suss out the null placement logic
const expectedOrder = [...rowValues].sort((rowA, rowB) => {
for (const sort of sorts) {
const isAscending = sort.direction === 'asc';
const valueA = parseColumnText(rowA[sort.column]);
const valueB = parseColumnText(rowB[sort.column]);
// nulls are always last
if (valueA === null && valueB !== null) {
return 1;
}
if (valueB === null && valueA !== null) {
return -1;
}
if (typeof valueA === 'string' && typeof valueB === 'string' && valueA !== valueB) {
const cmpValue = valueA < valueB ? -1 : 1;
return isAscending ? cmpValue : cmpValue * -1;
}
if (typeof valueA === 'number' && typeof valueB === 'number' && valueA !== valueB) {
const cmpValue = valueA - valueB;
return isAscending ? cmpValue : cmpValue * -1;
}
if (dayjs.isDayjs(valueA) && dayjs.isDayjs(valueB) && !valueA.isSame(valueB)) {
const cmpValue = valueA.isBefore(valueB) ? -1 : 1;
return isAscending ? cmpValue : cmpValue * -1;
}
// if types are different or values are same, skip the column
}
return 0;
});
expect(rowValues).toEqual(expectedOrder);
});
});
};

test.beforeAll(async ({ newProject }) => {
test.beforeAll(({ newProject }) => {
// create a new experiment for comparing Searcher Metric and Trial Count
await detExecSync(
detExecSync(
`experiment create ${fullPath('examples/tutorials/core_api_pytorch_mnist/checkpoints.yaml')} --paused --project_id ${newProject.response.project.id}`,
);
});
Expand Down Expand Up @@ -531,32 +573,24 @@ test.describe('Experiment List', () => {
await projectDetailsPage.f_experimentList.dataGrid.headRow.setColumnDefs();
});

test('sort with ID as 0 → 9 and Searcher as A → Z', async () => {
const { sortingScenario, checkTableOrder } = runScenarioAndValidation(projectDetailsPage);
await sortingScenario('ID', '0 → 9', 'Searcher', 'A → Z', async () => {
await checkTableOrder('ID', 'Searcher');
});
});

test('sort with ID as 9 → 0 and Searcher as Z → A', async () => {
const { sortingScenario, checkTableOrder } = runScenarioAndValidation(projectDetailsPage);
await sortingScenario('ID', '9 → 0', 'Searcher', 'Z → A', async () => {
await checkTableOrder('ID', 'Searcher', true);
});
});

test('sort with Trial count as 0 → 0 and Searcher Metric as A → Z', async () => {
const { sortingScenario, checkTableOrder } = runScenarioAndValidation(projectDetailsPage);
await sortingScenario('Trial count', '0 → 9', 'Searcher Metric', 'A → Z', async () => {
await checkTableOrder('Trial count', 'Searcher Metric');
});
});

test('sort with Trial count as 9 → 0 and Searcher Metric as Z → A', async () => {
const { sortingScenario, checkTableOrder } = runScenarioAndValidation(projectDetailsPage);
await sortingScenario('Trial count', '9 → 0', 'Searcher Metric', 'Z → A', async () => {
await checkTableOrder('Trial count', 'Searcher Metric', true);
});
});
testSorts([
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very clean!

{ column: 'ID', direction: 'asc' },
{ column: 'Searcher', direction: 'asc' },
]);

testSorts([
{ column: 'ID', direction: 'desc' },
{ column: 'Searcher', direction: 'desc' },
]);

testSorts([
{ column: 'Trial count', direction: 'asc' },
{ column: 'Searcher Metric', direction: 'asc' },
]);

testSorts([
{ column: 'Trial count', direction: 'desc' },
{ column: 'Searcher Metric', direction: 'desc' },
]);
});
});
Loading