go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/build/legacy/build_page/build_default_tab.test.tsx (about)

     1  // Copyright 2023 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  import { cleanup, render } from '@testing-library/react';
    16  import { applySnapshot, destroy, Instance } from 'mobx-state-tree';
    17  import type { NavigateFunction } from 'react-router-dom';
    18  import * as reactRouterDom from 'react-router-dom';
    19  
    20  import { Store, StoreProvider } from '@/common/store';
    21  
    22  import { BuildDefaultTab } from './build_default_tab';
    23  import { BuildPageTab } from './common';
    24  
    25  jest.mock('react-router-dom', () => {
    26    return createSelectiveMockFromModule<typeof import('react-router-dom')>(
    27      'react-router-dom',
    28      ['useNavigate'],
    29    );
    30  });
    31  
    32  describe('BuildDefaultTab', () => {
    33    let store: Instance<typeof Store>;
    34    let useNavigateSpy: jest.MockedFunction<
    35      () => jest.MockedFunction<NavigateFunction>
    36    >;
    37  
    38    beforeEach(() => {
    39      jest.useFakeTimers();
    40      const navigateSpies = new Map<
    41        NavigateFunction,
    42        jest.MockedFunction<NavigateFunction>
    43      >();
    44      useNavigateSpy = jest
    45        .mocked(
    46          // We will return a mocked `navigate` function so we can intercept the
    47          // `navigate` calls.
    48          reactRouterDom.useNavigate as () => jest.MockedFunction<NavigateFunction>,
    49        )
    50        .mockImplementation(() => {
    51          const navigate = (
    52            jest.requireActual('react-router-dom') as typeof reactRouterDom
    53          ).useNavigate();
    54          // Return the same mock reference if the reference to `navigate` is the
    55          // same. This is to ensure the dependency checks having the same result.
    56          const navigateSpy =
    57            navigateSpies.get(navigate) ||
    58            (jest.fn(
    59              navigate,
    60              // `jest.fn` isn't smart enough to infer the function type when
    61              // mocking an overloaded function. Use manual casting instead.
    62            ) as unknown as jest.MockedFunction<NavigateFunction>);
    63          navigateSpies.set(navigate, navigateSpy);
    64          return navigateSpy;
    65        });
    66      store = Store.create({ userConfig: { build: { defaultTab: 'overview' } } });
    67    });
    68  
    69    afterEach(() => {
    70      cleanup();
    71      destroy(store);
    72      jest.useRealTimers();
    73      useNavigateSpy.mockRestore();
    74    });
    75  
    76    test('should redirect to the default tab', async () => {
    77      const router = reactRouterDom.createMemoryRouter(
    78        [
    79          {
    80            path: 'path/prefix',
    81            children: [
    82              { index: true, element: <BuildDefaultTab /> },
    83              { path: 'overview', element: <></> },
    84            ],
    85          },
    86        ],
    87        { initialEntries: ['/path/prefix?param#hash'] },
    88      );
    89  
    90      render(
    91        <StoreProvider value={store}>
    92          <reactRouterDom.RouterProvider router={router} />
    93        </StoreProvider>,
    94      );
    95  
    96      expect(useNavigateSpy).toHaveBeenCalledTimes(1);
    97      const useNavigateSpyResult = useNavigateSpy.mock.results[0];
    98      expect(useNavigateSpyResult.type).toEqual('return');
    99      // Won't happen. Useful for type inference.
   100      if (useNavigateSpyResult.type !== 'return') {
   101        throw new Error('unreachable');
   102      }
   103      const navigateSpy = useNavigateSpyResult.value;
   104      expect(navigateSpy).toHaveBeenCalledTimes(1);
   105      expect(navigateSpy.mock.calls[0]).toMatchObject([
   106        // The type definition for `.toMatchObject` is incomplete. Cast to any to
   107        // make TSC happy.
   108        // eslint-disable-next-line @typescript-eslint/no-explicit-any
   109        '/path/prefix/overview?param#hash' as any,
   110        { replace: true },
   111      ]);
   112    });
   113  
   114    test("should work with '/' suffix", async () => {
   115      const router = reactRouterDom.createMemoryRouter(
   116        [
   117          {
   118            path: 'path/prefix',
   119            children: [
   120              { index: true, element: <BuildDefaultTab /> },
   121              { path: 'overview', element: <></> },
   122            ],
   123          },
   124        ],
   125        { initialEntries: ['/path/prefix/?param#hash'] },
   126      );
   127  
   128      render(
   129        <StoreProvider value={store}>
   130          <reactRouterDom.RouterProvider router={router} />
   131        </StoreProvider>,
   132      );
   133  
   134      expect(useNavigateSpy).toHaveBeenCalledTimes(1);
   135      const useNavigateSpyResult = useNavigateSpy.mock.results[0];
   136      expect(useNavigateSpyResult.type).toEqual('return');
   137      // Won't happen. Useful for type inference.
   138      if (useNavigateSpyResult.type !== 'return') {
   139        throw new Error('unreachable');
   140      }
   141      const navigateSpy = useNavigateSpyResult.value;
   142      expect(navigateSpy).toHaveBeenCalledTimes(1);
   143      expect(navigateSpy.mock.calls[0]).toMatchObject([
   144        // The type definition for `.toMatchObject` is incomplete. Cast to any to
   145        // make TSC happy.
   146        // eslint-disable-next-line @typescript-eslint/no-explicit-any
   147        '/path/prefix/overview?param#hash' as any,
   148        { replace: true },
   149      ]);
   150    });
   151  
   152    test('should redirect properly when the saved default tab is valid', async () => {
   153      // This can happen when we changed/added/removed tab identifiers.
   154      applySnapshot(store, {
   155        userConfig: { build: { defaultTab: BuildPageTab.Steps } },
   156      });
   157  
   158      const router = reactRouterDom.createMemoryRouter(
   159        [
   160          {
   161            path: 'path/prefix',
   162            children: [
   163              { index: true, element: <BuildDefaultTab /> },
   164              { path: BuildPageTab.Overview, element: <></> },
   165              { path: BuildPageTab.Steps, element: <></> },
   166            ],
   167          },
   168        ],
   169        { initialEntries: ['/path/prefix?param#hash'] },
   170      );
   171  
   172      render(
   173        <StoreProvider value={store}>
   174          <reactRouterDom.RouterProvider router={router} />
   175        </StoreProvider>,
   176      );
   177  
   178      expect(useNavigateSpy).toHaveBeenCalledTimes(1);
   179      const useNavigateSpyResult = useNavigateSpy.mock.results[0];
   180      expect(useNavigateSpyResult.type).toEqual('return');
   181      // Won't happen. Useful for type inference.
   182      if (useNavigateSpyResult.type !== 'return') {
   183        throw new Error('unreachable');
   184      }
   185      const navigateSpy = useNavigateSpyResult.value;
   186      expect(navigateSpy).toHaveBeenCalledTimes(1);
   187      expect(navigateSpy.mock.calls[0]).toMatchObject([
   188        // The type definition for `.toMatchObject` is incomplete. Cast to any to
   189        // make TSC happy.
   190        // eslint-disable-next-line @typescript-eslint/no-explicit-any
   191        '/path/prefix/steps?param#hash' as any,
   192        { replace: true },
   193      ]);
   194    });
   195  
   196    test('should redirect properly when the saved default tab is invalid', async () => {
   197      // This can happen when we changed/added/removed tab identifiers.
   198      applySnapshot(store, {
   199        userConfig: { build: { defaultTab: 'invalid-tab' } },
   200      });
   201  
   202      const router = reactRouterDom.createMemoryRouter(
   203        [
   204          {
   205            path: 'path/prefix',
   206            children: [
   207              { index: true, element: <BuildDefaultTab /> },
   208              { path: BuildPageTab.Overview, element: <></> },
   209              { path: BuildPageTab.Steps, element: <></> },
   210            ],
   211          },
   212        ],
   213        { initialEntries: ['/path/prefix?param#hash'] },
   214      );
   215  
   216      render(
   217        <StoreProvider value={store}>
   218          <reactRouterDom.RouterProvider router={router} />
   219        </StoreProvider>,
   220      );
   221  
   222      expect(useNavigateSpy).toHaveBeenCalledTimes(1);
   223      const useNavigateSpyResult = useNavigateSpy.mock.results[0];
   224      expect(useNavigateSpyResult.type).toEqual('return');
   225      // Won't happen. Useful for type inference.
   226      if (useNavigateSpyResult.type !== 'return') {
   227        throw new Error('unreachable');
   228      }
   229      const navigateSpy = useNavigateSpyResult.value;
   230      expect(navigateSpy).toHaveBeenCalledTimes(1);
   231      expect(navigateSpy.mock.calls[0]).toMatchObject([
   232        // The type definition for `.toMatchObject` is incomplete. Cast to any to
   233        // make TSC happy.
   234        // eslint-disable-next-line @typescript-eslint/no-explicit-any
   235        '/path/prefix/overview?param#hash' as any,
   236        { replace: true },
   237      ]);
   238    });
   239  });