This is an automated email from the ASF dual-hosted git repository.
chihsuan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git
The following commit(s) were added to refs/heads/master by this push:
new c3fa499ed7c HDDS-9793. [Recon][UI][Test] Add tests for Containers page
(#11417)
c3fa499ed7c is described below
commit c3fa499ed7c968b661b06bd1d3f63d3b33b1f04a
Author: sravani <[email protected]>
AuthorDate: Mon Oct 12 15:24:02 2026 +0530
HDDS-9793. [Recon][UI][Test] Add tests for Containers page (#11417)
---
.../src/__tests__/containers/Containers.test.tsx | 259 +++++++++++++++++++++
.../src/__tests__/locators/locators.ts | 5 +
.../mocks/containerMocks/containerResponseMocks.ts | 132 +++++++++++
.../mocks/containerMocks/containersServer.ts | 39 ++++
.../src/__tests__/utils/containers.utils.tsx | 25 ++
.../src/v2/components/tables/containersTable.tsx | 5 +-
6 files changed, 464 insertions(+), 1 deletion(-)
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/containers/Containers.test.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/containers/Containers.test.tsx
new file mode 100644
index 00000000000..9b6538341f1
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/containers/Containers.test.tsx
@@ -0,0 +1,259 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { vi } from 'vitest';
+
+import Containers from '@/v2/pages/containers/containers';
+import * as apiData from '@/v2/hooks/useAPIData.hook';
+import * as commonUtils from '@/utils/common';
+import { ClusterState } from
'@tests/mocks/overviewMocks/overviewResponseMocks';
+import { containersServer } from
'@tests/mocks/containerMocks/containersServer';
+import * as containerMocks from
'@tests/mocks/containerMocks/containerResponseMocks';
+import { containerLocators, searchInputLocator } from
'@tests/locators/locators';
+import { waitForContainerRows } from '@tests/utils/containers.utils';
+
+vi.spyOn(commonUtils, 'showDataFetchError');
+
+vi.mock('@/components/autoReloadPanel/autoReloadPanel', () => ({
+ default: () => <div data-testid="auto-reload-panel" />,
+}));
+vi.mock('@/v2/components/select/multiSelect.tsx', () => ({
+ default: ({ onChange }: { onChange: Function }) => (
+ <select data-testid="multi-select" onChange={(e) =>
onChange(e.target.value)}>
+ <option value="containerID">Container ID</option>
+ <option value="pipelineID">Pipeline ID</option>
+ </select>
+ ),
+}));
+
+vi.mock('@/v2/hooks/useAPIData.hook', async (importOriginal) => {
+ const actual = await importOriginal<typeof
import('@/v2/hooks/useAPIData.hook')>();
+ return {
+ ...actual,
+ useApiData: <T,>(url: string, defaultValue: T, options = {}) => {
+ // Containers fetches clusterState with initialFetch: true and again via
useAutoReload on mount;
+ // the second call aborts the first, which surfaces as unhandled
CanceledError in tests.
+ if (url === '/api/v1/clusterState') {
+ return {
+ data: ClusterState as T,
+ loading: false,
+ error: null,
+ lastUpdated: Date.now(),
+ success: true,
+ execute: vi.fn(),
+ refetch: vi.fn(() => Promise.resolve({ data: ClusterState })),
+ clearError: vi.fn(),
+ reset: vi.fn(),
+ };
+ }
+ return actual.useApiData(url, defaultValue, {
+ ...options,
+ retryAttempts: 0,
+ retryDelay: 0,
+ });
+ },
+ };
+});
+
+function getEnabledSearchInput() {
+ const inputs = screen.getAllByTestId(searchInputLocator);
+ return inputs.find((el) => !(el as HTMLInputElement).disabled) ?? inputs[0];
+}
+
+function highlightValue(label: string) {
+ return screen.getByText(label, { selector: '.highlight-content' })
+ .querySelector('.highlight-content-value');
+}
+
+async function waitForLoadingToFinish(container: HTMLElement) {
+ await waitFor(() => {
+
expect(container.querySelector('.ant-spin-spinning')).not.toBeInTheDocument();
+ });
+}
+
+describe('Containers Component', () => {
+ beforeEach(() => {
+ vi.mocked(commonUtils.showDataFetchError).mockClear();
+ });
+ beforeAll(() => containersServer.listen());
+ afterEach(() => containersServer.resetHandlers());
+ afterAll(() => containersServer.close());
+
+ test('renders component correctly', async () => {
+ render(<Containers />);
+
+ expect(screen.getByText('Containers')).toBeInTheDocument();
+ expect(screen.getByTestId('auto-reload-panel')).toBeInTheDocument();
+ expect(screen.getByTestId('multi-select')).toBeInTheDocument();
+ expect(screen.getByText('Highlights')).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: 'Missing' })).toBeInTheDocument();
+
+ await waitForContainerRows();
+ expect(getEnabledSearchInput()).toBeInTheDocument();
+ });
+
+ test('Loads data on mount', async () => {
+ render(<Containers />);
+
+ const rows = await waitForContainerRows();
+ expect(rows[0]).toHaveTextContent('101');
+
expect(screen.getByTestId(containerLocators.containerTableRow(102))).toBeInTheDocument();
+ });
+
+ test('loads missing containers on mount and updates highlights', async () =>
{
+ render(<Containers />);
+
+ await waitForContainerRows();
+
+ await waitFor(() =>
+ expect(highlightValue('Total Containers')).toHaveTextContent(new
RegExp(`^${ClusterState.containers}$`)));
+ expect(highlightValue('Missing')).toHaveTextContent(/^25$/);
+ await waitFor(() => expect(highlightValue('Quasi
Closed')).toHaveTextContent(/^2$/));
+ });
+
+ test('Renders table with correct number of rows', async () => {
+ render(<Containers />);
+
+ const rows = await waitForContainerRows();
+ expect(rows).toHaveLength(3);
+ });
+
+ test('Displays no data message if the missing containers API returns an
empty array', async () => {
+ containersServer.use(
+ http.get('/api/v1/containers/unhealthy/MISSING', () =>
+ HttpResponse.json(containerMocks.EmptyUnhealthyResponse))
+ );
+
+ const { container } = render(<Containers />);
+
+ await waitForLoadingToFinish(container);
+ await waitFor(() => {
+
expect(screen.queryAllByTestId(containerLocators.containerRowRegex)).toHaveLength(0);
+ expect(screen.getByText('No Data')).toBeInTheDocument();
+ });
+ });
+
+ test('loads the next page of missing containers when Next is clicked', async
() => {
+ containersServer.use(
+ http.get('/api/v1/containers/unhealthy/MISSING', ({ request }) => {
+ const url = new URL(request.url);
+ const minContainerId = Number(url.searchParams.get('minContainerId')
?? 0);
+ const limit = Number(url.searchParams.get('limit') ?? 11);
+ return HttpResponse.json(
+ containerMocks.paginatedMissingResponse(minContainerId, limit));
+ })
+ );
+
+ render(<Containers />);
+
+ await waitFor(() =>
+
expect(screen.getAllByTestId(containerLocators.containerRowRegex)).toHaveLength(10));
+
expect(screen.getByTestId(containerLocators.containerTableRow(101))).toBeInTheDocument();
+
expect(screen.getByTestId(containerLocators.containerTableRow(110))).toBeInTheDocument();
+
expect(screen.queryByTestId(containerLocators.containerTableRow(111))).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', { name: /Next/i }));
+
+ await waitFor(() =>
+
expect(screen.getByTestId(containerLocators.containerTableRow(111))).toBeInTheDocument());
+
expect(screen.queryByTestId(containerLocators.containerTableRow(101))).not.toBeInTheDocument();
+ });
+
+ test.each([
+ { tab: 'Under-Replicated', endpoint:
'/api/v1/containers/unhealthy/UNDER_REPLICATED', id: 201 },
+ { tab: 'Over-Replicated', endpoint:
'/api/v1/containers/unhealthy/OVER_REPLICATED', id: 301 },
+ { tab: 'Quasi Closed', endpoint: '/api/v1/containers/quasiClosed', id: 401
},
+ ])('lazy-loads $tab once on first activation', async ({ tab, endpoint, id })
=> {
+ const requests = vi.spyOn(apiData, 'fetchData');
+ const tableRequests = () => requests.mock.calls.filter(
+ ([url, , params]) => url === endpoint && params?.limit !== 0
+ );
+
+ try {
+ render(<Containers />);
+ await waitForContainerRows();
+ expect(tableRequests()).toHaveLength(0);
+
+ await userEvent.click(screen.getByRole('tab', { name: tab }));
+ await waitFor(() =>
+
expect(screen.getByTestId(containerLocators.containerTableRow(id))).toBeInTheDocument());
+ expect(tableRequests()).toHaveLength(1);
+
+ await userEvent.click(screen.getByRole('tab', { name: 'Missing' }));
+ await userEvent.click(screen.getByRole('tab', { name: tab }));
+ expect(tableRequests()).toHaveLength(1);
+ } finally {
+ requests.mockRestore();
+ }
+ });
+
+ test('Handles search input change', async () => {
+ render(<Containers />);
+ await waitForContainerRows();
+
+ fireEvent.change(getEnabledSearchInput(), { target: { value: '101' } });
+
+ await waitFor(() =>
+
expect(screen.getAllByTestId(containerLocators.containerRowRegex)).toHaveLength(1));
+ });
+
+ test('Displays a message when no results match the search term', async () =>
{
+ render(<Containers />);
+ await waitForContainerRows();
+
+ fireEvent.change(getEnabledSearchInput(), { target: { value: '999999' } });
+ await waitFor(() => expect(screen.getByText('No
Data')).toBeInTheDocument());
+ });
+
+ test('Handles API errors gracefully by showing error message', async () => {
+ containersServer.use(
+ http.get('/api/v1/containers/unhealthy/MISSING', () =>
+ HttpResponse.json({ error: 'Internal Server Error' }, { status: 500 }))
+ );
+
+ render(<Containers />);
+
+ await waitFor(() =>
+ expect(commonUtils.showDataFetchError).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'Request failed with status code
500' })
+ )
+ );
+ });
+
+ test('Handles API errors when loading under-replicated tab', async () => {
+ containersServer.use(
+ http.get('/api/v1/containers/unhealthy/UNDER_REPLICATED', () =>
+ HttpResponse.json({ error: 'Internal Server Error' }, { status: 500 }))
+ );
+
+ render(<Containers />);
+ await waitForContainerRows();
+
+ await userEvent.click(screen.getByRole('tab', { name: 'Under-Replicated'
}));
+
+ await waitFor(() =>
+ expect(commonUtils.showDataFetchError).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'Request failed with status code
500' })
+ )
+ );
+ });
+});
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/locators/locators.ts
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/locators/locators.ts
index 017c9a5b7a9..cb6781428ed 100644
---
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/locators/locators.ts
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/locators/locators.ts
@@ -50,6 +50,11 @@ export const pipelineLocators = {
pipelineTableRow: (uuid: string) => `pipelinetable-${uuid}`
}
+export const containerLocators = {
+ containerRowRegex: /containerstable-/,
+ containerTableRow: (containerId: number) => `containerstable-${containerId}`,
+}
+
export const autoReloadPanelLocators = {
'autoreloadPanel': 'autoreload-panel',
'refreshButton': 'autoreload-panel-refresh',
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/containerMocks/containerResponseMocks.ts
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/containerMocks/containerResponseMocks.ts
new file mode 100644
index 00000000000..1d656bfdcca
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/containerMocks/containerResponseMocks.ts
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+function missingContainer(containerID: number) {
+ return {
+ containerID,
+ containerState: 'MISSING',
+ unhealthySince: 1665590446222,
+ expectedReplicaCount: 3,
+ actualReplicaCount: 0,
+ replicaDeltaCount: 3,
+ reason: '',
+ keys: 4,
+ pipelineID: 'a10ffab6-8ed5-414a-aaf5-79890ff3e8a1',
+ replicas: [],
+ };
+}
+
+export const MissingContainersResponse = {
+ missingCount: 25,
+ underReplicatedCount: 15,
+ overReplicatedCount: 12,
+ misReplicatedCount: 13,
+ replicaMismatchCount: 11,
+ firstKey: 101,
+ lastKey: 103,
+ containers: [missingContainer(101), missingContainer(102),
missingContainer(103)],
+};
+
+export const EmptyUnhealthyResponse = {
+ missingCount: 0,
+ underReplicatedCount: 0,
+ overReplicatedCount: 0,
+ misReplicatedCount: 0,
+ replicaMismatchCount: 0,
+ firstKey: 0,
+ lastKey: 0,
+ containers: [],
+};
+
+export const UnderReplicatedContainersResponse = {
+ missingCount: 25,
+ underReplicatedCount: 15,
+ overReplicatedCount: 12,
+ misReplicatedCount: 13,
+ replicaMismatchCount: 11,
+ firstKey: 201,
+ lastKey: 201,
+ containers: [{
+ containerID: 201,
+ containerState: 'UNDER_REPLICATED',
+ unhealthySince: 1665591000000,
+ expectedReplicaCount: 3,
+ actualReplicaCount: 2,
+ replicaDeltaCount: -1,
+ reason: '',
+ keys: 8,
+ pipelineID: 'b20ggbc7-9fe6-525b-bbg6-80901gg4fb12',
+ replicas: [],
+ }],
+};
+
+export const OverReplicatedContainersResponse = {
+ missingCount: 25,
+ underReplicatedCount: 15,
+ overReplicatedCount: 12,
+ misReplicatedCount: 13,
+ replicaMismatchCount: 11,
+ firstKey: 301,
+ lastKey: 301,
+ containers: [{
+ containerID: 301,
+ containerState: 'OVER_REPLICATED',
+ unhealthySince: 1665591000000,
+ expectedReplicaCount: 3,
+ actualReplicaCount: 4,
+ replicaDeltaCount: 1,
+ reason: '',
+ keys: 1,
+ pipelineID: 'c30hhcd8-0gf7-636c-cch7-91012hh5gc23',
+ replicas: [],
+ }],
+};
+
+export const QuasiClosedResponse = {
+ quasiClosedCount: 2,
+ firstKey: 401,
+ lastKey: 401,
+ containers: [{
+ containerID: 401,
+ pipelineID: 'a10ffab6-8ed5-414a-aaf5-79890ff3e8a1',
+ keys: 3,
+ stateEnterTime: 1665590446222,
+ expectedReplicaCount: 3,
+ actualReplicaCount: 3,
+ replicas: [],
+ }],
+};
+
+/** IDs 101–125; honors minContainerId + limit (page uses limit = pageSize +
1). */
+export function paginatedMissingResponse(minContainerId: number, limit:
number) {
+ const allIds = Array.from({ length: 25 }, (_, i) => 101 + i);
+ const ids = allIds.filter((id) => id > minContainerId).slice(0, limit);
+ const containers = ids.map((id) => missingContainer(id));
+ return {
+ missingCount: 25,
+ underReplicatedCount: 15,
+ overReplicatedCount: 12,
+ misReplicatedCount: 13,
+ replicaMismatchCount: 11,
+ firstKey: ids[0] ?? 0,
+ lastKey: ids[ids.length - 1] ?? 0,
+ containers,
+ };
+}
+
+export const ExportJobsResponse: [] = [];
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/containerMocks/containersServer.ts
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/containerMocks/containersServer.ts
new file mode 100644
index 00000000000..0635665b8bb
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/containerMocks/containersServer.ts
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { setupServer } from 'msw/node';
+import { http, HttpResponse } from 'msw';
+
+import { ClusterState } from
'@tests/mocks/overviewMocks/overviewResponseMocks';
+import * as mockResponses from './containerResponseMocks';
+
+const handlers = [
+ http.get('/api/v1/clusterState', () => HttpResponse.json(ClusterState)),
+ http.get('/api/v1/containers/unhealthy/MISSING', () =>
+ HttpResponse.json(mockResponses.MissingContainersResponse)),
+ http.get('/api/v1/containers/unhealthy/UNDER_REPLICATED', () =>
+ HttpResponse.json(mockResponses.UnderReplicatedContainersResponse)),
+ http.get('/api/v1/containers/unhealthy/OVER_REPLICATED', () =>
+ HttpResponse.json(mockResponses.OverReplicatedContainersResponse)),
+ http.get('/api/v1/containers/quasiClosed', () =>
+ HttpResponse.json(mockResponses.QuasiClosedResponse)),
+ http.get('/api/v1/containers/unhealthy/export', () =>
+ HttpResponse.json(mockResponses.ExportJobsResponse)),
+];
+
+export const containersServer = setupServer(...handlers);
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/utils/containers.utils.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/utils/containers.utils.tsx
new file mode 100644
index 00000000000..63b5f07fc84
--- /dev/null
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/utils/containers.utils.tsx
@@ -0,0 +1,25 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { screen, waitFor } from '@testing-library/react';
+
+import { containerLocators } from '@tests/locators/locators';
+
+export const waitForContainerRows = async () => {
+ return waitFor(() =>
screen.getAllByTestId(containerLocators.containerRowRegex));
+};
diff --git
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/tables/containersTable.tsx
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/tables/containersTable.tsx
index 92b521259be..0ef909537aa 100644
---
a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/tables/containersTable.tsx
+++
b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/v2/components/tables/containersTable.tsx
@@ -16,7 +16,7 @@
* limitations under the License.
*/
-import React from 'react';
+import React, { HTMLAttributes } from 'react';
import filesize from 'filesize';
import { Button, Popover, Select, Space, Table } from 'antd';
@@ -285,6 +285,9 @@ const ContainerTable: React.FC<ContainerTableProps> = ({
pagination={false}
scroll={{ x: 'max-content', scrollToFirstRowOnChange: true }}
locale={{ filterTitle: '' }}
+ onRow={(record: Container) => ({
+ 'data-testid': `containerstable-${record.containerID}`,
+ } as HTMLAttributes<HTMLElement>)}
expandable={{
expandRowByClick: true,
expandedRowRender: expandedRowRender,
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]