Skip to content

Commit f3ddf60

Browse files
authored
fix(ui): handle test suite tab loading race (#29561)
1 parent 8c7a9c4 commit f3ddf60

3 files changed

Lines changed: 237 additions & 38 deletions

File tree

openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/TestSuite.spec.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
* See the License for the specific language governing permissions and
1111
* limitations under the License.
1212
*/
13-
import { expect } from '@playwright/test';
13+
import { expect, Route } from '@playwright/test';
1414
import { PLAYWRIGHT_INGESTION_TAG_OBJ } from '../../constant/config';
1515
import { Domain } from '../../support/domain/Domain';
16+
import { BundleTestSuiteClass } from '../../support/entity/BundleTestSuiteClass';
1617
import { EntityTypeEndpoint } from '../../support/entity/Entity.interface';
1718
import { TableClass } from '../../support/entity/TableClass';
1819
import { UserClass } from '../../support/user/UserClass';
@@ -50,6 +51,16 @@ const user1 = new UserClass();
5051
const user2 = new UserClass();
5152
const domain1 = new Domain();
5253
const domain2 = new Domain();
54+
const bundleTestSuite = new BundleTestSuiteClass();
55+
56+
const createDeferred = () => {
57+
let resolveDeferred: () => void = () => undefined;
58+
const promise = new Promise<void>((resolve) => {
59+
resolveDeferred = resolve;
60+
});
61+
62+
return { promise, resolve: resolveDeferred };
63+
};
5364

5465
test.beforeAll(async ({ browser }) => {
5566
const { apiContext, afterAction } = await performAdminLogin(browser);
@@ -60,13 +71,83 @@ test.beforeAll(async ({ browser }) => {
6071
await table.createTestCase(apiContext);
6172
await domain1.create(apiContext);
6273
await domain2.create(apiContext);
74+
await bundleTestSuite.createBundleTestSuite(apiContext);
6375
await afterAction();
6476
});
6577

78+
test.afterAll(async ({ browser }) => {
79+
const bundleSuiteName = bundleTestSuite.bundleTestSuiteResponseData?.name;
80+
81+
if (bundleSuiteName) {
82+
const { apiContext, afterAction } = await performAdminLogin(browser);
83+
await apiContext.delete(
84+
`/api/v1/dataQuality/testSuites/name/${encodeURIComponent(
85+
bundleSuiteName
86+
)}?hardDelete=true&recursive=true`
87+
);
88+
await afterAction();
89+
}
90+
});
91+
6692
test.beforeEach(async ({ page }) => {
6793
await redirectToHomePage(page);
6894
});
6995

96+
test('Test suite tab switching keeps active bundle suite data after stale table suite response', async ({
97+
page,
98+
}) => {
99+
const bundleSuiteName =
100+
bundleTestSuite.bundleTestSuiteResponseData?.name ?? '';
101+
const tableFqn = table.entityResponseData?.fullyQualifiedName ?? '';
102+
const tableSuiteRequestReceived = createDeferred();
103+
const tableSuiteResponseRelease = createDeferred();
104+
const tableSuiteResponseFulfilled = createDeferred();
105+
106+
expect(bundleSuiteName).not.toBe('');
107+
expect(tableFqn).not.toBe('');
108+
109+
await page.route(
110+
'**/api/v1/dataQuality/testSuites/search/list**',
111+
async (route: Route) => {
112+
const requestUrl = new URL(route.request().url());
113+
const testSuiteType = requestUrl.searchParams.get('testSuiteType');
114+
115+
if (testSuiteType === 'basic') {
116+
tableSuiteRequestReceived.resolve();
117+
const tableSuiteResponse = await route.fetch();
118+
await tableSuiteResponseRelease.promise;
119+
120+
await route.fulfill({
121+
response: tableSuiteResponse,
122+
});
123+
tableSuiteResponseFulfilled.resolve();
124+
125+
return;
126+
}
127+
128+
await route.continue();
129+
}
130+
);
131+
132+
await page.goto('/data-quality/test-suites/table-suites');
133+
await tableSuiteRequestReceived.promise;
134+
135+
await expect(page.getByTestId('test-suite-table')).toBeVisible();
136+
await expect(page.getByTestId('loader')).toBeVisible();
137+
138+
await page.getByTestId('bundle-suite-radio-btn').click();
139+
140+
await expect(page.getByTestId(bundleSuiteName)).toBeVisible();
141+
142+
tableSuiteResponseRelease.resolve();
143+
await tableSuiteResponseFulfilled.promise;
144+
145+
await expect(page.getByTestId(bundleSuiteName)).toBeVisible();
146+
await expect(
147+
page.getByTestId('test-suite-table').getByText(tableFqn)
148+
).not.toBeVisible();
149+
});
150+
70151
test(
71152
'Logical TestSuite',
72153
PLAYWRIGHT_INGESTION_TAG_OBJ,

openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/TestSuiteList/TestSuites.component.tsx

Lines changed: 64 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { Col, Form, Row, Select, Space, Typography } from 'antd';
1919
import { AxiosError } from 'axios';
2020
import { isEmpty } from 'lodash';
2121
import QueryString from 'qs';
22-
import { useCallback, useEffect, useMemo, useState } from 'react';
22+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2323
import type { SortDescriptor } from 'react-aria-components';
2424
import { useTranslation } from 'react-i18next';
2525
import { Link, useNavigate, useParams } from 'react-router-dom';
@@ -59,6 +59,7 @@ import { getEntityDetailsPath } from '../../../../utils/RouterUtils';
5959
import { showErrorToast } from '../../../../utils/ToastUtils';
6060
import ErrorPlaceHolder from '../../../common/ErrorWithPlaceholder/ErrorPlaceHolder';
6161
import FilterTablePlaceHolder from '../../../common/ErrorWithPlaceholder/FilterTablePlaceHolder';
62+
import Loader from '../../../common/Loader/Loader';
6263
import NextPrevious from '../../../common/NextPrevious/NextPrevious';
6364
import { PagingHandlerParams } from '../../../common/NextPrevious/NextPrevious.interface';
6465
import { OwnerLabel } from '../../../common/OwnerLabel/OwnerLabel.component';
@@ -116,6 +117,7 @@ export const TestSuites = () => {
116117
} = usePaging();
117118

118119
const [isLoading, setIsLoading] = useState<boolean>(true);
120+
const latestRequestId = useRef(0);
119121

120122
const ownerFilterValue = useMemo(() => {
121123
return selectedOwner
@@ -155,41 +157,55 @@ export const TestSuites = () => {
155157
});
156158
}, [testSuites, sortDescriptor]);
157159

158-
const fetchTestSuites = async (
159-
currentPage = INITIAL_PAGING_VALUE,
160-
params?: ListTestSuitePramsBySearch
161-
) => {
162-
setIsLoading(true);
163-
try {
164-
const result = await getListTestSuitesBySearch({
165-
...params,
166-
fields: [TabSpecificField.OWNERS, TabSpecificField.SUMMARY],
167-
q: searchValue ? `*${searchValue}*` : undefined,
168-
owner: ownerFilterValue?.key,
169-
offset: (currentPage - 1) * pageSize,
170-
includeEmptyTestSuites: subTab !== DataQualitySubTabs.TABLE_SUITES,
171-
testSuiteType:
172-
subTab === DataQualitySubTabs.TABLE_SUITES
173-
? TestSuiteType.basic
174-
: TestSuiteType.logical,
175-
sortField: 'lastResultTimestamp',
176-
sortType: SORT_ORDER.DESC,
177-
});
178-
setTestSuites(result.data);
179-
handlePagingChange(result.paging);
180-
} catch (error) {
181-
showErrorToast(error as AxiosError);
182-
} finally {
183-
setIsLoading(false);
184-
}
185-
};
160+
const fetchTestSuites = useCallback(
161+
async (
162+
currentPage = INITIAL_PAGING_VALUE,
163+
params?: ListTestSuitePramsBySearch
164+
) => {
165+
const requestId = latestRequestId.current + 1;
166+
latestRequestId.current = requestId;
167+
168+
setIsLoading(true);
169+
try {
170+
const result = await getListTestSuitesBySearch({
171+
...params,
172+
fields: [TabSpecificField.OWNERS, TabSpecificField.SUMMARY],
173+
q: searchValue ? `*${searchValue}*` : undefined,
174+
owner: ownerFilterValue?.key,
175+
offset: (currentPage - 1) * pageSize,
176+
includeEmptyTestSuites: subTab !== DataQualitySubTabs.TABLE_SUITES,
177+
testSuiteType:
178+
subTab === DataQualitySubTabs.TABLE_SUITES
179+
? TestSuiteType.basic
180+
: TestSuiteType.logical,
181+
sortField: 'lastResultTimestamp',
182+
sortType: SORT_ORDER.DESC,
183+
});
184+
if (requestId !== latestRequestId.current) {
185+
return;
186+
}
187+
188+
setTestSuites(result.data);
189+
handlePagingChange(result.paging);
190+
} catch (error) {
191+
if (requestId === latestRequestId.current) {
192+
showErrorToast(error as AxiosError);
193+
}
194+
} finally {
195+
if (requestId === latestRequestId.current) {
196+
setIsLoading(false);
197+
}
198+
}
199+
},
200+
[searchValue, ownerFilterValue?.key, pageSize, subTab, handlePagingChange]
201+
);
186202

187203
const handleTestSuitesPageChange = useCallback(
188204
({ currentPage }: PagingHandlerParams) => {
189205
fetchTestSuites(currentPage, { limit: pageSize });
190206
handlePageChange(currentPage);
191207
},
192-
[pageSize, handlePageChange]
208+
[fetchTestSuites, pageSize, handlePageChange]
193209
);
194210

195211
const handleSearchParam = (
@@ -315,7 +331,15 @@ export const TestSuites = () => {
315331
} else {
316332
setIsLoading(false);
317333
}
318-
}, [testSuitePermission, pageSize, searchValue, owner, subTab, currentPage]);
334+
}, [
335+
testSuitePermission,
336+
pageSize,
337+
searchValue,
338+
owner,
339+
subTab,
340+
currentPage,
341+
fetchTestSuites,
342+
]);
319343

