Skip to content

Commit 973fafd

Browse files
chore(frontend): Add unit tests for MLMD detail pages (0% coverage on ArtifactDetails and ExecutionDetails) #13042 (#13045)
* test(frontend): add unit tests for ArtifactDetails and ExecutionDetails MLMD pages. Fixes #13042 Signed-off-by: Kanishka Panwar <kanishka03p@gmail.com> * test(frontend): increase MLMD detail page test coverage and fix weak assertions Signed-off-by: Kanishka Panwar <kanishka03p@gmail.com> * fix(frontend): normalize undefined to null in ExecutionReference useQuery Signed-off-by: Kanishka Panwar <kanishka03p@gmail.com> * test(frontend): silence unmounted-component warning in ExecutionDetails tests Signed-off-by: Kanishka Panwar <kanishka03p@gmail.com> --------- Signed-off-by: Kanishka Panwar <kanishka03p@gmail.com>
1 parent 5c79d3d commit 973fafd

File tree

3 files changed

+954
-3
lines changed

3 files changed

+954
-3
lines changed
Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
/*
2+
* Copyright 2026 The Kubeflow Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
18+
import { createMemoryHistory } from 'history';
19+
import { MemoryRouter, Route, Router } from 'react-router-dom';
20+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
21+
import { vi, Mock } from 'vitest';
22+
import { Api } from 'src/mlmd/library';
23+
import { Artifact, ArtifactType, GetArtifactsByIDResponse, Value } from 'src/third_party/mlmd';
24+
import { GetArtifactTypesByIDResponse } from 'src/third_party/mlmd/generated/ml_metadata/proto/metadata_store_service_pb';
25+
import { RoutePage, RouteParams } from 'src/components/Router';
26+
import { testBestPractices } from 'src/TestUtils';
27+
import EnhancedArtifactDetails from 'src/pages/ArtifactDetails';
28+
import { PageProps } from 'src/pages/Page';
29+
30+
vi.mock('src/mlmd/LineageView', () => ({
31+
LineageView: () => <div data-testid='lineage-view'>LineageView Mock</div>,
32+
}));
33+
34+
testBestPractices();
35+
36+
describe('ArtifactDetails', () => {
37+
let updateBannerSpy: Mock;
38+
let updateToolbarSpy: Mock;
39+
let historyPushSpy: Mock;
40+
let getArtifactsByIDSpy: Mock;
41+
let getArtifactTypesByIDSpy: Mock;
42+
43+
const TEST_ARTIFACT_ID = 42;
44+
45+
function buildArtifact(id = TEST_ARTIFACT_ID, name = 'test-artifact'): Artifact {
46+
const artifact = new Artifact();
47+
artifact.setId(id);
48+
artifact.setTypeId(7);
49+
const nameValue = new Value();
50+
nameValue.setStringValue(name);
51+
artifact.getPropertiesMap().set('name', nameValue);
52+
return artifact;
53+
}
54+
55+
function buildArtifactType(): ArtifactType {
56+
const artifactType = new ArtifactType();
57+
artifactType.setId(7);
58+
artifactType.setName('system/Dataset');
59+
return artifactType;
60+
}
61+
62+
function buildGetArtifactsByIDResponse(artifacts: Artifact[]): GetArtifactsByIDResponse {
63+
const response = new GetArtifactsByIDResponse();
64+
response.setArtifactsList(artifacts);
65+
return response;
66+
}
67+
68+
function buildGetArtifactTypesByIDResponse(types: ArtifactType[]): GetArtifactTypesByIDResponse {
69+
const response = new GetArtifactTypesByIDResponse();
70+
response.setArtifactTypesList(types);
71+
return response;
72+
}
73+
74+
function generateProps(artifactId = TEST_ARTIFACT_ID): PageProps {
75+
const match = {
76+
isExact: true,
77+
path: RoutePage.ARTIFACT_DETAILS,
78+
url: `/artifacts/${artifactId}`,
79+
params: { [RouteParams.ID]: String(artifactId) },
80+
} as any;
81+
return {
82+
history: { push: historyPushSpy } as any,
83+
location: { pathname: `/artifacts/${artifactId}` } as any,
84+
match,
85+
toolbarProps: {
86+
actions: {},
87+
breadcrumbs: [{ displayName: 'Artifacts', href: RoutePage.ARTIFACTS }],
88+
pageTitle: `Artifact #${artifactId}`,
89+
},
90+
updateBanner: updateBannerSpy,
91+
updateDialog: vi.fn(),
92+
updateSnackbar: vi.fn(),
93+
updateToolbar: updateToolbarSpy,
94+
} as any;
95+
}
96+
97+
function renderWithRouter(props: PageProps, initialPath?: string) {
98+
const path = initialPath || `/artifacts/${props.match.params[RouteParams.ID]}`;
99+
const queryClient = new QueryClient({
100+
defaultOptions: { queries: { retry: false } },
101+
});
102+
return render(
103+
<QueryClientProvider client={queryClient}>
104+
<MemoryRouter initialEntries={[path]}>
105+
<Route path={['/artifacts/:id/lineage', '/artifacts/:id']}>
106+
{(routeProps: any) => (
107+
<EnhancedArtifactDetails
108+
{...routeProps}
109+
{...props}
110+
match={{ ...routeProps.match, ...props.match }}
111+
/>
112+
)}
113+
</Route>
114+
</MemoryRouter>
115+
</QueryClientProvider>,
116+
);
117+
}
118+
119+
beforeEach(() => {
120+
updateBannerSpy = vi.fn();
121+
updateToolbarSpy = vi.fn();
122+
historyPushSpy = vi.fn();
123+
getArtifactsByIDSpy = vi.spyOn(Api.getInstance().metadataStoreService, 'getArtifactsByID');
124+
getArtifactTypesByIDSpy = vi.spyOn(
125+
Api.getInstance().metadataStoreService,
126+
'getArtifactTypesByID',
127+
);
128+
});
129+
130+
function mockSuccessfulLoad(artifact?: Artifact) {
131+
const a = artifact || buildArtifact();
132+
getArtifactsByIDSpy.mockResolvedValue(buildGetArtifactsByIDResponse([a]));
133+
getArtifactTypesByIDSpy.mockResolvedValue(
134+
buildGetArtifactTypesByIDResponse([buildArtifactType()]),
135+
);
136+
}
137+
138+
it('shows CircularProgress spinner while artifact is loading', () => {
139+
getArtifactsByIDSpy.mockReturnValue(new Promise(() => {}));
140+
141+
renderWithRouter(generateProps());
142+
143+
expect(screen.getByRole('progressbar')).toBeInTheDocument();
144+
});
145+
146+
it('renders Overview tab with ResourceInfo after artifact loads', async () => {
147+
mockSuccessfulLoad();
148+
149+
renderWithRouter(generateProps());
150+
151+
await waitFor(() => {
152+
expect(screen.getByText('Overview')).toBeInTheDocument();
153+
});
154+
expect(screen.getByText('Lineage Explorer')).toBeInTheDocument();
155+
});
156+
157+
it('updates toolbar title with artifact name after load', async () => {
158+
mockSuccessfulLoad();
159+
160+
renderWithRouter(generateProps());
161+
162+
await waitFor(() => {
163+
expect(updateToolbarSpy).toHaveBeenCalledWith(
164+
expect.objectContaining({
165+
pageTitle: expect.stringContaining('test-artifact'),
166+
}),
167+
);
168+
});
169+
});
170+
171+
it('shows page error banner when no artifact found for the given ID', async () => {
172+
getArtifactsByIDSpy.mockResolvedValue(buildGetArtifactsByIDResponse([]));
173+
174+
renderWithRouter(generateProps());
175+
176+
await waitFor(() => {
177+
expect(updateBannerSpy).toHaveBeenCalledWith(
178+
expect.objectContaining({
179+
message: expect.stringContaining(`No artifact identified by id: ${TEST_ARTIFACT_ID}`),
180+
mode: 'error',
181+
}),
182+
);
183+
});
184+
});
185+
186+
it('shows page error banner when multiple artifacts found for the given ID', async () => {
187+
getArtifactsByIDSpy.mockResolvedValue(
188+
buildGetArtifactsByIDResponse([buildArtifact(), buildArtifact()]),
189+
);
190+
191+
renderWithRouter(generateProps());
192+
193+
await waitFor(() => {
194+
expect(updateBannerSpy).toHaveBeenCalledWith(
195+
expect.objectContaining({
196+
message: expect.stringContaining(`Found multiple artifacts with ID: ${TEST_ARTIFACT_ID}`),
197+
mode: 'error',
198+
}),
199+
);
200+
});
201+
});
202+
203+
it('shows page error banner on service error', async () => {
204+
getArtifactsByIDSpy.mockRejectedValue({ message: 'Service unavailable' });
205+
206+
renderWithRouter(generateProps());
207+
208+
await waitFor(() => {
209+
expect(updateBannerSpy).toHaveBeenCalledWith(
210+
expect.objectContaining({
211+
message: expect.stringContaining('Service unavailable'),
212+
mode: 'error',
213+
}),
214+
);
215+
});
216+
});
217+
218+
it('shows fallback error message when a non-service error with no message is thrown', async () => {
219+
getArtifactsByIDSpy.mockRejectedValue(undefined);
220+
221+
renderWithRouter(generateProps());
222+
223+
await waitFor(() => {
224+
expect(updateBannerSpy).toHaveBeenCalledWith(
225+
expect.objectContaining({
226+
message: 'Error: failed to load artifact.',
227+
mode: 'error',
228+
}),
229+
);
230+
});
231+
});
232+
233+
it('shows extracted error message when a non-service error with text() is thrown', async () => {
234+
getArtifactsByIDSpy.mockRejectedValue({ text: () => 'detailed failure info' });
235+
236+
renderWithRouter(generateProps());
237+
238+
await waitFor(() => {
239+
expect(updateBannerSpy).toHaveBeenCalledWith(
240+
expect.objectContaining({
241+
message: 'Error: detailed failure info',
242+
mode: 'error',
243+
}),
244+
);
245+
});
246+
});
247+
248+
it('renders a new ArtifactDetails instance when artifact ID in URL changes', async () => {
249+
getArtifactsByIDSpy
250+
.mockResolvedValueOnce(buildGetArtifactsByIDResponse([buildArtifact(1, 'artifact-one')]))
251+
.mockResolvedValueOnce(buildGetArtifactsByIDResponse([buildArtifact(2, 'artifact-two')]));
252+
getArtifactTypesByIDSpy.mockResolvedValue(
253+
buildGetArtifactTypesByIDResponse([buildArtifactType()]),
254+
);
255+
256+
const history = createMemoryHistory({ initialEntries: ['/artifacts/1'] });
257+
const queryClient = new QueryClient({
258+
defaultOptions: { queries: { retry: false } },
259+
});
260+
261+
render(
262+
<QueryClientProvider client={queryClient}>
263+
<Router history={history}>
264+
<Route path={['/artifacts/:id/lineage', '/artifacts/:id']}>
265+
{(routeProps: any) => (
266+
<EnhancedArtifactDetails
267+
{...routeProps}
268+
updateBanner={updateBannerSpy}
269+
updateDialog={vi.fn()}
270+
updateSnackbar={vi.fn()}
271+
updateToolbar={updateToolbarSpy}
272+
toolbarProps={{
273+
actions: {},
274+
breadcrumbs: [{ displayName: 'Artifacts', href: RoutePage.ARTIFACTS }],
275+
pageTitle: '',
276+
}}
277+
/>
278+
)}
279+
</Route>
280+
</Router>
281+
</QueryClientProvider>,
282+
);
283+
284+
await waitFor(() => {
285+
expect(updateToolbarSpy).toHaveBeenCalledWith(
286+
expect.objectContaining({ pageTitle: expect.stringContaining('artifact-one') }),
287+
);
288+
});
289+
290+
updateToolbarSpy.mockClear();
291+
history.push('/artifacts/2');
292+
293+
await waitFor(() => {
294+
expect(updateToolbarSpy).toHaveBeenCalledWith(
295+
expect.objectContaining({ pageTitle: expect.stringContaining('artifact-two') }),
296+
);
297+
});
298+
});
299+
300+
it('renders with empty type name when artifact type list is empty', async () => {
301+
getArtifactsByIDSpy.mockResolvedValue(buildGetArtifactsByIDResponse([buildArtifact()]));
302+
getArtifactTypesByIDSpy.mockResolvedValue(buildGetArtifactTypesByIDResponse([]));
303+
304+
renderWithRouter(generateProps());
305+
306+
await waitFor(() => {
307+
expect(screen.getByText('Overview')).toBeInTheDocument();
308+
});
309+
expect(screen.queryByText('Dataset')).not.toBeInTheDocument();
310+
});
311+
312+
it('includes version in toolbar title when artifact has a version property', async () => {
313+
const artifact = buildArtifact();
314+
const versionValue = new Value();
315+
versionValue.setStringValue('v1.2');
316+
artifact.getPropertiesMap().set('version', versionValue);
317+
318+
mockSuccessfulLoad(artifact);
319+
320+
renderWithRouter(generateProps());
321+
322+
await waitFor(() => {
323+
expect(updateToolbarSpy).toHaveBeenCalledWith(
324+
expect.objectContaining({
325+
pageTitle: expect.stringContaining('(version: v1.2)'),
326+
}),
327+
);
328+
});
329+
});
330+
331+
it('navigates to lineage URL when Lineage Explorer tab is clicked', async () => {
332+
mockSuccessfulLoad();
333+
334+
renderWithRouter(generateProps());
335+
336+
await waitFor(() => {
337+
expect(screen.getByText('Overview')).toBeInTheDocument();
338+
});
339+
340+
fireEvent.click(screen.getByText('Lineage Explorer'));
341+
342+
expect(historyPushSpy).toHaveBeenCalledWith(expect.stringContaining('/lineage'));
343+
});
344+
345+
it('navigates back to overview URL when Overview tab is clicked', async () => {
346+
mockSuccessfulLoad();
347+
348+
renderWithRouter(generateProps(), `/artifacts/${TEST_ARTIFACT_ID}/lineage`);
349+
350+
await waitFor(() => {
351+
expect(screen.getByText('Overview')).toBeInTheDocument();
352+
});
353+
354+
fireEvent.click(screen.getByText('Overview'));
355+
356+
expect(historyPushSpy).toHaveBeenCalledWith(expect.stringMatching(/\/artifacts\/42$/));
357+
});
358+
});

0 commit comments

Comments
 (0)