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]

Reply via email to