320344
if (!testSuitePermission?.ViewAll && !testSuitePermission?.ViewBasic) {
321345
return (
@@ -417,7 +441,15 @@ export const TestSuites = () => {
417441
</Table.Header>
418442
<Table.Body
419443
items={isLoading ? [] : sortedData}
420-
renderEmptyState={() => (isLoading ? <></> : noDataPlaceholder)}>
444+
renderEmptyState={() =>
445+
isLoading ? (
446+
<div className="tw:flex tw:justify-center tw:p-8">
447+
<Loader />
448+
</div>
449+
) : (
450+
noDataPlaceholder
451+
)
452+
}>
421453
{(record) => renderRow(record as TestSuite)}
422454
</Table.Body>
423455
</Table>

openmetadata-ui/src/main/resources/ui/src/components/DataQuality/TestSuite/TestSuiteList/TestSuites.test.tsx

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@
1111
* limitations under the License.
1212
*/
1313
import { act, fireEvent, render, screen } from '@testing-library/react';
14-
import { MemoryRouter, useNavigate } from 'react-router-dom';
15-
import { DataQualityPageTabs } from '../../../../pages/DataQuality/DataQualityPage.interface';
14+
import { MemoryRouter, useNavigate, useParams } from 'react-router-dom';
15+
import {
16+
DataQualityPageTabs,
17+
DataQualitySubTabs,
18+
} from '../../../../pages/DataQuality/DataQualityPage.interface';
1619
import { getListTestSuitesBySearch } from '../../../../rest/testAPI';
1720
import observabilityRouterClassBase from '../../../../utils/ObservabilityRouterClassBase';
1821
import { TestSuites } from './TestSuites.component';
@@ -140,9 +143,13 @@ jest.mock('@openmetadata/ui-core-components', () => {
140143
dependencies?: unknown[];
141144
}) => (
142145
<tbody>
143-
{items && items.length > 0
144-
? items.map((item) => children(item))
145-
: renderEmptyState?.()}
146+
{items && items.length > 0 ? (
147+
items.map((item) => children(item))
148+
) : (
149+
<tr>
150+
<td colSpan={4}>{renderEmptyState?.()}</td>
151+
</tr>
152+
)}
146153
</tbody>
147154
);
148155

@@ -257,6 +264,10 @@ jest.mock('../../../common/NextPrevious/NextPrevious', () => {
257264
return jest.fn().mockImplementation(() => <div>NextPrevious.component</div>);
258265
});
259266

267+
jest.mock('../../../common/Loader/Loader', () =>
268+
jest.fn().mockImplementation(() => <div data-testid="loader">Loader</div>)
269+
);
270+
260271
jest.mock('../../../../utils/ObservabilityRouterClassBase', () => ({
261272
__esModule: true,
262273
default: {
@@ -351,6 +362,10 @@ describe('TestSuites component', () => {
351362
jest.clearAllMocks();
352363
testSuitePermission.ViewAll = true;
353364
mockLocation.search = '';
365+
(useParams as jest.Mock).mockReturnValue({
366+
tab: 'test-cases',
367+
subTab: 'table-suites',
368+
});
354369
});
355370

356371
it('component should render', async () => {
@@ -528,6 +543,77 @@ describe('TestSuites component', () => {
528543
).toBeInTheDocument();
529544
});
530545

546+
it('should render loader while test suites are loading', async () => {
547+
(getListTestSuitesBySearch as jest.Mock).mockImplementationOnce(
548+
() => new Promise(() => undefined)
549+
);
550+
551+
render(<TestSuites />);
552+
553+
expect(await screen.findByTestId('loader')).toBeInTheDocument();
554+
expect(
555+
screen.queryByTestId('filter-table-placeholder')
556+
).not.toBeInTheDocument();
557+
});
558+
559+
it('should ignore stale table suite response after switching to bundle suites', async () => {
560+
let resolveTableSuites: (value: typeof mockList) => void;
561+
let resolveBundleSuites: (value: typeof mockList) => void;
562+
const tableSuitesResponse = new Promise<typeof mockList>((resolve) => {
563+
resolveTableSuites = resolve;
564+
});
565+
const bundleSuitesResponse = new Promise<typeof mockList>((resolve) => {
566+
resolveBundleSuites = resolve;
567+
});
568+
const bundleSuiteName = 'bundle.suite';
569+
570+
(getListTestSuitesBySearch as jest.Mock)
571+
.mockImplementationOnce(() => tableSuitesResponse)
572+
.mockImplementationOnce(() => bundleSuitesResponse);
573+
574+
const { rerender } = render(<TestSuites />);
575+
576+
expect(await screen.findByTestId('loader')).toBeInTheDocument();
577+
578+
(useParams as jest.Mock).mockReturnValue({
579+
tab: DataQualityPageTabs.TEST_CASES,
580+
subTab: DataQualitySubTabs.BUNDLE_SUITES,
581+
});
582+
583+
rerender(<TestSuites />);
584+
585+
await act(async () => {
586+
resolveBundleSuites({
587+
data: [
588+
{
589+
id: 'bundle-id',
590+
name: bundleSuiteName,
591+
fullyQualifiedName: bundleSuiteName,
592+
basic: false,
593+
},
594+
],
595+
paging: {
596+
offset: 0,
597+
limit: 15,
598+
total: 1,
599+
},
600+
});
601+
});
602+
603+
expect(await screen.findByTestId(bundleSuiteName)).toBeInTheDocument();
604+
605+
await act(async () => {
606+
resolveTableSuites(mockList);
607+
});
608+
609+
expect(screen.getByTestId(bundleSuiteName)).toBeInTheDocument();
610+
expect(
611+
screen.queryByTestId(
612+
'sample_data.ecommerce_db.shopify.dim_address.testSuite'
613+
)
614+
).not.toBeInTheDocument();
615+
});
616+
531617
it('should not render pagination when showPagination is false', async () => {
532618
(getListTestSuitesBySearch as jest.Mock).mockImplementationOnce(() =>
533619
Promise.resolve({ data: [], paging: { total: 5 } })

0 commit comments

Comments
 (0)