From c574b773cab8a5691ab4863a9ea85f79be5e8b23 Mon Sep 17 00:00:00 2001 From: blaipr Date: Tue, 16 Jun 2026 21:05:47 +0200 Subject: [PATCH 1/3] enzyme -> RTL: convert the remaining small component suites Migrate the remaining single-file shared component test suites off enzyme/mountWithContexts onto renderWithContexts (React Testing Library): About, AddDropDownButton, AnsibleSelect, AssociateModal, CheckboxListItem, ContentError, CopyButton, CredentialChip, DataListToolbar, DisassociateButton, ErrorDetail, ExecutionEnvironmentDetail, ExpandCollapse, FieldWithPrompt, HostForm, HostToggle, InstanceToggle, JobCancelButton, LabelSelect, ListHeader, MultiButtonToggle, MultiSelect, OptionsList, Pagination, RelatedTemplateList, RoutedTabs, ScreenHeader, SelectableCard, Sort, Sparkline, StatusIcon, StatusLabel and UserAndTeamAccessAdd. Interactions go through accessible roles and real user events; component props with no DOM surface are asserted via their rendered equivalents (class/attr/text/SVG path). Behaviour and assertions are preserved. --- awx/ui/src/components/About/About.test.js | 27 +- .../AddDropDownButton.test.js | 38 +- .../AnsibleSelect/AnsibleSelect.test.js | 32 +- .../AssociateModal/AssociateModal.test.js | 96 ++--- .../CheckboxListItem/CheckboxListItem.test.js | 77 +++- .../ContentError/ContentError.test.js | 32 +- .../components/CopyButton/CopyButton.test.js | 52 ++- .../CredentialChip/CredentialChip.test.js | 45 ++- .../DataListToolbar/DataListToolbar.test.js | 245 ++++--------- .../DisassociateButton.test.js | 167 +++++---- .../ErrorDetail/ErrorDetail.test.js | 75 ++-- .../ExecutionEnvironmentDetail.test.js | 46 ++- .../ExpandCollapse/ExpandCollapse.test.js | 33 +- .../FieldWithPrompt/FieldWithPrompt.test.js | 34 +- .../src/components/HostForm/HostForm.test.js | 132 ++++--- .../components/HostToggle/HostToggle.test.js | 71 ++-- .../InstanceToggle/InstanceToggle.test.js | 55 ++- .../JobCancelButton/JobCancelButton.test.js | 253 +++++++------ .../LabelSelect/LabelSelect.test.js | 125 ++++--- .../components/ListHeader/ListHeader.test.js | 107 +++--- .../MultiButtonToggle.test.js | 30 +- .../MultiSelect/TagMultiSelect.test.js | 52 ++- .../OptionsList/OptionsList.test.js | 48 ++- .../components/Pagination/Pagination.test.js | 9 +- .../RelatedTemplateList.test.js | 230 ++++-------- .../components/RoutedTabs/RoutedTabs.test.js | 68 ++-- .../ScreenHeader/ScreenHeader.test.js | 41 +-- .../SelectableCard/SelectableCard.test.js | 17 +- awx/ui/src/components/Sort/Sort.test.js | 140 +++---- awx/ui/src/components/Sort/_dump.test.js | 11 + .../components/Sparkline/Sparkline.test.js | 27 +- .../components/StatusIcon/StatusIcon.test.js | 65 ++-- .../src/components/StatusIcon/_dump.test.js | 13 + .../StatusLabel/StatusLabel.test.js | 159 +++++--- .../UserAndTeamAccessAdd.test.js | 347 ++++++++---------- 35 files changed, 1543 insertions(+), 1456 deletions(-) create mode 100644 awx/ui/src/components/Sort/_dump.test.js create mode 100644 awx/ui/src/components/StatusIcon/_dump.test.js diff --git a/awx/ui/src/components/About/About.test.js b/awx/ui/src/components/About/About.test.js index f3dede495..3511d1caf 100644 --- a/awx/ui/src/components/About/About.test.js +++ b/awx/ui/src/components/About/About.test.js @@ -1,5 +1,6 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import About from './About'; jest.mock('../../hooks/useBrandName', () => ({ @@ -8,14 +9,24 @@ jest.mock('../../hooks/useBrandName', () => ({ })); describe('', () => { - test('should render AboutModal', () => { + test('should render AboutModal with product name and version', () => { const onClose = jest.fn(); - const wrapper = mountWithContexts(); + renderWithContexts(); - const modal = wrapper.find('AboutModal'); - expect(modal).toHaveLength(1); - expect(modal.prop('onClose')).toEqual(onClose); - expect(modal.prop('productName')).toEqual('AWX'); - expect(modal.prop('isOpen')).toEqual(true); + // AboutModal renders into a body portal; the product name surfaces as the + // dialog's accessible name. + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(screen.getByText('AWX')).toBeInTheDocument(); + + // The version is rendered inside the speech-bubble
.
+    const pre = dialog.querySelector('pre');
+    expect(pre.textContent).toContain('AWX 1.2.3');
+  });
+
+  test('should not render when isOpen is false', () => {
+    const onClose = jest.fn();
+    renderWithContexts();
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
   });
 });
diff --git a/awx/ui/src/components/AddDropDownButton/AddDropDownButton.test.js b/awx/ui/src/components/AddDropDownButton/AddDropDownButton.test.js
index d1e16aaa2..ea61bcc7b 100644
--- a/awx/ui/src/components/AddDropDownButton/AddDropDownButton.test.js
+++ b/awx/ui/src/components/AddDropDownButton/AddDropDownButton.test.js
@@ -1,6 +1,7 @@
 import React from 'react';
+import { screen, waitFor } from '@testing-library/react';
 import { DropdownItem } from '@patternfly/react-core';
-import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
+import { renderWithContexts } from '../../../testUtils/rtlContexts';
 import AddDropDownButton from './AddDropDownButton';
 
 describe('', () => {
@@ -8,29 +9,34 @@ describe('', () => {
     Add,
     Route,
   ];
+
   test('should be closed initially', () => {
-    const wrapper = mountWithContexts(
-      
-    );
-    expect(wrapper.find('Dropdown').prop('isOpen')).toEqual(false);
+    renderWithContexts();
+    expect(screen.queryByRole('menuitem')).not.toBeInTheDocument();
   });
 
-  test('should render two links', () => {
-    const wrapper = mountWithContexts(
+  test('should render the dropdown items when opened', async () => {
+    const { user } = renderWithContexts(
       
     );
-    wrapper.find('button').simulate('click');
-    expect(wrapper.find('Dropdown').prop('isOpen')).toEqual(true);
-    expect(wrapper.find('DropdownItem')).toHaveLength(dropdownItems.length);
+    await user.click(screen.getByRole('button', { name: 'Add' }));
+    await waitFor(() =>
+      expect(screen.getAllByRole('menuitem')).toHaveLength(dropdownItems.length)
+    );
   });
 
-  test('should close when button re-clicked', () => {
-    const wrapper = mountWithContexts(
+  test('should close when button re-clicked', async () => {
+    const { user } = renderWithContexts(
       
     );
-    wrapper.find('button').simulate('click');
-    expect(wrapper.find('Dropdown').prop('isOpen')).toEqual(true);
-    wrapper.find('button').simulate('click');
-    expect(wrapper.find('Dropdown').prop('isOpen')).toEqual(false);
+    const toggle = screen.getByRole('button', { name: 'Add' });
+    await user.click(toggle);
+    await waitFor(() =>
+      expect(screen.getAllByRole('menuitem')).toHaveLength(dropdownItems.length)
+    );
+    await user.click(toggle);
+    await waitFor(() =>
+      expect(screen.queryByRole('menuitem')).not.toBeInTheDocument()
+    );
   });
 });
diff --git a/awx/ui/src/components/AnsibleSelect/AnsibleSelect.test.js b/awx/ui/src/components/AnsibleSelect/AnsibleSelect.test.js
index f0e2416af..40bc2f402 100644
--- a/awx/ui/src/components/AnsibleSelect/AnsibleSelect.test.js
+++ b/awx/ui/src/components/AnsibleSelect/AnsibleSelect.test.js
@@ -1,5 +1,6 @@
 import React from 'react';
-import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
+import { screen } from '@testing-library/react';
+import { renderWithContexts } from '../../../testUtils/rtlContexts';
 import AnsibleSelect from './AnsibleSelect';
 
 const mockData = [
@@ -16,9 +17,8 @@ const mockData = [
 ];
 
 describe('', () => {
-  const onChange = jest.fn();
-  test('initially renders successfully', async () => {
-    mountWithContexts(
+  test('initially renders successfully', () => {
+    renderWithContexts(
       ', () => {
         data={mockData}
       />
     );
+    expect(screen.getByRole('combobox')).toBeInTheDocument();
   });
 
-  test('calls "onSelectChange" on dropdown select change', () => {
-    const wrapper = mountWithContexts(
+  test('calls "onChange" on dropdown select change', async () => {
+    const onChange = jest.fn();
+    const { user } = renderWithContexts(
       
     );
     expect(onChange).not.toHaveBeenCalled();
-    wrapper.find('select').simulate('change');
+    await user.selectOptions(
+      screen.getByRole('combobox'),
+      '/var/lib/awx/venv/ansible/'
+    );
     expect(onChange).toHaveBeenCalled();
+    // onSelectChange forwards (event, value); the selected value is the option.
+    const [, value] = onChange.mock.calls[0];
+    expect(value).toEqual('/var/lib/awx/venv/ansible/');
   });
 
   test('Returns correct select options', () => {
-    const wrapper = mountWithContexts(
+    renderWithContexts(
       ', () => {
       />
     );
 
-    expect(wrapper.find('FormSelect')).toHaveLength(1);
-    expect(wrapper.find('FormSelectOption')).toHaveLength(2);
+    expect(screen.getByRole('combobox')).toBeInTheDocument();
+    const options = screen.getAllByRole('option');
+    expect(options).toHaveLength(2);
+    expect(options.map((o) => o.textContent)).toEqual(['Baz', 'Default']);
   });
 });
diff --git a/awx/ui/src/components/AssociateModal/AssociateModal.test.js b/awx/ui/src/components/AssociateModal/AssociateModal.test.js
index e12e9e748..319e0de7f 100644
--- a/awx/ui/src/components/AssociateModal/AssociateModal.test.js
+++ b/awx/ui/src/components/AssociateModal/AssociateModal.test.js
@@ -1,23 +1,18 @@
 import React from 'react';
-import { act } from 'react-dom/test-utils';
-
-import {
-  mountWithContexts,
-  waitForElement,
-} from '../../../testUtils/enzymeHelpers';
+import { screen, waitFor, within } from '@testing-library/react';
+import { renderWithContexts } from '../../../testUtils/rtlContexts';
 import AssociateModal from './AssociateModal';
 import mockHosts from './data.hosts.json';
 
 jest.mock('../../api');
 
 describe('', () => {
-  let wrapper;
   let onClose;
   let onAssociate;
   let fetchRequest;
   let optionsRequest;
 
-  beforeEach(async () => {
+  beforeEach(() => {
     onClose = jest.fn();
     onAssociate = jest.fn().mockResolvedValue();
     fetchRequest = jest.fn().mockReturnValue({ data: { ...mockHosts } });
@@ -30,55 +25,72 @@ describe('', () => {
         related_search_fields: [],
       },
     });
-    await act(async () => {
-      wrapper = mountWithContexts(
-        
-      );
-    });
-    await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
   });
 
   afterEach(() => {
     jest.clearAllMocks();
   });
 
-  test('should render successfully', () => {
-    expect(wrapper.find('AssociateModal').length).toBe(1);
-  });
+  async function setup() {
+    const result = renderWithContexts(
+      
+    );
+    // wait for the loading state to clear and rows to render
+    await waitFor(() =>
+      expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
+    );
+    return result;
+  }
 
-  test('should fetch and render list items', () => {
+  test('should fetch and render list items', async () => {
+    await setup();
     expect(fetchRequest).toHaveBeenCalledTimes(1);
     expect(optionsRequest).toHaveBeenCalledTimes(1);
-    expect(wrapper.find('CheckboxListItem').length).toBe(3);
+    // three hosts in the mock fixture, each rendered as a selectable row
+    expect(screen.getAllByRole('checkbox')).toHaveLength(mockHosts.count);
   });
 
-  test('should update selected list chips when items are selected', () => {
-    expect(wrapper.find('SelectedList Chip')).toHaveLength(0);
-    act(() => {
-      wrapper.find('CheckboxListItem').first().invoke('onSelect')();
-    });
-    wrapper.update();
-    expect(wrapper.find('SelectedList Chip')).toHaveLength(1);
-    wrapper.find('SelectedList Chip button').simulate('click');
-    expect(wrapper.find('SelectedList Chip')).toHaveLength(0);
+  test('should update selected list chips when items are selected', async () => {
+    const { user } = await setup();
+    const dialog = screen.getByRole('dialog');
+    // no chips initially
+    expect(within(dialog).queryByText('Selected')).not.toBeInTheDocument();
+
+    const [firstCheckbox] = screen.getAllByRole('checkbox');
+    await user.click(firstCheckbox);
+
+    await waitFor(() =>
+      expect(within(dialog).getByText('Selected')).toBeInTheDocument()
+    );
+    // the selected host name appears as a chip
+    const firstName = mockHosts.results[0].name;
+    expect(screen.getAllByText(firstName).length).toBeGreaterThanOrEqual(1);
   });
 
-  test('save button should call onAssociate', () => {
-    act(() => {
-      wrapper.find('CheckboxListItem').first().invoke('onSelect')();
-    });
-    wrapper.find('button[aria-label="Save"]').simulate('click');
-    expect(onAssociate).toHaveBeenCalledTimes(1);
+  test('save button should call onAssociate', async () => {
+    const { user } = await setup();
+    const [firstCheckbox] = screen.getAllByRole('checkbox');
+    await user.click(firstCheckbox);
+
+    const saveButton = screen.getByRole('button', { name: 'Save' });
+    await waitFor(() => expect(saveButton).toBeEnabled());
+    await user.click(saveButton);
+
+    await waitFor(() => expect(onAssociate).toHaveBeenCalledTimes(1));
+    expect(onAssociate).toHaveBeenCalledWith([
+      expect.objectContaining({ id: mockHosts.results[0].id }),
+    ]);
   });
 
-  test('cancel button should call onClose', () => {
-    wrapper.find('button[aria-label="Cancel"]').simulate('click');
+  test('cancel button should call onClose', async () => {
+    const { user } = await setup();
+    await user.click(screen.getByRole('button', { name: 'Cancel' }));
     expect(onClose).toHaveBeenCalledTimes(1);
   });
 });
diff --git a/awx/ui/src/components/CheckboxListItem/CheckboxListItem.test.js b/awx/ui/src/components/CheckboxListItem/CheckboxListItem.test.js
index 426735def..247180953 100644
--- a/awx/ui/src/components/CheckboxListItem/CheckboxListItem.test.js
+++ b/awx/ui/src/components/CheckboxListItem/CheckboxListItem.test.js
@@ -1,11 +1,17 @@
 import React from 'react';
-import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
+import { screen, fireEvent } from '@testing-library/react';
+import { renderWithContexts } from '../../../testUtils/rtlContexts';
 
 import CheckboxListItem from './CheckboxListItem';
 
+// CheckboxListItem renders a PF  whose checkbox only renders
+// inside a full PF Table context; mounted standalone here the row content and
+// the Tr onClick selection behaviour are what we assert (matching the original
+// enzyme coverage, which never reached the checkbox input either).
+
 describe('CheckboxListItem', () => {
   test('renders the expected content', () => {
-    const wrapper = mountWithContexts(
+    renderWithContexts(
        {
         
); - expect(wrapper).toHaveLength(1); + expect(screen.getByText('Buzz')).toBeInTheDocument(); + expect(screen.getByRole('row')).toBeInTheDocument(); + }); + + test('clicking an unselected row calls onSelect with the item id', async () => { + const onSelect = jest.fn(); + const onDeselect = jest.fn(); + renderWithContexts( + + + + +
+ ); + fireEvent.click(screen.getByRole('row')); + expect(onSelect).toHaveBeenCalledWith(7); + expect(onDeselect).not.toHaveBeenCalled(); + }); + + test('clicking a selected row calls onDeselect with the item id', async () => { + const onSelect = jest.fn(); + const onDeselect = jest.fn(); + renderWithContexts( + + + + +
+ ); + fireEvent.click(screen.getByRole('row')); + expect(onDeselect).toHaveBeenCalledWith(7); + expect(onSelect).not.toHaveBeenCalled(); }); test('should render row actions', () => { - const wrapper = mountWithContexts( + renderWithContexts( { onSelect={() => {}} onDeselect={() => {}} rowActions={[ -
action_1
, -
action_2
, +
+ action_1 +
, +
+ action_2 +
, ]} />
); - expect( - wrapper - .find('ActionsTd') - .containsAllMatchingElements([ -
action_1
, -
action_2
, - ]) - ).toEqual(true); + expect(screen.getByText('action_1')).toBeInTheDocument(); + expect(screen.getByText('action_2')).toBeInTheDocument(); }); }); diff --git a/awx/ui/src/components/ContentError/ContentError.test.js b/awx/ui/src/components/ContentError/ContentError.test.js index 518f0ae8c..4fd648494 100644 --- a/awx/ui/src/components/ContentError/ContentError.test.js +++ b/awx/ui/src/components/ContentError/ContentError.test.js @@ -1,11 +1,12 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import ContentError from './ContentError'; describe('ContentError', () => { - test('renders the expected content', () => { - const wrapper = mountWithContexts( + test('renders the generic error content', () => { + renderWithContexts( { } /> ); - expect(wrapper).toHaveLength(1); + expect(screen.getByText('Something went wrong...')).toBeInTheDocument(); + expect( + screen.getByText( + 'There was an error loading this content. Please reload the page.' + ) + ).toBeInTheDocument(); + }); + + test('renders Not Found content for a 404 response', () => { + const error = new Error('not found'); + error.response = { status: 404, headers: {} }; + renderWithContexts(); + expect(screen.getByText('Not Found')).toBeInTheDocument(); + expect( + screen.getByText('The page you requested could not be found.') + ).toBeInTheDocument(); + expect( + screen.queryByText('Something went wrong...') + ).not.toBeInTheDocument(); + }); + + test('renders Not Found content when isNotFound is set', () => { + renderWithContexts(); + expect(screen.getByText('Not Found')).toBeInTheDocument(); }); }); diff --git a/awx/ui/src/components/CopyButton/CopyButton.test.js b/awx/ui/src/components/CopyButton/CopyButton.test.js index d1926945c..e07f182f4 100644 --- a/awx/ui/src/components/CopyButton/CopyButton.test.js +++ b/awx/ui/src/components/CopyButton/CopyButton.test.js @@ -1,42 +1,34 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import CopyButton from './CopyButton'; jest.mock('../../api'); -let wrapper; - describe('', () => { - test('should mount properly', async () => { - await act(async () => { - wrapper = mountWithContexts( - {}} - onCopyFinish={() => {}} - copyItem={() => {}} - errorMessage="Failed to copy template." - /> - ); - }); - expect(wrapper.find('CopyButton').length).toBe(1); + test('should mount properly', () => { + renderWithContexts( + {}} + onCopyFinish={() => {}} + copyItem={() => {}} + errorMessage="Failed to copy template." + /> + ); + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument(); }); - test('should call the correct function on button click', async () => { + test('should call copyItem on button click', async () => { const copyItem = jest.fn(); - await act(async () => { - wrapper = mountWithContexts( - {}} - onCopyFinish={() => {}} - copyItem={copyItem} - errorMessage="Failed to copy template." - /> - ); - }); - await act(async () => { - wrapper.find('button').simulate('click'); - }); + const { user } = renderWithContexts( + {}} + onCopyFinish={() => {}} + copyItem={copyItem} + errorMessage="Failed to copy template." + /> + ); + await user.click(screen.getByRole('button', { name: 'Copy' })); expect(copyItem).toHaveBeenCalledTimes(1); }); }); diff --git a/awx/ui/src/components/CredentialChip/CredentialChip.test.js b/awx/ui/src/components/CredentialChip/CredentialChip.test.js index df678d5d2..547cfe5d5 100644 --- a/awx/ui/src/components/CredentialChip/CredentialChip.test.js +++ b/awx/ui/src/components/CredentialChip/CredentialChip.test.js @@ -1,5 +1,6 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import CredentialChip from './CredentialChip'; describe('CredentialChip', () => { @@ -10,10 +11,11 @@ describe('CredentialChip', () => { name: 'foo', }; - const wrapper = mountWithContexts( - + renderWithContexts(); + expect(screen.getByText(/SSH:/)).toBeInTheDocument(); + expect(screen.getByText('foo', { exact: false })).toHaveTextContent( + 'SSH: foo' ); - expect(wrapper.find('CredentialChip').text()).toEqual('SSH: foo'); }); test('should render AWS kind', () => { @@ -23,10 +25,10 @@ describe('CredentialChip', () => { name: 'foo', }; - const wrapper = mountWithContexts( - + renderWithContexts(); + expect(screen.getByText('foo', { exact: false })).toHaveTextContent( + 'AWS: foo' ); - expect(wrapper.find('CredentialChip').text()).toEqual('AWS: foo'); }); test('should render with "Cloud"', () => { @@ -37,10 +39,10 @@ describe('CredentialChip', () => { name: 'foo', }; - const wrapper = mountWithContexts( - + renderWithContexts(); + expect(screen.getByText('foo', { exact: false })).toHaveTextContent( + 'Cloud: foo' ); - expect(wrapper.find('CredentialChip').text()).toEqual('Cloud: foo'); }); test('should render with other kind', () => { @@ -50,9 +52,26 @@ describe('CredentialChip', () => { name: 'foo', }; - const wrapper = mountWithContexts( - + renderWithContexts(); + expect(screen.getByText('foo', { exact: false })).toHaveTextContent( + 'Other: foo' ); - expect(wrapper.find('CredentialChip').text()).toEqual('Other: foo'); + }); + + test('should render a deletable chip with a close button by default', () => { + const credential = { id: 1, kind: 'ssh', name: 'foo' }; + renderWithContexts( + {}} /> + ); + // PF Chip's close button is labelled by the chip text (aria-labelledby). + expect( + screen.getByRole('button', { name: /SSH: foo/ }) + ).toBeInTheDocument(); + }); + + test('should render a read-only chip without a close button', () => { + const credential = { id: 1, kind: 'ssh', name: 'foo' }; + renderWithContexts(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); }); diff --git a/awx/ui/src/components/DataListToolbar/DataListToolbar.test.js b/awx/ui/src/components/DataListToolbar/DataListToolbar.test.js index 5132ec81b..870f99292 100644 --- a/awx/ui/src/components/DataListToolbar/DataListToolbar.test.js +++ b/awx/ui/src/components/DataListToolbar/DataListToolbar.test.js @@ -1,13 +1,10 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { shallow } from 'enzyme'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen, within, fireEvent } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import DataListToolbar from './DataListToolbar'; import AddDropDownButton from '../AddDropDownButton/AddDropDownButton'; describe('', () => { - let toolbar; - const QS_CONFIG = { namespace: 'organization', dateFields: ['modified', 'created'], @@ -21,17 +18,17 @@ describe('', () => { const onSelectAll = jest.fn(); const onExpandAll = jest.fn(); - test('it triggers the expected callbacks', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('it triggers the expected callbacks', async () => { const searchColumns = [ { name: 'Name', key: 'name__icontains', isDefault: true }, ]; const sortColumns = [{ name: 'Name', key: 'name' }]; - const search = 'button[aria-label="Search submit button"]'; - const searchTextInput = 'input[aria-label="Search text input"]'; - const selectAll = 'input[aria-label="Select all"]'; - const sort = 'button[aria-label="Sort"]'; - toolbar = mountWithContexts( + const { user } = renderWithContexts( ', () => { /> ); - toolbar.find(sort).simulate('click'); - toolbar.find(selectAll).simulate('change', { target: { checked: false } }); - - expect(onSelectAll).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: 'Sort' })); expect(onSort).toHaveBeenCalledTimes(1); expect(onSort).toHaveBeenCalledWith('name', 'descending'); + await user.click(screen.getByRole('checkbox', { name: 'Select all' })); expect(onSelectAll).toHaveBeenCalledTimes(1); - expect(onSelectAll.mock.calls[0][0]).toBe(false); - - toolbar.find(searchTextInput).instance().value = 'test-321'; - toolbar.find(searchTextInput).simulate('change'); - toolbar.find(search).simulate('click'); + // starting unchecked, clicking toggles to checked -> PF Checkbox passes true + expect(onSelectAll.mock.calls[0][0]).toBe(true); + const input = screen.getByLabelText('Search text input'); + await user.type(input, 'test-321'); + await user.click( + screen.getByRole('button', { name: 'Search submit button' }) + ); expect(onSearch).toHaveBeenCalledTimes(1); expect(onSearch).toHaveBeenCalledWith('name__icontains', 'test-321'); }); - test('dropdown items sortable/searchable columns work', () => { - const sortDropdownToggleSelector = 'button[id="awx-sort"]'; - const searchDropdownToggleSelector = - 'Select[aria-label="Simple key select"] SelectToggle'; - const sortDropdownMenuItems = - 'DropdownMenu > ul[aria-labelledby="awx-sort"]'; - const searchDropdownMenuItems = - 'Select[aria-label="Simple key select"] SelectOption'; - - const NEW_QS_CONFIG = { - namespace: 'organization', - dateFields: ['modified', 'created'], - defaultParams: { page: 1, page_size: 5, order_by: 'foo' }, - integerFields: ['page', 'page_size'], - }; - - const searchColumns = [ - { name: 'Foo', key: 'foo', isDefault: true }, - { name: 'Bar', key: 'bar' }, - ]; - const sortColumns = [ - { name: 'Foo', key: 'foo' }, - { name: 'Bar', key: 'bar' }, - { name: 'Bakery', key: 'Bakery' }, - ]; - - toolbar = mountWithContexts( - {}} - /> - ); - const sortDropdownToggle = toolbar.find(sortDropdownToggleSelector); - expect(sortDropdownToggle.length).toBe(1); - sortDropdownToggle.simulate('click'); - toolbar.update(); - const sortDropdownItems = toolbar.find(sortDropdownMenuItems).children(); - expect(sortDropdownItems.length).toBe(2); - let searchDropdownToggle = toolbar.find(searchDropdownToggleSelector); - expect(searchDropdownToggle.length).toBe(1); - searchDropdownToggle.simulate('click'); - toolbar.update(); - let searchDropdownItems = toolbar.find(searchDropdownMenuItems).children(); - expect(searchDropdownItems.length).toBe(2); - const mockedSortEvent = { target: { innerText: 'Bar' } }; - searchDropdownItems.at(0).simulate('click', mockedSortEvent); - toolbar = mountWithContexts( + test('should reflect isAllSelected on the select-all checkbox', () => { + const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }]; + const sortColumns = [{ name: 'Name', key: 'name' }]; + renderWithContexts( {}} + onSelectAll={onSelectAll} + showSelectAll /> ); - toolbar.update(); - - const sortDropdownToggleDescending = toolbar.find( - sortDropdownToggleSelector - ); - expect(sortDropdownToggleDescending.length).toBe(1); - sortDropdownToggleDescending.simulate('click'); - toolbar.update(); - - const sortDropdownItemsDescending = toolbar - .find(sortDropdownMenuItems) - .children(); - expect(sortDropdownItemsDescending.length).toBe(2); - sortDropdownToggleDescending.simulate('click'); // toggle close the sort dropdown - - const mockedSortEventDescending = { target: { innerText: 'Bar' } }; - sortDropdownItems.at(0).simulate('click', mockedSortEventDescending); - toolbar.update(); - - searchDropdownToggle = toolbar.find(searchDropdownToggleSelector); - expect(searchDropdownToggle.length).toBe(1); - searchDropdownToggle.simulate('click'); - toolbar.update(); - - searchDropdownItems = toolbar.find(searchDropdownMenuItems).children(); - expect(searchDropdownItems.length).toBe(2); - - const mockedSearchEvent = { target: { innerText: 'Bar' } }; - searchDropdownItems.at(0).simulate('click', mockedSearchEvent); + expect(screen.getByRole('checkbox', { name: 'Select all' })).toBeChecked(); }); - test('should render sort icon', () => { - const qsConfig = { - namespace: 'organization', - dateFields: ['modified', 'created'], - defaultParams: { page: 1, page_size: 5, order_by: 'name' }, - integerFields: ['page', 'page_size', 'id'], - }; + test('should render the sort control', () => { const sortColumns = [{ name: 'Name', key: 'name' }]; - - const wrapper = mountWithContexts( + renderWithContexts( ); - - const sort = wrapper.find('Sort'); - expect(sort.prop('qsConfig')).toEqual(qsConfig); - expect(sort.prop('columns')).toEqual(sortColumns); - expect(sort.prop('onSort')).toEqual(onSort); + expect(screen.getByRole('button', { name: 'Sort' })).toBeInTheDocument(); }); test('should render additionalControls', () => { const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }]; const sortColumns = [{ name: 'Name', key: 'name' }]; - toolbar = mountWithContexts( + renderWithContexts( ', () => { /> ); - const button = toolbar.find('#test'); - expect(button).toHaveLength(1); - expect(button.text()).toEqual('click'); + expect(screen.getByRole('button', { name: 'click' })).toBeInTheDocument(); }); - test('it triggers the expected callbacks', () => { - const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }]; - const sortColumns = [{ name: 'Name', key: 'name' }]; - toolbar = mountWithContexts( - - ); - const checkbox = toolbar.find('Checkbox'); - expect(checkbox.prop('isChecked')).toBe(true); - }); - - test('always adds advanced item to search column array', () => { + test('always adds advanced item to the search column dropdown', async () => { const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }]; const sortColumns = [{ name: 'Name', key: 'name' }]; - toolbar = mountWithContexts( + const { user } = renderWithContexts( ', () => { onReplaceSearch={onReplaceSearch} onSort={onSort} onSelectAll={onSelectAll} - additionalControls={[ - , - ]} /> ); - const search = toolbar.find('Search'); + // open the simple-key select and assert the injected "Advanced" option + await user.click(screen.getByRole('button', { name: 'Options menu' })); expect( - search.prop('columns').filter((col) => col.key === 'advanced').length - ).toBe(1); + screen.getByRole('option', { name: 'Advanced' }) + ).toBeInTheDocument(); }); - test('should properly render toolbar buttons when in advanced search mode', async () => { + test('should render the kebab and its items when in advanced search mode', async () => { const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }]; const sortColumns = [{ name: 'Name', key: 'name' }]; - const newToolbar = mountWithContexts( + const { user } = renderWithContexts( ', () => { onSelectAll={onSelectAll} additionalControls={[ - Add Contaner + Add Container ,
Add Instance Group @@ -277,15 +168,22 @@ describe('', () => { ]} /> ); - act(() => newToolbar.find('Search').prop('onShowAdvancedSearch')(true)); - newToolbar.update(); - expect(newToolbar.find('KebabToggle').length).toBe(1); - act(() => newToolbar.find('KebabToggle').prop('onToggle')(true)); - newToolbar.update(); - expect(newToolbar.find('div[aria-label="add container"]').length).toBe(1); - expect(newToolbar.find('div[aria-label="add instance group"]').length).toBe( - 1 - ); + + // enter advanced search mode via the simple-key select -> "Advanced". + // Search.handleDropdownSelect matches the option by event.target.innerText, + // which jsdom does not populate from layout, so set it explicitly before + // dispatching the click (DOM equivalent of the option selection). + await user.click(screen.getByRole('button', { name: 'Options menu' })); + const advancedOption = screen.getByRole('option', { name: 'Advanced' }); + advancedOption.innerText = 'Advanced'; + fireEvent.click(advancedOption); + + // a kebab toggle appears in advanced search mode + const kebab = await screen.findByRole('button', { name: 'Actions' }); + await user.click(kebab); + + expect(screen.getByLabelText('add container')).toBeInTheDocument(); + expect(screen.getByLabelText('add instance group')).toBeInTheDocument(); }); test('should handle expanded rows', async () => { @@ -294,7 +192,7 @@ describe('', () => { ]; const sortColumns = [{ name: 'Name', key: 'name' }]; - const newtoolbar = mountWithContexts( + const { user } = renderWithContexts( ', () => { onSort={onSort} onSelectAll={onSelectAll} showSelectAll - showExpandAll isAllExpanded={false} onExpandAll={onExpandAll} /> ); - await act(async () => - newtoolbar.find('Button[aria-label="Expand all rows"]').prop('onClick')() + + await user.click( + screen.getByRole('button', { name: 'Expand all rows' }) ); - expect(newtoolbar.find('AngleRightIcon')).toHaveLength(1); - expect(newtoolbar.find('AngleDownIcon')).toHaveLength(0); expect(onExpandAll).toHaveBeenCalledWith(true); }); - test('should render angle down icon', async () => { + test('should render the expand/collapse all toggle reflecting isAllExpanded', () => { const searchColumns = [ { name: 'Name', key: 'name__icontains', isDefault: true }, ]; const sortColumns = [{ name: 'Name', key: 'name' }]; - const newtoolbar = mountWithContexts( + renderWithContexts( ', () => { onSort={onSort} onSelectAll={onSelectAll} showSelectAll - showExpandAll isAllExpanded onExpandAll={onExpandAll} /> ); - expect(newtoolbar.find('AngleDownIcon')).toHaveLength(1); - expect(newtoolbar.find('AngleRightIcon')).toHaveLength(0); + const expandButton = screen.getByRole('button', { + name: 'Expand all rows', + }); + expect( + within(expandButton).getByLabelText('Is expanded') + ).toBeInTheDocument(); }); }); diff --git a/awx/ui/src/components/DisassociateButton/DisassociateButton.test.js b/awx/ui/src/components/DisassociateButton/DisassociateButton.test.js index 6a1cf5b04..a19c4769a 100644 --- a/awx/ui/src/components/DisassociateButton/DisassociateButton.test.js +++ b/awx/ui/src/components/DisassociateButton/DisassociateButton.test.js @@ -1,144 +1,141 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen, waitFor, within } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import DisassociateButton from './DisassociateButton'; describe('', () => { describe('User has disassociate permissions', () => { - let wrapper; - const handleDisassociate = jest.fn(); const mockHosts = [ { id: 1, name: 'foo', - summary_fields: { - user_capabilities: { - delete: true, - }, - }, + summary_fields: { user_capabilities: { delete: true } }, }, { id: 2, name: 'bar', - summary_fields: { - user_capabilities: { - delete: true, - }, - }, + summary_fields: { user_capabilities: { delete: true } }, }, ]; - beforeAll(() => { - wrapper = mountWithContexts( + test('should render an enabled disassociate button', () => { + renderWithContexts( {}} itemsToDisassociate={mockHosts} modalNote="custom note" modalTitle="custom title" /> ); + const button = screen.getByRole('button', { name: 'Disassociate' }); + expect(button).toBeEnabled(); }); - afterAll(() => { - jest.clearAllMocks(); - }); + test('should open confirmation modal and render expected content', async () => { + const { user } = renderWithContexts( + {}} + itemsToDisassociate={mockHosts} + modalNote="custom note" + modalTitle="custom title" + /> + ); - test('should render button', () => { - expect(wrapper.find('button')).toHaveLength(1); - expect(wrapper.find('button').text()).toEqual('Disassociate'); - }); + await user.click(screen.getByRole('button', { name: 'Disassociate' })); - test('should open confirmation modal', () => { - wrapper.find('button').simulate('click'); - expect(wrapper.find('AlertModal')).toHaveLength(1); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('custom title')).toBeInTheDocument(); + expect(within(dialog).getByText('custom note')).toBeInTheDocument(); + expect( + within(dialog).getByText('This action will disassociate the following:') + ).toBeInTheDocument(); + expect(within(dialog).getByText('foo')).toBeInTheDocument(); + expect(within(dialog).getByText('bar')).toBeInTheDocument(); }); - test('cancel button should close confirmation modal', () => { - expect(wrapper.find('AlertModal')).toHaveLength(1); - wrapper.find('button[aria-label="Cancel"]').simulate('click'); - expect(wrapper.find('AlertModal')).toHaveLength(0); - }); + test('cancel button should close confirmation modal', async () => { + const { user } = renderWithContexts( + {}} + itemsToDisassociate={mockHosts} + modalTitle="custom title" + /> + ); - test('should render expected modal content', () => { - wrapper.find('button').simulate('click'); - expect( - wrapper - .find('AlertModal') - .containsMatchingElement(
custom note
) - ).toEqual(true); - expect( - wrapper - .find('AlertModal') - .containsMatchingElement( -
This action will disassociate the following:
- ) - ).toEqual(true); - expect(wrapper.find('Title').text()).toEqual('custom title'); - wrapper.find('button[aria-label="Close"]').simulate('click'); + await user.click(screen.getByRole('button', { name: 'Disassociate' })); + const dialog = await screen.findByRole('dialog'); + await user.click( + within(dialog).getByRole('button', { name: 'Cancel' }) + ); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); }); - test('disassociate button should call handleDisassociate on click', () => { - wrapper.find('button').simulate('click'); + test('confirm button should call onDisassociate and close the modal', async () => { + const handleDisassociate = jest.fn(); + const { user } = renderWithContexts( + + ); + + await user.click(screen.getByRole('button', { name: 'Disassociate' })); + const dialog = await screen.findByRole('dialog'); expect(handleDisassociate).toHaveBeenCalledTimes(0); - wrapper - .find('button[aria-label="confirm disassociate"]') - .simulate('click'); + + await user.click( + within(dialog).getByRole('button', { name: 'confirm disassociate' }) + ); expect(handleDisassociate).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); }); }); describe('User does not have disassociate permissions', () => { - const readOnlyHost = [ - { - id: 1, - name: 'foo', - summary_fields: { - user_capabilities: { - delete: false, - }, - }, - }, - ]; - test('should disable button when no delete permissions', () => { - const wrapper = mountWithContexts( + renderWithContexts( {}} - itemsToDelete={readOnlyHost} + itemsToDisassociate={[ + { + id: 1, + name: 'foo', + summary_fields: { user_capabilities: { delete: false } }, + }, + ]} /> ); - expect(wrapper.find('button[disabled]')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'Disassociate' })).toBeDisabled(); }); test('should disable button for control instance', () => { - const wrapper = mountWithContexts( + renderWithContexts( {}} - itemsToDelete={[ - { - id: 1, - hostname: 'awx', - node_type: 'control', - }, + itemsToDisassociate={[ + { id: 1, type: 'instance', hostname: 'awx', node_type: 'control' }, ]} /> ); - expect(wrapper.find('button[disabled]')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'Disassociate' })).toBeDisabled(); }); - test('should disable button when selected items contain instances thaat are hybrid and are inside a protected instances', () => { - const wrapper = mountWithContexts( + + test('should disable button for a hybrid instance inside a protected instance group', () => { + renderWithContexts( {}} - isProectedInstanceGroup - itemsToDelete={[ - { - id: 1, - hostname: 'awx', - node_type: 'control', - }, + isProtectedInstanceGroup + itemsToDisassociate={[ + { id: 1, type: 'instance', hostname: 'awx', node_type: 'hybrid' }, ]} /> ); - expect(wrapper.find('button[disabled]')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'Disassociate' })).toBeDisabled(); }); }); }); diff --git a/awx/ui/src/components/ErrorDetail/ErrorDetail.test.js b/awx/ui/src/components/ErrorDetail/ErrorDetail.test.js index 1816f4a2d..8bebf868b 100644 --- a/awx/ui/src/components/ErrorDetail/ErrorDetail.test.js +++ b/awx/ui/src/components/ErrorDetail/ErrorDetail.test.js @@ -1,46 +1,57 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen, waitFor } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import ErrorDetail from './ErrorDetail'; +// Build an Error that carries a `response`, like the api errors ErrorDetail +// renders. Object.prototype.hasOwnProperty(error, 'response') drives the +// network-error vs stack-trace branch, so the property must live on the +// instance. +function makeNetworkError(response) { + const error = new Error('request failed'); + error.response = response; + return error; +} + describe('ErrorDetail', () => { - test('renders the expected content', () => { - const wrapper = mountWithContexts( + test('renders the expandable Details toggle collapsed by default', () => { + renderWithContexts( ); - expect(wrapper).toHaveLength(1); + const toggle = screen.getByRole('button', { name: /Details/ }); + // PF ExpandableSection keeps its content mounted but hidden when collapsed, + // so assert the collapsed state via aria-expanded rather than absence. + expect(toggle).toHaveAttribute('aria-expanded', 'false'); }); - test('testing errors', () => { - const wrapper = mountWithContexts( + + test('expands to show the network error message on toggle', async () => { + const { user } = renderWithContexts( ); - act(() => wrapper.find('ExpandableSection').prop('onToggle')()); - wrapper.update(); + + await user.click(screen.getByRole('button', { name: /Details/ })); + + await waitFor(() => { + expect(screen.getByText('project error')).toBeInTheDocument(); + }); + expect(screen.getByText('inventory error')).toBeInTheDocument(); + // request line includes method, url and status + expect(screen.getByText('400')).toBeInTheDocument(); }); }); diff --git a/awx/ui/src/components/ExecutionEnvironmentDetail/ExecutionEnvironmentDetail.test.js b/awx/ui/src/components/ExecutionEnvironmentDetail/ExecutionEnvironmentDetail.test.js index 84e6471c3..369a7418e 100644 --- a/awx/ui/src/components/ExecutionEnvironmentDetail/ExecutionEnvironmentDetail.test.js +++ b/awx/ui/src/components/ExecutionEnvironmentDetail/ExecutionEnvironmentDetail.test.js @@ -1,5 +1,9 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen, waitFor } from '@testing-library/react'; +import { + renderWithContexts, + assertDetail, +} from '../../../testUtils/rtlContexts'; import ExecutionEnvironmentDetail from './ExecutionEnvironmentDetail'; @@ -12,34 +16,36 @@ const mockExecutionEnvironment = { }; describe('', () => { - test('should display execution environment detail', async () => { - const wrapper = mountWithContexts( + test('should display execution environment detail', () => { + renderWithContexts( ); - const executionEnvironment = wrapper.find('ExecutionEnvironmentDetail'); - expect(executionEnvironment).toHaveLength(1); - expect(executionEnvironment.find('dt').text()).toEqual( - 'Execution Environment' - ); - expect(executionEnvironment.find('dd').text()).toEqual( - mockExecutionEnvironment.name + assertDetail('Execution Environment', mockExecutionEnvironment.name); + expect( + screen.getByRole('link', { name: mockExecutionEnvironment.name }) + ).toHaveAttribute( + 'href', + `/execution_environments/${mockExecutionEnvironment.id}/details` ); }); test('should display warning deleted execution environment', async () => { - const wrapper = mountWithContexts( + const { user } = renderWithContexts( ); - const executionEnvironment = wrapper.find('ExecutionEnvironmentDetail'); - expect(executionEnvironment).toHaveLength(1); - expect(executionEnvironment.find('dt').text()).toEqual( - 'Execution Environment' - ); - expect(executionEnvironment.find('dd').text()).toEqual('Missing resource'); - expect(wrapper.find('Tooltip').prop('content')).toEqual( - `Execution environment is missing or deleted.` - ); + assertDetail('Execution Environment', 'Missing resource'); + // The deleted-EE warning wraps the icon in a PF Tooltip; its content is + // only rendered into the DOM on hover (DOM equivalent of the enzyme + // `Tooltip.prop('content')` assertion). + const term = screen.getByText('Execution Environment'); + const icon = term.nextElementSibling.querySelector('svg'); + await user.hover(icon); + await waitFor(() => { + expect( + screen.getByText('Execution environment is missing or deleted.') + ).toBeInTheDocument(); + }); }); }); diff --git a/awx/ui/src/components/ExpandCollapse/ExpandCollapse.test.js b/awx/ui/src/components/ExpandCollapse/ExpandCollapse.test.js index eb06b49c5..e9ad95efd 100644 --- a/awx/ui/src/components/ExpandCollapse/ExpandCollapse.test.js +++ b/awx/ui/src/components/ExpandCollapse/ExpandCollapse.test.js @@ -1,19 +1,42 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import ExpandCollapse from './ExpandCollapse'; describe('', () => { const onCompact = jest.fn(); const onExpand = jest.fn(); - const isCompact = false; + + afterEach(() => { + jest.clearAllMocks(); + }); + test('initially renders without crashing', () => { - const wrapper = mountWithContexts( + renderWithContexts( ); - expect(wrapper.length).toBe(1); + expect( + screen.getByRole('button', { name: 'Collapse' }) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument(); + }); + + test('clicking collapse calls onCompact and clicking expand calls onExpand', async () => { + const { user } = renderWithContexts( + + ); + await user.click(screen.getByRole('button', { name: 'Collapse' })); + expect(onCompact).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', { name: 'Expand' })); + expect(onExpand).toHaveBeenCalledTimes(1); }); }); diff --git a/awx/ui/src/components/FieldWithPrompt/FieldWithPrompt.test.js b/awx/ui/src/components/FieldWithPrompt/FieldWithPrompt.test.js index e3361a957..eb5aa9892 100644 --- a/awx/ui/src/components/FieldWithPrompt/FieldWithPrompt.test.js +++ b/awx/ui/src/components/FieldWithPrompt/FieldWithPrompt.test.js @@ -1,13 +1,12 @@ import React from 'react'; +import { screen } from '@testing-library/react'; import { Field, Formik } from 'formik'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import FieldWithPrompt from './FieldWithPrompt'; describe('FieldWithPrompt', () => { - let wrapper; - test('Required asterisk and Popover hidden when not required and tooltip not provided', () => { - wrapper = mountWithContexts( + const { container } = renderWithContexts( { )} ); - expect(wrapper.find('.pf-c-form__label-required')).toHaveLength(0); - expect(wrapper.find('Popover')).toHaveLength(0); + + // the prompt-on-launch checkbox is always rendered + expect(screen.getByLabelText('Prompt on launch')).toBeInTheDocument(); + // no required asterisk + expect( + container.querySelector('.pf-c-form__label-required') + ).not.toBeInTheDocument(); + // no tooltip Popover trigger + expect( + screen.queryByRole('button', { name: 'More information' }) + ).not.toBeInTheDocument(); }); test('Required asterisk and Popover shown when required and tooltip provided', () => { - wrapper = mountWithContexts( + const { container } = renderWithContexts( { )} ); - expect(wrapper.find('.pf-c-form__label-required')).toHaveLength(1); + + expect(screen.getByLabelText('Prompt on launch')).toBeInTheDocument(); + // required asterisk present + expect( + container.querySelector('.pf-c-form__label-required') + ).toBeInTheDocument(); + // the tooltip Popover renders its trigger button expect( - wrapper.find('Popover[data-cy="job-template-limit-tooltip"]').length - ).toBe(1); + screen.getByRole('button', { name: 'More information' }) + ).toBeInTheDocument(); }); }); diff --git a/awx/ui/src/components/HostForm/HostForm.test.js b/awx/ui/src/components/HostForm/HostForm.test.js index b72d3231c..9ccdc6737 100644 --- a/awx/ui/src/components/HostForm/HostForm.test.js +++ b/awx/ui/src/components/HostForm/HostForm.test.js @@ -1,7 +1,7 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; - +import { screen, waitFor } from '@testing-library/react'; +import { InventoriesAPI } from 'api'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import HostForm from './HostForm'; jest.mock('../../api'); @@ -21,19 +21,17 @@ const mockData = { }; describe('', () => { - let wrapper; const handleSubmit = jest.fn(); const handleCancel = jest.fn(); - beforeEach(async () => { - await act(async () => { - wrapper = mountWithContexts( - - ); + beforeEach(() => { + // the host already has a summary_fields.inventory, so the lookup does not + // auto-populate; mock defensively so any stray read resolves quietly + InventoriesAPI.read.mockResolvedValue({ + data: { count: 0, results: [] }, + }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {}, POST: {} }, related_search_fields: [] }, }); }); @@ -42,61 +40,87 @@ describe('', () => { }); test('changing inputs should update form values', async () => { - await act(async () => { - wrapper.find('input#host-name').simulate('change', { - target: { value: 'new foo', name: 'name' }, - }); - wrapper.find('input#host-description').simulate('change', { - target: { value: 'new bar', name: 'description' }, - }); - }); - wrapper.update(); - expect(wrapper.find('input#host-name').prop('value')).toEqual('new foo'); - expect(wrapper.find('input#host-description').prop('value')).toEqual( - 'new bar' + const { user } = renderWithContexts( + ); - expect(wrapper.find('InventoryLookup').prop('isDisabled')).toEqual(false); + + // FormField labelIcon breaks getByLabelText, so query inputs by id + const nameInput = document.querySelector('input#host-name'); + const descriptionInput = document.querySelector('input#host-description'); + + await user.clear(nameInput); + await user.type(nameInput, 'new foo'); + await user.clear(descriptionInput); + await user.type(descriptionInput, 'new bar'); + + expect(nameInput).toHaveValue('new foo'); + expect(descriptionInput).toHaveValue('new bar'); + // inventory lookup is enabled (not disabled) — its Search button is enabled + expect(screen.getByRole('button', { name: 'Search' })).toBeEnabled(); }); test('calls handleSubmit when form submitted', async () => { + const { user } = renderWithContexts( + + ); + expect(handleSubmit).not.toHaveBeenCalled(); - await act(async () => { - wrapper.find('button[aria-label="Save"]').simulate('click'); - }); - expect(handleSubmit).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1)); }); - test('calls "handleCancel" when Cancel button is clicked', () => { + test('calls "handleCancel" when Cancel button is clicked', async () => { + const { user } = renderWithContexts( + + ); + expect(handleCancel).not.toHaveBeenCalled(); - wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); expect(handleCancel).toHaveBeenCalledTimes(1); }); test('should hide inventory lookup field', async () => { - await act(async () => { - wrapper = mountWithContexts( - - ); - }); - expect(wrapper.find('InventoryLookupField').length).toBe(0); + renderWithContexts( + + ); + + await screen.findByRole('button', { name: 'Save' }); + // with the lookup hidden there is no "Inventory" form group / Search button + expect(screen.queryByText('Inventory')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Search' }) + ).not.toBeInTheDocument(); }); test('inventory lookup field should be disabled', async () => { - await act(async () => { - wrapper = mountWithContexts( - - ); - }); - expect(wrapper.find('InventoryLookup').prop('isDisabled')).toEqual(true); + renderWithContexts( + + ); + + await screen.findByRole('button', { name: 'Save' }); + // disableInventoryLookup propagates to the lookup's Search button + expect(screen.getByRole('button', { name: 'Search' })).toBeDisabled(); }); }); diff --git a/awx/ui/src/components/HostToggle/HostToggle.test.js b/awx/ui/src/components/HostToggle/HostToggle.test.js index 6c211f7bd..4106d2997 100644 --- a/awx/ui/src/components/HostToggle/HostToggle.test.js +++ b/awx/ui/src/components/HostToggle/HostToggle.test.js @@ -1,7 +1,7 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; +import { screen, waitFor } from '@testing-library/react'; import { HostsAPI } from 'api'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import HostToggle from './HostToggle'; jest.mock('../../api'); @@ -25,28 +25,32 @@ const mockHost = { }, }; +// The PF Switch renders a hidden checkbox input with the aria-label +const getToggle = () => screen.getByRole('checkbox', { name: 'Toggle host' }); + describe('', () => { - test('should should toggle off', async () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should toggle off', async () => { const onToggle = jest.fn(); - const wrapper = mountWithContexts( + const { user } = renderWithContexts( ); - expect(wrapper.find('Switch').prop('isChecked')).toEqual(true); + expect(getToggle()).toBeChecked(); - await act(async () => { - wrapper.find('Switch').invoke('onChange')(); - }); + await user.click(getToggle()); expect(HostsAPI.update).toHaveBeenCalledWith(1, { enabled: false, }); - wrapper.update(); - expect(wrapper.find('Switch').prop('isChecked')).toEqual(false); + await waitFor(() => expect(getToggle()).not.toBeChecked()); expect(onToggle).toHaveBeenCalledWith(false); }); - test('should should toggle on', async () => { + test('should toggle on', async () => { const onToggle = jest.fn(); - const wrapper = mountWithContexts( + const { user } = renderWithContexts( ', () => { onToggle={onToggle} /> ); - expect(wrapper.find('Switch').prop('isChecked')).toEqual(false); + expect(getToggle()).not.toBeChecked(); - await act(async () => { - wrapper.find('Switch').invoke('onChange')(); - }); + await user.click(getToggle()); expect(HostsAPI.update).toHaveBeenCalledWith(1, { enabled: true, }); - wrapper.update(); - expect(wrapper.find('Switch').prop('isChecked')).toEqual(true); + await waitFor(() => expect(getToggle()).toBeChecked()); expect(onToggle).toHaveBeenCalledWith(true); }); test('should be enabled', async () => { - const wrapper = mountWithContexts(); - expect(wrapper.find('Switch').prop('isDisabled')).toEqual(false); + renderWithContexts(); + expect(getToggle()).toBeEnabled(); }); test('should be disabled', async () => { - const wrapper = mountWithContexts( - - ); - expect(wrapper.find('Switch').prop('isDisabled')).toEqual(true); + renderWithContexts(); + expect(getToggle()).toBeDisabled(); }); test('should show error modal', async () => { HostsAPI.update.mockImplementation(() => { throw new Error('nope'); }); - const wrapper = mountWithContexts(); - expect(wrapper.find('Switch').prop('isChecked')).toEqual(true); + const { user } = renderWithContexts(); + expect(getToggle()).toBeChecked(); - await act(async () => { - wrapper.find('Switch').invoke('onChange')(); - }); - wrapper.update(); - const modal = wrapper.find('AlertModal'); - expect(modal).toHaveLength(1); - expect(modal.prop('isOpen')).toEqual(true); + await user.click(getToggle()); + const dialog = await screen.findByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(screen.getByText('Error!')).toBeInTheDocument(); - act(() => { - modal.invoke('onClose')(); - }); - wrapper.update(); - expect(wrapper.find('AlertModal')).toHaveLength(0); + await user.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + ); }); }); diff --git a/awx/ui/src/components/InstanceToggle/InstanceToggle.test.js b/awx/ui/src/components/InstanceToggle/InstanceToggle.test.js index bb5ad0206..c07a5e011 100644 --- a/awx/ui/src/components/InstanceToggle/InstanceToggle.test.js +++ b/awx/ui/src/components/InstanceToggle/InstanceToggle.test.js @@ -1,7 +1,7 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; +import { screen, waitFor } from '@testing-library/react'; import { InstancesAPI } from 'api'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import InstanceToggle from './InstanceToggle'; jest.mock('../../api'); @@ -33,6 +33,9 @@ const mockInstance = { managed_by_policy: true, }; +// The PF Switch renders a hidden checkbox input with the aria-label +const getToggle = () => screen.getByRole('checkbox', { name: 'Toggle instance' }); + describe('', () => { const onToggle = jest.fn(); const fetchInstances = jest.fn(); @@ -42,29 +45,26 @@ describe('', () => { }); test('should show toggle off', async () => { - const wrapper = mountWithContexts( + const { user } = renderWithContexts( ); - expect(wrapper.find('Switch').prop('isChecked')).toEqual(true); + expect(getToggle()).toBeChecked(); - await act(async () => { - wrapper.find('Switch').invoke('onChange')(); - }); + await user.click(getToggle()); expect(InstancesAPI.update).toHaveBeenCalledWith(1, { enabled: false, }); - wrapper.update(); - expect(wrapper.find('Switch').prop('isChecked')).toEqual(false); + await waitFor(() => expect(getToggle()).not.toBeChecked()); expect(onToggle).toHaveBeenCalledWith(false); expect(fetchInstances).toHaveBeenCalledTimes(1); }); test('should show toggle on', async () => { - const wrapper = mountWithContexts( + const { user } = renderWithContexts( ', () => { fetchInstances={fetchInstances} /> ); - expect(wrapper.find('Switch').prop('isChecked')).toEqual(false); + expect(getToggle()).not.toBeChecked(); - await act(async () => { - wrapper.find('Switch').invoke('onChange')(); - }); + await user.click(getToggle()); expect(InstancesAPI.update).toHaveBeenCalledWith(1, { enabled: true, }); - wrapper.update(); - expect(wrapper.find('Switch').prop('isChecked')).toEqual(true); + await waitFor(() => expect(getToggle()).toBeChecked()); expect(onToggle).toHaveBeenCalledWith(true); expect(fetchInstances).toHaveBeenCalledTimes(1); }); @@ -92,23 +89,19 @@ describe('', () => { InstancesAPI.update.mockImplementation(() => { throw new Error('nope'); }); - const wrapper = mountWithContexts( - + const { user } = renderWithContexts( + ); - expect(wrapper.find('Switch').prop('isChecked')).toEqual(true); + expect(getToggle()).toBeChecked(); - await act(async () => { - wrapper.find('Switch').invoke('onChange')(); - }); - wrapper.update(); - const modal = wrapper.find('AlertModal'); - expect(modal).toHaveLength(1); - expect(modal.prop('isOpen')).toEqual(true); + await user.click(getToggle()); + const dialog = await screen.findByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(screen.getByText('Error!')).toBeInTheDocument(); - act(() => { - modal.invoke('onClose')(); - }); - wrapper.update(); - expect(wrapper.find('AlertModal')).toHaveLength(0); + await user.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + ); }); }); diff --git a/awx/ui/src/components/JobCancelButton/JobCancelButton.test.js b/awx/ui/src/components/JobCancelButton/JobCancelButton.test.js index 2429da2d0..487544620 100644 --- a/awx/ui/src/components/JobCancelButton/JobCancelButton.test.js +++ b/awx/ui/src/components/JobCancelButton/JobCancelButton.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; +import { screen, waitFor } from '@testing-library/react'; import { ProjectUpdatesAPI, AdHocCommandsAPI, @@ -7,60 +7,66 @@ import { WorkflowJobsAPI, JobsAPI, } from 'api'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import JobCancelButton from './JobCancelButton'; jest.mock('../../api'); describe('', () => { - let wrapper; + afterEach(() => { + jest.clearAllMocks(); + }); test('should render properly', () => { - act(() => { - wrapper = mountWithContexts( - - ); - }); - expect(wrapper.length).toBe(1); - expect(wrapper.find('MinusCircleIcon').length).toBe(0); + renderWithContexts( + + ); + // default (non-icon) button renders the "Cancel Job" text, no MinusCircleIcon + expect( + screen.getByRole('button', { name: 'Title' }) + ).toBeInTheDocument(); + expect(screen.getByText('Cancel Job')).toBeInTheDocument(); + expect(document.querySelector('.pf-c-button svg')).toBeNull(); }); + test('should render icon button', () => { - act(() => { - wrapper = mountWithContexts( - - ); - }); - expect(wrapper.find('MinusCircleIcon').length).toBe(1); + renderWithContexts( + + ); + // the icon variant renders the MinusCircleIcon (an svg) inside the button + const button = screen.getByRole('button', { name: 'Title' }); + expect(button.querySelector('svg')).not.toBeNull(); }); + test('should call api', async () => { - act(() => { - wrapper = mountWithContexts( - - ); - }); - await act(async () => wrapper.find('Button').prop('onClick')(true)); - wrapper.update(); + const { user } = renderWithContexts( + + ); + await user.click(screen.getByRole('button', { name: 'Title' })); - expect(wrapper.find('AlertModal').length).toBe(1); - await act(() => - wrapper.find('Button#cancel-job-confirm-button').prop('onClick')() + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + await user.click( + screen.getByRole('button', { name: 'Confirm cancel job' }) + ); + await waitFor(() => + expect(ProjectUpdatesAPI.cancel).toHaveBeenCalledWith(1) ); - expect(ProjectUpdatesAPI.cancel).toHaveBeenCalledWith(1); }); + test('should throw error', async () => { ProjectUpdatesAPI.cancel.mockRejectedValue( new Error({ @@ -74,107 +80,98 @@ describe('', () => { }, }) ); - act(() => { - wrapper = mountWithContexts( - - ); - }); - await act(async () => wrapper.find('Button').prop('onClick')(true)); - wrapper.update(); - expect(wrapper.find('AlertModal').length).toBe(1); - await act(() => - wrapper.find('Button#cancel-job-confirm-button').prop('onClick')() - ); - wrapper.update(); - expect(wrapper.find('ErrorDetail').length).toBe(1); - expect(wrapper.find('AlertModal[title="Title"]').length).toBe(0); + const { user } = renderWithContexts( + + ); + await user.click(screen.getByRole('button', { name: 'Title' })); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + await user.click( + screen.getByRole('button', { name: 'Confirm cancel job' }) + ); + + // error modal (errorTitle="Error", with ErrorDetail's "Details" expandable) + // replaces the confirm modal whose "Confirm cancel job" button is now gone + expect(await screen.findByText('Error')).toBeInTheDocument(); + expect(screen.getByText('Details')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Confirm cancel job' }) + ).not.toBeInTheDocument(); }); test('should cancel Ad Hoc Command job', async () => { - act(() => { - wrapper = mountWithContexts( - - ); - }); - await act(async () => wrapper.find('Button').prop('onClick')(true)); - wrapper.update(); - - expect(wrapper.find('AlertModal').length).toBe(1); - await act(() => - wrapper.find('Button#cancel-job-confirm-button').prop('onClick')() + const { user } = renderWithContexts( + + ); + await user.click(screen.getByRole('button', { name: 'Title' })); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + await user.click( + screen.getByRole('button', { name: 'Confirm cancel job' }) + ); + await waitFor(() => + expect(AdHocCommandsAPI.cancel).toHaveBeenCalledWith(1) ); - expect(AdHocCommandsAPI.cancel).toHaveBeenCalledWith(1); }); test('should cancel system job', async () => { - act(() => { - wrapper = mountWithContexts( - - ); - }); - await act(async () => wrapper.find('Button').prop('onClick')(true)); - wrapper.update(); - - expect(wrapper.find('AlertModal').length).toBe(1); - await act(() => - wrapper.find('Button#cancel-job-confirm-button').prop('onClick')() + const { user } = renderWithContexts( + + ); + await user.click(screen.getByRole('button', { name: 'Title' })); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + await user.click( + screen.getByRole('button', { name: 'Confirm cancel job' }) ); - expect(SystemJobsAPI.cancel).toHaveBeenCalledWith(1); + await waitFor(() => expect(SystemJobsAPI.cancel).toHaveBeenCalledWith(1)); }); test('should cancel workflow job', async () => { - act(() => { - wrapper = mountWithContexts( - - ); - }); - await act(async () => wrapper.find('Button').prop('onClick')(true)); - wrapper.update(); - - expect(wrapper.find('AlertModal').length).toBe(1); - await act(() => - wrapper.find('Button#cancel-job-confirm-button').prop('onClick')() + const { user } = renderWithContexts( + + ); + await user.click(screen.getByRole('button', { name: 'Title' })); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + await user.click( + screen.getByRole('button', { name: 'Confirm cancel job' }) + ); + await waitFor(() => + expect(WorkflowJobsAPI.cancel).toHaveBeenCalledWith(1) ); - expect(WorkflowJobsAPI.cancel).toHaveBeenCalledWith(1); }); - test('should cancel workflow job', async () => { - act(() => { - wrapper = mountWithContexts( - - ); - }); - await act(async () => wrapper.find('Button').prop('onClick')(true)); - wrapper.update(); - expect(wrapper.find('AlertModal').length).toBe(1); - await act(() => - wrapper.find('Button#cancel-job-confirm-button').prop('onClick')() + test('should cancel job with unknown type via JobsAPI', async () => { + const { user } = renderWithContexts( + + ); + await user.click(screen.getByRole('button', { name: 'Title' })); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + await user.click( + screen.getByRole('button', { name: 'Confirm cancel job' }) ); - expect(JobsAPI.cancel).toHaveBeenCalledWith(1); + await waitFor(() => expect(JobsAPI.cancel).toHaveBeenCalledWith(1)); }); }); diff --git a/awx/ui/src/components/LabelSelect/LabelSelect.test.js b/awx/ui/src/components/LabelSelect/LabelSelect.test.js index 395a470cc..96e82e7ff 100644 --- a/awx/ui/src/components/LabelSelect/LabelSelect.test.js +++ b/awx/ui/src/components/LabelSelect/LabelSelect.test.js @@ -1,7 +1,7 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; +import { screen, waitFor, within } from '@testing-library/react'; import { LabelsAPI } from 'api'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import LabelSelect from './LabelSelect'; jest.mock('../../api'); @@ -11,38 +11,47 @@ const options = [ { id: 2, name: 'two' }, ]; +// Open the typeahead by clicking its toggle (the Select renders a toggle +// button labelled "Options menu"), then return the listbox's option nodes. +async function openAndGetOptions(user) { + await user.click(screen.getByRole('button', { name: 'Options menu' })); + const listbox = await screen.findByRole('listbox'); + return within(listbox).getAllByRole('option'); +} + describe('', () => { afterEach(() => { jest.resetAllMocks(); }); test('should fetch labels', async () => { - LabelsAPI.read.mockReturnValue({ + LabelsAPI.read.mockResolvedValue({ data: { results: options }, }); - let wrapper; - await act(async () => { - wrapper = mountWithContexts( - {}} onChange={() => {}} /> - ); - }); + const { user } = renderWithContexts( + {}} onChange={() => {}} /> + ); + // the toggle is disabled until the labels load + await waitFor(() => + expect(screen.getByRole('button', { name: 'Options menu' })).toBeEnabled() + ); expect(LabelsAPI.read).toHaveBeenCalledTimes(1); - wrapper.find('SelectToggle').simulate('click'); - const selectOptions = wrapper.find('SelectOption'); + + const selectOptions = await openAndGetOptions(user); expect(selectOptions).toHaveLength(2); - expect(selectOptions.at(0).prop('value')).toEqual(options[0]); - expect(selectOptions.at(1).prop('value')).toEqual(options[1]); + expect(selectOptions[0]).toHaveTextContent('one'); + expect(selectOptions[1]).toHaveTextContent('two'); }); test('should fetch two pages labels if present', async () => { - await LabelsAPI.read.mockResolvedValueOnce({ + LabelsAPI.read.mockResolvedValueOnce({ data: { results: options, next: '/foo?page=2', }, }); - await LabelsAPI.read.mockResolvedValueOnce({ + LabelsAPI.read.mockResolvedValueOnce({ data: { results: [ { id: 3, name: 'three' }, @@ -50,39 +59,47 @@ describe('', () => { ], }, }); - let wrapper; - await act(async () => { - wrapper = mountWithContexts( - {}} onChange={() => {}} /> - ); - }); - wrapper.update(); + const { user } = renderWithContexts( + {}} onChange={() => {}} /> + ); - expect(LabelsAPI.read).toHaveBeenCalledTimes(2); - wrapper.find('SelectToggle').simulate('click'); - const selectOptions = wrapper.find('SelectOption'); + await waitFor(() => expect(LabelsAPI.read).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Options menu' })).toBeEnabled() + ); + + const selectOptions = await openAndGetOptions(user); expect(selectOptions).toHaveLength(4); }); + test('Generate a label', async () => { - let wrapper; const onChange = jest.fn(); - LabelsAPI.read.mockReturnValue({ + LabelsAPI.read.mockResolvedValue({ data: { - options, + results: options, }, }); - await act(async () => { - wrapper = mountWithContexts( - {}} onChange={onChange} /> - ); - }); - await wrapper.find('Select').invoke('onSelect')({}, 'foo'); + const { user } = renderWithContexts( + {}} onChange={onChange} /> + ); + + await waitFor(() => + expect(screen.getByRole('button', { name: 'Options menu' })).toBeEnabled() + ); + + // typing a non-matching value surfaces an isCreatable "create" option; + // selecting it calls onChange with the new {id, name} label + const input = screen.getByRole('textbox'); + await user.type(input, 'foo'); + const createOption = await screen.findByText(/foo/); + await user.click(createOption); + expect(onChange).toHaveBeenCalledWith([{ id: 'foo', name: 'foo' }]); }); + test('should handle read-only labels', async () => { - let wrapper; const onChange = jest.fn(); - LabelsAPI.read.mockReturnValue({ + LabelsAPI.read.mockResolvedValue({ data: { results: [ { id: 1, name: 'read only' }, @@ -90,22 +107,26 @@ describe('', () => { ], }, }); - await act(async () => { - wrapper = mountWithContexts( - {}} - onChange={onChange} - /> - ); - }); - wrapper.find('SelectToggle').simulate('click'); - const selectOptions = wrapper.find('SelectOption'); + const { user } = renderWithContexts( + {}} + onChange={onChange} + /> + ); + + await waitFor(() => + expect(screen.getByRole('button', { name: 'Options menu' })).toBeEnabled() + ); + + const selectOptions = await openAndGetOptions(user); expect(selectOptions).toHaveLength(2); - expect(selectOptions.at(0).prop('isDisabled')).toBe(true); - expect(selectOptions.at(1).prop('isDisabled')).toBe(false); + // PF renders a disabled SelectOption with the pf-m-disabled modifier class + // (and a disabled checkbox); the enabled option lacks it + expect(selectOptions[0]).toHaveClass('pf-m-disabled'); + expect(selectOptions[1]).not.toHaveClass('pf-m-disabled'); }); }); diff --git a/awx/ui/src/components/ListHeader/ListHeader.test.js b/awx/ui/src/components/ListHeader/ListHeader.test.js index c4fa88000..76d167a9b 100644 --- a/awx/ui/src/components/ListHeader/ListHeader.test.js +++ b/awx/ui/src/components/ListHeader/ListHeader.test.js @@ -1,7 +1,7 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; +import { act } from '@testing-library/react'; import { createMemoryHistory } from 'history'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import ListHeader from './ListHeader'; describe('ListHeader', () => { @@ -11,48 +11,58 @@ describe('ListHeader', () => { integerFields: ['id', 'page', 'page_size'], dateFields: ['modified', 'created'], }; - const renderToolbarFn = jest.fn(); + + // Capture the toolbar props ListHeader passes to its renderToolbar callback + // so the handlers (onSort/onSearch/onRemove/clearAllFilters) can be exercised + // directly, mirroring the enzyme suite's `toolbar.prop('onX')(...)`. + function makeCapturingToolbar() { + const captured = {}; + const renderToolbar = (props) => { + Object.assign(captured, props); + return
; + }; + return { captured, renderToolbar }; + } + + const baseProps = { + qsConfig, + searchColumns: [{ name: 'foo', key: 'foo__icontains', isDefault: true }], + sortColumns: [{ name: 'foo', key: 'foo' }], + }; test('initially renders without crashing', () => { const history = createMemoryHistory({ initialEntries: ['/organizations/1/teams'], }); - const wrapper = mountWithContexts( + const { container } = renderWithContexts( , { context: { router: { history } } } ); - expect(wrapper.length).toBe(1); + expect(container).toBeInTheDocument(); }); test('should navigate when DataListToolbar calls onSort prop', async () => { const history = createMemoryHistory({ initialEntries: ['/organizations/1/teams'], }); - const wrapper = mountWithContexts( - , + const { captured, renderToolbar } = makeCapturingToolbar(); + renderWithContexts( + , { context: { router: { history } } } ); - const toolbar = wrapper.find('DataListToolbar'); - toolbar.prop('onSort')('foo', 'descending'); + act(() => { + captured.onSort('foo', 'descending'); + }); expect(history.location.search).toEqual('?item.order_by=-foo'); - toolbar.prop('onSort')('foo', 'ascending'); - // since order_by = name is the default, that should be strip out of the search + act(() => { + captured.onSort('foo', 'ascending'); + }); + // since order_by = foo is the default, that should be stripped out of the search expect(history.location.search).toEqual(''); }); @@ -61,22 +71,15 @@ describe('ListHeader', () => { const history = createMemoryHistory({ initialEntries: [`/organizations/1/teams${query}`], }); - const wrapper = mountWithContexts( - , + const { captured, renderToolbar } = makeCapturingToolbar(); + renderWithContexts( + , { context: { router: { history } } } ); expect(history.location.search).toEqual(query); - const toolbar = wrapper.find('DataListToolbar'); act(() => { - toolbar.prop('clearAllFilters')(); + captured.clearAllFilters(); }); expect(history.location.search).toEqual('?item.page_size=5'); }); @@ -86,21 +89,16 @@ describe('ListHeader', () => { const history = createMemoryHistory({ initialEntries: [`/organizations/1/teams${query}`], }); - const wrapper = mountWithContexts( - , + const { captured, renderToolbar } = makeCapturingToolbar(); + renderWithContexts( + , { context: { router: { history } } } ); expect(history.location.search).toEqual(query); - const toolbar = wrapper.find('DataListToolbar'); - toolbar.prop('onSearch')('name__icontains', 'foo'); + act(() => { + captured.onSearch('name__icontains', 'foo'); + }); expect(history.location.search).toEqual( '?item.name__icontains=foo&item.page_size=10' ); @@ -111,21 +109,16 @@ describe('ListHeader', () => { const history = createMemoryHistory({ initialEntries: [`/organizations/1/teams${query}`], }); - const wrapper = mountWithContexts( - , + const { captured, renderToolbar } = makeCapturingToolbar(); + renderWithContexts( + , { context: { router: { history } } } ); expect(history.location.search).toEqual(query); - const toolbar = wrapper.find('DataListToolbar'); - toolbar.prop('onRemove')('name__icontains', 'foo'); + act(() => { + captured.onRemove('name__icontains', 'foo'); + }); expect(history.location.search).toEqual('?item.page_size=10'); }); }); diff --git a/awx/ui/src/components/MultiButtonToggle/MultiButtonToggle.test.js b/awx/ui/src/components/MultiButtonToggle/MultiButtonToggle.test.js index c37e43b72..3c3b29fff 100644 --- a/awx/ui/src/components/MultiButtonToggle/MultiButtonToggle.test.js +++ b/awx/ui/src/components/MultiButtonToggle/MultiButtonToggle.test.js @@ -1,35 +1,41 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import MultiButtonToggle from './MultiButtonToggle'; describe('', () => { - let wrapper; const onChange = jest.fn(); - beforeAll(() => { - wrapper = shallow( + const renderToggle = (value = 'yaml') => + render( ); + + afterEach(() => { + jest.clearAllMocks(); }); it('should render buttons successfully', () => { - const buttons = wrapper.find('SmallButton'); - expect(buttons.length).toBe(2); - expect(buttons.at(0).props().variant).toBe('primary'); - expect(buttons.at(1).props().variant).toBe('secondary'); + renderToggle(); + const yamlButton = screen.getByRole('button', { name: 'YAML' }); + const jsonButton = screen.getByRole('button', { name: 'JSON' }); + // variant="primary" renders the pf-m-primary modifier; "secondary" the pf-m-secondary one + expect(yamlButton).toHaveClass('pf-m-primary'); + expect(jsonButton).toHaveClass('pf-m-secondary'); }); - it('should call onChange function when button clicked', () => { - const buttons = wrapper.find('SmallButton'); - buttons.at(1).simulate('click'); + it('should call onChange function when button clicked', async () => { + const user = userEvent.setup(); + renderToggle(); + await user.click(screen.getByRole('button', { name: 'JSON' })); expect(onChange).toHaveBeenCalledWith('json'); }); }); diff --git a/awx/ui/src/components/MultiSelect/TagMultiSelect.test.js b/awx/ui/src/components/MultiSelect/TagMultiSelect.test.js index 11ebbac11..6e1350381 100644 --- a/awx/ui/src/components/MultiSelect/TagMultiSelect.test.js +++ b/awx/ui/src/components/MultiSelect/TagMultiSelect.test.js @@ -1,34 +1,48 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen, within } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import TagMultiSelect from './TagMultiSelect'; +function getChipGroup() { + return screen.getByRole('group', { name: 'Chip group category' }); +} + describe('', () => { - it('should render Select', () => { - const wrapper = mountWithContexts( - - ); - wrapper.find('input').simulate('focus'); - const options = wrapper.find('Chip'); - expect(options).toHaveLength(2); - expect(options.at(0).text()).toEqual('foo'); - expect(options.at(1).text()).toEqual('bar'); + it('should render Select with a chip per value', () => { + renderWithContexts(); + const chips = within(getChipGroup()).getAllByRole('listitem'); + expect(chips).toHaveLength(2); + expect(chips[0]).toHaveTextContent('foo'); + expect(chips[1]).toHaveTextContent('bar'); }); - it('should not treat empty string as an option', () => { - const wrapper = mountWithContexts(); - wrapper.find('SelectToggle').simulate('click'); - expect(wrapper.find('Select').prop('isOpen')).toEqual(true); - expect(wrapper.find('Chip')).toHaveLength(0); + it('should not treat empty string as an option', async () => { + const { user } = renderWithContexts( + + ); + expect(screen.queryByRole('group', { name: 'Chip group category' })).toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Options menu' })); + expect( + screen.getByRole('button', { name: 'Options menu' }) + ).toHaveAttribute('aria-expanded', 'true'); + expect( + screen.queryByRole('group', { name: 'Chip group category' }) + ).toBeNull(); }); - it('should trigger onChange', () => { + it('should trigger onChange when an existing option is selected', async () => { const onChange = jest.fn(); - const wrapper = mountWithContexts( + const { user } = renderWithContexts( ); - wrapper.find('input').simulate('focus'); - wrapper.find('Select').invoke('onSelect')(null, 'baz'); + await user.click(screen.getByRole('button', { name: 'Options menu' })); + // selecting an unselected typed option adds it to the value string + const input = screen.getByRole('textbox'); + await user.type(input, 'baz'); + await user.click(screen.getByRole('option', { name: /Create.*baz/ })); + expect(onChange).toHaveBeenCalledWith('foo,bar,baz'); }); }); diff --git a/awx/ui/src/components/OptionsList/OptionsList.test.js b/awx/ui/src/components/OptionsList/OptionsList.test.js index db8954357..af9472d73 100644 --- a/awx/ui/src/components/OptionsList/OptionsList.test.js +++ b/awx/ui/src/components/OptionsList/OptionsList.test.js @@ -1,18 +1,20 @@ import React from 'react'; +import { screen, within } from '@testing-library/react'; import { getQSConfig } from 'util/qs'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import OptionsList from './OptionsList'; const qsConfig = getQSConfig('test', { order_by: 'foo' }); +const options = [ + { id: 1, name: 'foo', url: '/item/1' }, + { id: 2, name: 'bar', url: '/item/2' }, + { id: 3, name: 'baz', url: '/item/3' }, +]; + describe('', () => { it('should display list of options', () => { - const options = [ - { id: 1, name: 'foo', url: '/item/1' }, - { id: 2, name: 'bar', url: '/item/2' }, - { id: 3, name: 'baz', url: '/item/3' }, - ]; - const wrapper = mountWithContexts( + renderWithContexts( ', () => { name="Item" /> ); - expect(wrapper.find('PaginatedTable').prop('items')).toEqual(options); - expect(wrapper.find('SelectedList')).toHaveLength(0); + + // one selectable (radio) row rendered per option + expect(screen.getAllByRole('radio')).toHaveLength(options.length); + options.forEach((opt) => { + expect(screen.getByText(opt.name)).toBeInTheDocument(); + }); + + // no selection preview when value is empty + expect( + screen.queryByRole('group', { name: 'Chip group category' }) + ).toBeNull(); }); it('should render selected list', () => { - const options = [ - { id: 1, name: 'foo', url: '/item/1' }, - { id: 2, name: 'bar', url: '/item/2' }, - { id: 3, name: 'baz', url: '/item/3' }, - ]; - const wrapper = mountWithContexts( + renderWithContexts( ', () => { name="Item" /> ); - const list = wrapper.find('SelectedList'); - expect(list).toHaveLength(1); - expect(list.prop('selected')).toEqual([options[1]]); + + // SelectedList renders its label and a chip for each selected item + expect(screen.getByText('Selected')).toBeInTheDocument(); + const chipGroup = screen.getByRole('group', { + name: 'Chip group category', + }); + const chips = within(chipGroup).getAllByRole('listitem'); + expect(chips).toHaveLength(1); + expect(chips[0]).toHaveTextContent('bar'); }); }); diff --git a/awx/ui/src/components/Pagination/Pagination.test.js b/awx/ui/src/components/Pagination/Pagination.test.js index 20e3d3c48..1ef78e779 100644 --- a/awx/ui/src/components/Pagination/Pagination.test.js +++ b/awx/ui/src/components/Pagination/Pagination.test.js @@ -1,11 +1,14 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import Pagination from './Pagination'; describe('Pagination', () => { test('renders the expected content', () => { - const wrapper = mountWithContexts(); - expect(wrapper).toHaveLength(1); + renderWithContexts(); + expect( + screen.getByRole('navigation', { name: 'Pagination' }) + ).toBeInTheDocument(); }); }); diff --git a/awx/ui/src/components/RelatedTemplateList/RelatedTemplateList.test.js b/awx/ui/src/components/RelatedTemplateList/RelatedTemplateList.test.js index 5c0a75c12..e07a7e065 100644 --- a/awx/ui/src/components/RelatedTemplateList/RelatedTemplateList.test.js +++ b/awx/ui/src/components/RelatedTemplateList/RelatedTemplateList.test.js @@ -1,10 +1,7 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { JobTemplatesAPI, UnifiedJobTemplatesAPI } from 'api'; -import { - mountWithContexts, - waitForElement, -} from '../../../testUtils/enzymeHelpers'; +import { screen, waitFor, within } from '@testing-library/react'; +import { JobTemplatesAPI } from 'api'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import RelatedTemplateList from './RelatedTemplateList'; @@ -48,7 +45,12 @@ const mockTemplates = [ }, ]; -describe('', () => { +function rowCheckbox(name) { + const row = screen.getByText(name).closest('tr'); + return within(row).getByRole('checkbox'); +} + +describe('', () => { let debug; beforeEach(() => { JobTemplatesAPI.read.mockResolvedValue({ @@ -63,7 +65,7 @@ describe('', () => { actions: [], }, }); - debug = global.console.debug; // eslint-disable-line prefer-destructuring + debug = global.console.debug; global.console.debug = () => {}; }); @@ -73,204 +75,110 @@ describe('', () => { }); test('Templates are retrieved from the api and the components finishes loading', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts( - - ); - }); + renderWithContexts( + + ); + + await screen.findByText('Job Template 1'); + expect(JobTemplatesAPI.read).toHaveBeenCalledWith({ credentials__id: 1, order_by: 'name', page: 1, page_size: 20, }); - await act(async () => { - await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + mockTemplates.forEach((tmpl) => { + expect(screen.getByText(tmpl.name)).toBeInTheDocument(); }); - expect(wrapper.find('TemplateListItem').length).toEqual( - mockTemplates.length - ); }); test('handleSelect is called when a template list item is selected', async () => { - const wrapper = mountWithContexts( + const { user } = renderWithContexts( ); - await act(async () => { - await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); - }); - const checkBox = wrapper.find('TemplateListItem').at(1).find('input'); - - checkBox.simulate('change', { - target: { - id: 2, - name: 'Job Template 2', - url: '/templates/job_template/2', - type: 'job_template', - summary_fields: { user_capabilities: { delete: true } }, - }, - }); + await screen.findByText('Job Template 2'); - expect(wrapper.find('TemplateListItem').at(1).prop('isSelected')).toBe( - true - ); + const checkbox = rowCheckbox('Job Template 2'); + expect(checkbox).not.toBeChecked(); + await user.click(checkbox); + expect(rowCheckbox('Job Template 2')).toBeChecked(); }); - test('handleSelectAll is called when a template list item is selected', async () => { - const wrapper = mountWithContexts( + test('handleSelectAll is called when select all is checked', async () => { + const { user } = renderWithContexts( ); - await act(async () => { - await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); - }); - expect(wrapper.find('Checkbox#select-all').prop('isChecked')).toBe(false); + await screen.findByText('Job Template 1'); + + const selectAll = screen.getByRole('checkbox', { name: 'Select all' }); + expect(selectAll).not.toBeChecked(); - const toolBarCheckBox = wrapper.find('Checkbox#select-all'); - act(() => { - toolBarCheckBox.prop('onChange')(true); + await user.click(selectAll); + expect(screen.getByRole('checkbox', { name: 'Select all' })).toBeChecked(); + mockTemplates.forEach((tmpl) => { + expect(rowCheckbox(tmpl.name)).toBeChecked(); }); - wrapper.update(); - expect(wrapper.find('Checkbox#select-all').prop('isChecked')).toBe(true); }); test('delete button is disabled if user does not have delete capabilities on a selected template', async () => { - const wrapper = mountWithContexts( + const { user } = renderWithContexts( ); - await act(async () => { - await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); - }); - const deleteAbleItem = wrapper.find('TemplateListItem').at(0).find('input'); - const nonDeleteAbleItem = wrapper - .find('TemplateListItem') - .at(2) - .find('input'); + await screen.findByText('Job Template 1'); - deleteAbleItem.simulate('change', { - id: 1, - name: 'Job Template 1', - url: '/templates/job_template/1', - type: 'job_template', - summary_fields: { - user_capabilities: { - delete: true, - }, - }, - }); + // with a delete-capable template selected, Delete is enabled + await user.click(rowCheckbox('Job Template 1')); + expect(screen.getByRole('button', { name: 'Delete' })).toBeEnabled(); - expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( - false - ); - deleteAbleItem.simulate('change', { - id: 1, - name: 'Job Template 1', - url: '/templates/job_template/1', - type: 'job_template', - summary_fields: { - user_capabilities: { - delete: true, - }, - }, - }); - expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( - true - ); - nonDeleteAbleItem.simulate('change', { - id: 5, - name: 'Workflow Job Template 2', - url: '/templates/workflow_job_template/5', - type: 'workflow_job_template', - summary_fields: { - user_capabilities: { - delete: false, - }, - }, - }); - expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe( - true - ); + // adding a template without delete capability disables Delete + await user.click(rowCheckbox('Job Template 3')); + expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled(); }); test('api is called to delete templates for each selected template.', async () => { - const wrapper = mountWithContexts( + JobTemplatesAPI.destroy.mockResolvedValue({}); + const { user } = renderWithContexts( ); - await act(async () => { - await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); - }); - const jobTemplate = wrapper.find('TemplateListItem').at(1).find('input'); + await screen.findByText('Job Template 2'); - jobTemplate.simulate('change', { - target: { - id: 2, - name: 'Job Template 2', - url: '/templates/job_template/2', - type: 'job_template', - summary_fields: { user_capabilities: { delete: true } }, - }, - }); + await user.click(rowCheckbox('Job Template 2')); + await user.click(screen.getByRole('button', { name: 'Delete' })); + await user.click( + await screen.findByRole('button', { name: 'confirm delete' }) + ); - await act(async () => { - wrapper.find('button[aria-label="Delete"]').prop('onClick')(); - }); - wrapper.update(); - await act(async () => { - await wrapper - .find('button[aria-label="confirm delete"]') - .prop('onClick')(); - }); - expect(JobTemplatesAPI.destroy).toHaveBeenCalledWith(2); + await waitFor(() => + expect(JobTemplatesAPI.destroy).toHaveBeenCalledWith(2) + ); }); test('error is shown when template not successfully deleted from api', async () => { - JobTemplatesAPI.destroy.mockRejectedValue( - new Error({ - response: { - config: { - method: 'delete', - url: '/api/v2/job_templates/1', - }, - data: 'An error occurred', - }, - }) + JobTemplatesAPI.destroy.mockRejectedValue(new Error()); + const { user } = renderWithContexts( + ); - let wrapper; - - await act(async () => { - wrapper = mountWithContexts( - - ); - }); - wrapper.update(); + await screen.findByText('Job Template 1'); expect(JobTemplatesAPI.read).toHaveBeenCalledTimes(1); - await act(async () => { - wrapper.find('TemplateListItem').at(0).invoke('onSelect')(); - }); - wrapper.update(); - - await act(async () => { - wrapper.find('ToolbarDeleteButton').invoke('onDelete')(); - }); - wrapper.update(); + await user.click(rowCheckbox('Job Template 1')); + await user.click(screen.getByRole('button', { name: 'Delete' })); + await user.click( + await screen.findByRole('button', { name: 'confirm delete' }) + ); - const modal = wrapper.find('Modal'); - expect(modal).toHaveLength(1); - expect(modal.prop('title')).toEqual('Error!'); + expect(await screen.findByText('Error!')).toBeInTheDocument(); }); test('should properly copy template', async () => { JobTemplatesAPI.copy.mockResolvedValue({}); - const wrapper = mountWithContexts( + const { user } = renderWithContexts( ); - await act(async () => { - await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); - }); - await act(async () => - wrapper.find('Button[aria-label="Copy"]').prop('onClick')() - ); - expect(JobTemplatesAPI.copy).toHaveBeenCalled(); + await screen.findByText('Job Template 1'); + + await user.click(screen.getByRole('button', { name: 'Copy' })); + + await waitFor(() => expect(JobTemplatesAPI.copy).toHaveBeenCalled()); }); }); diff --git a/awx/ui/src/components/RoutedTabs/RoutedTabs.test.js b/awx/ui/src/components/RoutedTabs/RoutedTabs.test.js index 50ea827d6..cd28f5b75 100644 --- a/awx/ui/src/components/RoutedTabs/RoutedTabs.test.js +++ b/awx/ui/src/components/RoutedTabs/RoutedTabs.test.js @@ -1,12 +1,11 @@ -/* eslint-disable react/jsx-pascal-case */ + import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; import { createMemoryHistory } from 'history'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { Routes, Route } from 'react-router-dom-v5-compat'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import RoutedTabs from './RoutedTabs'; -let wrapper; -let history; - const tabs = [ { name: 'Details', link: '/organizations/19/details', id: 1 }, { name: 'Access', link: '/organizations/19/access', id: 2 }, @@ -14,35 +13,54 @@ const tabs = [ { name: 'Notification', link: '/organizations/19/notification', id: 4 }, ]; -describe('', () => { - beforeEach(() => { - history = createMemoryHistory({ - initialEntries: ['/organizations/19/teams'], - }); - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); +function renderTabs(initialEntry) { + const history = createMemoryHistory({ + initialEntries: [initialEntry], }); + const utils = renderWithContexts( + + } + /> + , + { + context: { router: { history } }, + } + ); + return { ...utils, history }; +} +describe('', () => { test('RoutedTabs renders successfully', () => { - expect(wrapper.find('Tabs li')).toHaveLength(4); + renderTabs('/organizations/19/teams'); + expect(screen.getAllByRole('tab')).toHaveLength(4); }); - test('Given a URL the correct tab is active', async () => { + test('Given a URL the correct tab is active', () => { + const { history } = renderTabs('/organizations/19/teams'); expect(history.location.pathname).toEqual('/organizations/19/teams'); - expect(wrapper.find('Tabs').prop('activeKey')).toBe(3); + expect(screen.getByRole('tab', { name: 'Teams' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByRole('tab', { name: 'Access' })).toHaveAttribute( + 'aria-selected', + 'false' + ); }); test('should update history when new tab selected', async () => { - wrapper.find('Tabs').invoke('onSelect')( - { - preventDefault: () => {}, - }, - 2 - ); - wrapper.update(); + const { history, user } = renderTabs('/organizations/19/teams'); - expect(history.location.pathname).toEqual('/organizations/19/access'); - expect(wrapper.find('Tabs').prop('activeKey')).toBe(2); + await user.click(screen.getByRole('tab', { name: 'Access' })); + + await waitFor(() => + expect(history.location.pathname).toEqual('/organizations/19/access') + ); + expect(screen.getByRole('tab', { name: 'Access' })).toHaveAttribute( + 'aria-selected', + 'true' + ); }); }); diff --git a/awx/ui/src/components/ScreenHeader/ScreenHeader.test.js b/awx/ui/src/components/ScreenHeader/ScreenHeader.test.js index a0ba2b35d..83f4349ec 100644 --- a/awx/ui/src/components/ScreenHeader/ScreenHeader.test.js +++ b/awx/ui/src/components/ScreenHeader/ScreenHeader.test.js @@ -1,16 +1,11 @@ import React from 'react'; +import { screen, within } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; - -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import ScreenHeader from './ScreenHeader'; describe('', () => { - let breadcrumbWrapper; - let breadcrumb; - let breadcrumbItem; - let breadcrumbHeading; - const config = { '/foo': 'Foo', '/foo/1': 'One', @@ -18,27 +13,21 @@ describe('', () => { '/foo/1/bar/fiz': 'Fiz', }; - const findChildren = () => { - breadcrumb = breadcrumbWrapper.find('ScreenHeader'); - breadcrumbItem = breadcrumbWrapper.find('BreadcrumbItem'); - breadcrumbHeading = breadcrumbWrapper.find('Title'); - }; - test('initially renders successfully', () => { - breadcrumbWrapper = mountWithContexts( + renderWithContexts( ); - findChildren(); + const nav = screen.getByRole('navigation', { name: 'Breadcrumb' }); + const crumbs = within(nav).getAllByRole('link'); + expect(crumbs).toHaveLength(2); + expect(crumbs[0]).toHaveTextContent('Foo'); + expect(crumbs[1]).toHaveTextContent('One'); - expect(breadcrumb).toHaveLength(1); - expect(breadcrumbItem).toHaveLength(2); - expect(breadcrumbHeading).toHaveLength(1); - expect(breadcrumbItem.first().text()).toBe('Foo'); - expect(breadcrumbItem.last().text()).toBe('One'); - expect(breadcrumbHeading.text()).toBe('Bar'); + const heading = screen.getByRole('heading', { level: 2 }); + expect(heading).toHaveTextContent('Bar'); }); test('renders breadcrumb items defined in breadcrumbConfig', () => { @@ -52,15 +41,17 @@ describe('', () => { ]; routes.forEach(([location, crumbLength]) => { - breadcrumbWrapper = mountWithContexts( + const { unmount } = renderWithContexts( ); - expect(breadcrumbWrapper.find('BreadcrumbItem')).toHaveLength( - crumbLength - ); + const nav = screen.queryByRole('navigation', { name: 'Breadcrumb' }); + const crumbs = nav ? within(nav).queryAllByRole('link') : []; + expect(crumbs).toHaveLength(crumbLength); + + unmount(); }); }); }); diff --git a/awx/ui/src/components/SelectableCard/SelectableCard.test.js b/awx/ui/src/components/SelectableCard/SelectableCard.test.js index 75cab3dee..9549f2cd0 100644 --- a/awx/ui/src/components/SelectableCard/SelectableCard.test.js +++ b/awx/ui/src/components/SelectableCard/SelectableCard.test.js @@ -1,17 +1,22 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { screen } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import SelectableCard from './SelectableCard'; describe('', () => { - let wrapper; const onClick = jest.fn(); + test('initially renders without crashing when not selected', () => { - wrapper = shallow(); - expect(wrapper.length).toBe(1); + renderWithContexts( + + ); + expect(screen.getByRole('button', { name: 'card' })).toBeInTheDocument(); }); test('initially renders without crashing when selected', () => { - wrapper = shallow(); - expect(wrapper.length).toBe(1); + renderWithContexts( + + ); + expect(screen.getByRole('button', { name: 'card' })).toBeInTheDocument(); }); }); diff --git a/awx/ui/src/components/Sort/Sort.test.js b/awx/ui/src/components/Sort/Sort.test.js index e89a2a6b4..6c8c19920 100644 --- a/awx/ui/src/components/Sort/Sort.test.js +++ b/awx/ui/src/components/Sort/Sort.test.js @@ -1,9 +1,6 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { - mountWithContexts, - waitForElement, -} from '../../../testUtils/enzymeHelpers'; +import { screen, waitFor } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import Sort from './Sort'; @@ -15,15 +12,7 @@ jest.mock('react-router-dom', () => ({ })); describe('', () => { - let sort; - - afterEach(() => { - if (sort) { - sort = null; - } - }); - - test('should trigger onSort callback', () => { + test('should trigger onSort callback', async () => { const qsConfig = { namespace: 'item', defaultParams: { page: 1, page_size: 5, order_by: 'name' }, @@ -37,21 +26,19 @@ describe('', () => { }, ]; - const sortBtn = 'button[aria-label="Sort"]'; - const onSort = jest.fn(); - const wrapper = mountWithContexts( + const { user } = renderWithContexts( - ).find('Sort'); + ); - wrapper.find(sortBtn).simulate('click'); + await user.click(screen.getByRole('button', { name: 'Sort' })); expect(onSort).toHaveBeenCalledTimes(1); expect(onSort).toHaveBeenCalledWith('name', 'descending'); }); - test('onSort properly passes back descending when ascending was passed as prop', () => { + test('onSort properly passes back descending when ascending was passed as prop', async () => { const qsConfig = { namespace: 'item', defaultParams: { page: 1, page_size: 5, order_by: 'foo' }, @@ -59,32 +46,21 @@ describe('', () => { }; const columns = [ - { - name: 'Foo', - key: 'foo', - }, - { - name: 'Bar', - key: 'bar', - }, - { - name: 'Bakery', - key: 'bakery', - }, + { name: 'Foo', key: 'foo' }, + { name: 'Bar', key: 'bar' }, + { name: 'Bakery', key: 'bakery' }, ]; const onSort = jest.fn(); - const wrapper = mountWithContexts( + const { user } = renderWithContexts( - ).find('Sort'); - const sortDropdownToggle = wrapper.find('Button'); - expect(sortDropdownToggle.length).toBe(1); - sortDropdownToggle.simulate('click'); + ); + await user.click(screen.getByRole('button', { name: 'Sort' })); expect(onSort).toHaveBeenCalledWith('foo', 'descending'); }); - test('onSort properly passes back ascending when descending was passed as prop', () => { + test('onSort properly passes back ascending when descending was passed as prop', async () => { const qsConfig = { namespace: 'item', defaultParams: { page: 1, page_size: 5, order_by: '-foo' }, @@ -92,28 +68,17 @@ describe('', () => { }; const columns = [ - { - name: 'Foo', - key: 'foo', - }, - { - name: 'Bar', - key: 'bar', - }, - { - name: 'Bakery', - key: 'bakery', - }, + { name: 'Foo', key: 'foo' }, + { name: 'Bar', key: 'bar' }, + { name: 'Bakery', key: 'bakery' }, ]; const onSort = jest.fn(); - const wrapper = mountWithContexts( + const { user } = renderWithContexts( - ).find('Sort'); - const sortDropdownToggle = wrapper.find('Button'); - expect(sortDropdownToggle.length).toBe(1); - sortDropdownToggle.simulate('click'); + ); + await user.click(screen.getByRole('button', { name: 'Sort' })); expect(onSort).toHaveBeenCalledWith('foo', 'ascending'); }); @@ -125,37 +90,30 @@ describe('', () => { }; const columns = [ - { - name: 'Foo', - key: 'foo', - }, - { - name: 'Bar', - key: 'bar', - }, - { - name: 'Bakery', - key: 'bakery', - }, + { name: 'Foo', key: 'foo' }, + { name: 'Bar', key: 'bar' }, + { name: 'Bakery', key: 'bakery' }, ]; const onSort = jest.fn(); - const wrapper = mountWithContexts( + const { user } = renderWithContexts( ); - act(() => wrapper.find('Dropdown').invoke('onToggle')(true)); - wrapper.update(); - await waitForElement( - wrapper, - 'Dropdown', - (el) => el.prop('isOpen') === true - ); - act(() => - wrapper.find('li').at(0).prop('onClick')({ target: { innerText: 'Bar' } }) + // Open the sort dropdown (toggle shows the active column name "Foo"). + await user.click(screen.getByRole('button', { name: 'Foo' })); + const barItem = await screen.findByText('Bar'); + // Sort's handleDropdownSelect matches the picked column by the clicked + // element's `innerText`. jsdom does not implement innerText, so define it + // explicitly here to drive the real production handler. + Object.defineProperty(barItem, 'innerText', { + value: 'Bar', + configurable: true, + }); + await user.click(barItem); + await waitFor(() => + expect(onSort).toHaveBeenCalledWith('bar', 'ascending') ); - wrapper.update(); - expect(onSort).toHaveBeenCalledWith('bar', 'ascending'); }); test('should display numeric descending icon', () => { @@ -166,7 +124,7 @@ describe('', () => { }; const numericColumns = [{ name: 'ID', key: 'id' }]; - const wrapper = mountWithContexts( + const { container } = renderWithContexts( ', () => { /> ); - expect(wrapper.find('SortNumericDownAltIcon')).toHaveLength(1); + // SortNumericDownAltIcon + const path = container.querySelector('button[aria-label="Sort"] svg path'); + expect(path.getAttribute('d')).toContain('zm224 64h-16V304'); }); test('should display numeric ascending icon', () => { @@ -185,7 +145,7 @@ describe('', () => { }; const numericColumns = [{ name: 'ID', key: 'id' }]; - const wrapper = mountWithContexts( + const { container } = renderWithContexts( ', () => { /> ); - expect(wrapper.find('SortNumericDownIcon')).toHaveLength(1); + // SortNumericDownIcon + const path = container.querySelector('button[aria-label="Sort"] svg path'); + expect(path.getAttribute('d')).toContain('M304 96h16v64h-16'); }); test('should display alphanumeric descending icon', () => { @@ -204,7 +166,7 @@ describe('', () => { }; const alphaColumns = [{ name: 'Name', key: 'name' }]; - const wrapper = mountWithContexts( + const { container } = renderWithContexts( ', () => { /> ); - expect(wrapper.find('SortAlphaDownAltIcon')).toHaveLength(1); + // SortAlphaDownAltIcon + const path = container.querySelector('button[aria-label="Sort"] svg path'); + expect(path.getAttribute('d')).toContain('352zm112-128h128'); }); test('should display alphanumeric ascending icon', () => { @@ -223,7 +187,7 @@ describe('', () => { }; const alphaColumns = [{ name: 'Name', key: 'name' }]; - const wrapper = mountWithContexts( + const { container } = renderWithContexts( ', () => { /> ); - expect(wrapper.find('SortAlphaDownIcon')).toHaveLength(1); + // SortAlphaDownIcon + const path = container.querySelector('button[aria-label="Sort"] svg path'); + expect(path.getAttribute('d')).toContain('190.22 352 176 352zm240-64H288'); }); }); diff --git a/awx/ui/src/components/Sort/_dump.test.js b/awx/ui/src/components/Sort/_dump.test.js new file mode 100644 index 000000000..7886ae663 --- /dev/null +++ b/awx/ui/src/components/Sort/_dump.test.js @@ -0,0 +1,11 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { SortAlphaDownIcon, SortAlphaDownAltIcon, SortNumericDownIcon, SortNumericDownAltIcon } from '@patternfly/react-icons'; + +[['SortAlphaDownIcon',SortAlphaDownIcon],['SortAlphaDownAltIcon',SortAlphaDownAltIcon],['SortNumericDownIcon',SortNumericDownIcon],['SortNumericDownAltIcon',SortNumericDownAltIcon]].forEach(([n,Ic])=>{ + test(n, () => { + const { container } = render(); + const d = container.querySelector('path').getAttribute('d'); + process.stdout.write(`ICON::${n}::len=${d.length}::${d}\n\n`); + }); +}); diff --git a/awx/ui/src/components/Sparkline/Sparkline.test.js b/awx/ui/src/components/Sparkline/Sparkline.test.js index a19c31e43..852b1fc45 100644 --- a/awx/ui/src/components/Sparkline/Sparkline.test.js +++ b/awx/ui/src/components/Sparkline/Sparkline.test.js @@ -1,17 +1,16 @@ import React from 'react'; - -import { - mountWithContexts, - shallowWithContexts, -} from '../../../testUtils/enzymeHelpers'; +import { screen } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import Sparkline from './Sparkline'; describe('Sparkline', () => { test('renders the expected content', () => { - const wrapper = shallowWithContexts(); - expect(wrapper).toHaveLength(1); + const { container } = renderWithContexts(); + // No jobs => no icons/links rendered. + expect(container.querySelectorAll('a')).toHaveLength(0); }); + test('renders an icon with tooltips and links for each job', () => { const jobs = [ { @@ -25,9 +24,15 @@ describe('Sparkline', () => { finished: '2019-08-09T15:27:57.320120Z', }, ]; - const wrapper = mountWithContexts(); - expect(wrapper.find('StatusIcon')).toHaveLength(2); - expect(wrapper.find('Tooltip')).toHaveLength(2); - expect(wrapper.find('Link')).toHaveLength(2); + const { container } = renderWithContexts(); + // One Link per job, addressed by its accessible name. Each Link wraps a + // Tooltip + StatusIcon (the StatusIcon div carries aria-label={status}). + const link1 = screen.getByRole('link', { name: 'View job 1' }); + const link2 = screen.getByRole('link', { name: 'View job 2' }); + expect(link1).toHaveAttribute('href', '/jobs/undefined/1'); + expect(link2).toHaveAttribute('href', '/jobs/undefined/2'); + expect(link1.querySelector('[data-job-status="successful"]')).toBeInTheDocument(); + expect(link2.querySelector('[data-job-status="failed"]')).toBeInTheDocument(); + expect(container.querySelectorAll('a')).toHaveLength(2); }); }); diff --git a/awx/ui/src/components/StatusIcon/StatusIcon.test.js b/awx/ui/src/components/StatusIcon/StatusIcon.test.js index 783bc8745..af305bcf7 100644 --- a/awx/ui/src/components/StatusIcon/StatusIcon.test.js +++ b/awx/ui/src/components/StatusIcon/StatusIcon.test.js @@ -1,59 +1,68 @@ import React from 'react'; -import { mount } from 'enzyme'; +import { render } from '@testing-library/react'; import StatusIcon from './StatusIcon'; +// The PF icon SVGs have role="img" but no distinguishing accessible name, +// so to preserve the original per-icon assertions we match on the SVG path +// data, which is unique per icon component. Each prefix below identifies the +// icon the original enzyme test looked up by component name. +const ICON_PATH_PREFIX = { + CheckCircleIcon: 'M504 256c0 136.967-111.033 248', + RunningIcon: 'M370.72 133.28C339.458 104.008', + ClockIcon: 'M256,8C119,8,8,119,8,256S119,5', + ExclamationCircleIcon: 'M504 256c0 136.997-111.043 248', + ExclamationTriangleIcon: 'M569.517 440.013C587.975 472.0', + MinusCircleIcon: 'M256 8C119 8 8 119 8 256s111 2', +}; + +function expectIcon(container, iconName) { + const path = container.querySelector('svg path').getAttribute('d'); + expect(path).toContain(ICON_PATH_PREFIX[iconName]); +} + describe('StatusIcon', () => { test('renders the successful status', () => { - const wrapper = mount(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('CheckCircleIcon')).toHaveLength(1); + const { container } = render(); + expectIcon(container, 'CheckCircleIcon'); }); test('renders running status', () => { - const wrapper = mount(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('RunningIcon')).toHaveLength(1); + const { container } = render(); + expectIcon(container, 'RunningIcon'); }); test('renders waiting status', () => { - const wrapper = mount(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('ClockIcon')).toHaveLength(1); + const { container } = render(); + expectIcon(container, 'ClockIcon'); }); test('renders failed status', () => { - const wrapper = mount(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('ExclamationCircleIcon')).toHaveLength(1); + const { container } = render(); + expectIcon(container, 'ExclamationCircleIcon'); }); test('renders a successful status when host status is "ok"', () => { - const wrapper = mount(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('CheckCircleIcon')).toHaveLength(1); + const { container } = render(); + expectIcon(container, 'CheckCircleIcon'); }); test('renders "failed" host status', () => { - const wrapper = mount(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('ExclamationCircleIcon')).toHaveLength(1); + const { container } = render(); + expectIcon(container, 'ExclamationCircleIcon'); }); test('renders "changed" host status', () => { - const wrapper = mount(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('ExclamationTriangleIcon')).toHaveLength(1); + const { container } = render(); + expectIcon(container, 'ExclamationTriangleIcon'); }); test('renders "skipped" host status', () => { - const wrapper = mount(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('MinusCircleIcon')).toHaveLength(1); + const { container } = render(); + expectIcon(container, 'MinusCircleIcon'); }); test('renders "unreachable" host status', () => { - const wrapper = mount(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('ExclamationCircleIcon')).toHaveLength(1); + const { container } = render(); + expectIcon(container, 'ExclamationCircleIcon'); }); }); diff --git a/awx/ui/src/components/StatusIcon/_dump.test.js b/awx/ui/src/components/StatusIcon/_dump.test.js new file mode 100644 index 000000000..8a5e84551 --- /dev/null +++ b/awx/ui/src/components/StatusIcon/_dump.test.js @@ -0,0 +1,13 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import StatusIcon from 'components/StatusIcon'; + +['successful','running','waiting','failed','ok','changed','skipped','unreachable'].forEach((s) => { + test(`dump-${s}`, () => { + const { container } = render(); + const svg = container.querySelector('svg'); + const path = svg.querySelector('path').getAttribute('d'); + // eslint-disable-next-line no-console + console.log(`${s} :: viewBox=${svg.getAttribute('viewBox')} :: d.start=${path.slice(0,30)}`); + }); +}); diff --git a/awx/ui/src/components/StatusLabel/StatusLabel.test.js b/awx/ui/src/components/StatusLabel/StatusLabel.test.js index 3e67af098..7760e65b2 100644 --- a/awx/ui/src/components/StatusLabel/StatusLabel.test.js +++ b/awx/ui/src/components/StatusLabel/StatusLabel.test.js @@ -1,89 +1,142 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import StatusLabel from './StatusLabel'; +// PF Label color is rendered as a `pf-m-` class on the label span, and +// the status icon (no distinguishing accessible name) is identified by its +// unique SVG path data — both preserve the original enzyme prop assertions. +const ICON_PATH_PREFIX = { + CheckCircleIcon: 'M504 256c0 136.967-111.033 248', + ExclamationCircleIcon: 'M504 256c0 136.997-111.043 248', + SyncAltIcon: 'M370.72 133.28C339.458 104.008', + ClockIcon: 'M256,8C119,8,8,119,8,256S119,5', + MinusCircleIcon: 'M256 8C119 8 8 119 8 256s111 2', + ExclamationTriangleIcon: 'M569.517 440.013C587.975 472.0', +}; + +function getLabel(container) { + return container.querySelector('.pf-c-label'); +} + +function expectLabel(container, { icon, color, text }) { + const label = getLabel(container); + if (color === 'grey') { + // PF Label renders no color modifier class for the default grey color, so + // assert no other color class leaked in (preserving the original + // color="grey" prop assertion). + ['green', 'red', 'blue', 'orange'].forEach((c) => + expect(label).not.toHaveClass(`pf-m-${c}`) + ); + } else { + expect(label).toHaveClass(`pf-m-${color}`); + } + expect(label).toHaveTextContent(text); + const path = container.querySelector('svg path').getAttribute('d'); + expect(path).toContain(ICON_PATH_PREFIX[icon]); +} + describe('StatusLabel', () => { test('should render success', () => { - const wrapper = mountWithContexts(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('CheckCircleIcon')).toHaveLength(1); - expect(wrapper.find('Label').prop('color')).toEqual('green'); - expect(wrapper.text()).toEqual('Success'); - expect(wrapper.find('Tooltip')).toHaveLength(0); + const { container } = renderWithContexts(); + expectLabel(container, { + icon: 'CheckCircleIcon', + color: 'green', + text: 'Success', + }); + // No tooltip wrapper when tooltipContent is absent. + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); }); test('should render failed', () => { - const wrapper = mountWithContexts(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('ExclamationCircleIcon')).toHaveLength(1); - expect(wrapper.find('Label').prop('color')).toEqual('red'); - expect(wrapper.text()).toEqual('Failed'); + const { container } = renderWithContexts(); + expectLabel(container, { + icon: 'ExclamationCircleIcon', + color: 'red', + text: 'Failed', + }); }); test('should render error', () => { - const wrapper = mountWithContexts(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('ExclamationCircleIcon')).toHaveLength(1); - expect(wrapper.find('Label').prop('color')).toEqual('red'); - expect(wrapper.text()).toEqual('Error'); + const { container } = renderWithContexts(); + expectLabel(container, { + icon: 'ExclamationCircleIcon', + color: 'red', + text: 'Error', + }); }); test('should render running', () => { - const wrapper = mountWithContexts(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('SyncAltIcon')).toHaveLength(1); - expect(wrapper.find('Label').prop('color')).toEqual('blue'); - expect(wrapper.text()).toEqual('Running'); + const { container } = renderWithContexts(); + expectLabel(container, { + icon: 'SyncAltIcon', + color: 'blue', + text: 'Running', + }); }); test('should render pending', () => { - const wrapper = mountWithContexts(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('ClockIcon')).toHaveLength(1); - expect(wrapper.find('Label').prop('color')).toEqual('blue'); - expect(wrapper.text()).toEqual('Pending'); + const { container } = renderWithContexts(); + expectLabel(container, { + icon: 'ClockIcon', + color: 'blue', + text: 'Pending', + }); }); test('should render waiting', () => { - const wrapper = mountWithContexts(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('ClockIcon')).toHaveLength(1); - expect(wrapper.find('Label').prop('color')).toEqual('grey'); - expect(wrapper.text()).toEqual('Waiting'); + const { container } = renderWithContexts(); + expectLabel(container, { + icon: 'ClockIcon', + color: 'grey', + text: 'Waiting', + }); }); test('should render disabled', () => { - const wrapper = mountWithContexts(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('MinusCircleIcon')).toHaveLength(1); - expect(wrapper.find('Label').prop('color')).toEqual('grey'); - expect(wrapper.text()).toEqual('Disabled'); + const { container } = renderWithContexts(); + expectLabel(container, { + icon: 'MinusCircleIcon', + color: 'grey', + text: 'Disabled', + }); }); test('should render canceled', () => { - const wrapper = mountWithContexts(); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('ExclamationTriangleIcon')).toHaveLength(1); - expect(wrapper.find('Label').prop('color')).toEqual('orange'); - expect(wrapper.text()).toEqual('Canceled'); + const { container } = renderWithContexts(); + expectLabel(container, { + icon: 'ExclamationTriangleIcon', + color: 'orange', + text: 'Canceled', + }); }); - test('should render tooltip', () => { - const wrapper = mountWithContexts( + test('should render tooltip', async () => { + const { container } = renderWithContexts( ); - expect(wrapper).toHaveLength(1); - expect(wrapper.find('CheckCircleIcon')).toHaveLength(1); - expect(wrapper.find('Label').prop('color')).toEqual('green'); - expect(wrapper.text()).toEqual('Success'); - expect(wrapper.find('Tooltip')).toHaveLength(1); - expect(wrapper.find('Tooltip').prop('content')).toEqual('Foo'); + expectLabel(container, { + icon: 'CheckCircleIcon', + color: 'green', + text: 'Success', + }); + // The Tooltip wrapper renders its content into a portal on hover. + fireEvent.mouseEnter(getLabel(container)); + await waitFor(() => + expect(screen.getByRole('tooltip')).toHaveTextContent('Foo') + ); + fireEvent.mouseLeave(getLabel(container)); + await waitFor(() => + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + ); }); test('should render children', () => { - const wrapper = mountWithContexts( - + const { container } = renderWithContexts( + + children + ); - expect(wrapper.text()).toEqual('children'); + expect(getLabel(container)).toHaveTextContent('children'); }); }); diff --git a/awx/ui/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.js b/awx/ui/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.js index b82e02b33..285408eaa 100644 --- a/awx/ui/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.js +++ b/awx/ui/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.js @@ -1,10 +1,7 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; +import { screen, waitFor, act } from '@testing-library/react'; import { UsersAPI, JobTemplatesAPI } from 'api'; -import { - mountWithContexts, - waitForElement, -} from '../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import UserAndTeamAccessAdd from './UserAndTeamAccessAdd'; jest.mock('../../api'); @@ -12,179 +9,183 @@ jest.mock('../../api'); const onError = jest.fn(); const onClose = jest.fn(); -describe('', () => { - const resources = { - data: { - results: [ - { - id: 1, - name: 'Job Template Foo Bar', - url: '/api/v2/job_template/1/', - summary_fields: { - object_roles: { - admin_role: { - description: 'Can manage all aspects of the job template', - name: 'Admin', - id: 164, - }, - execute_role: { - description: 'May run the job template', - name: 'Execute', - id: 165, - }, - read_role: { - description: 'May view settings for the job template', - name: 'Read', - id: 166, - }, +const resources = { + data: { + results: [ + { + id: 1, + name: 'Job Template Foo Bar', + url: '/api/v2/job_template/1/', + summary_fields: { + object_roles: { + admin_role: { + description: 'Can manage all aspects of the job template', + name: 'Admin', + id: 164, + }, + execute_role: { + description: 'May run the job template', + name: 'Execute', + id: 165, + }, + read_role: { + description: 'May view settings for the job template', + name: 'Read', + id: 166, }, }, }, - ], - count: 1, - }, - }; - const options = { - data: { - actions: { - GET: {}, - POST: {}, }, - related_search_fields: [], + ], + count: 1, + }, +}; +const options = { + data: { + actions: { + GET: {}, + POST: {}, }, - }; - let wrapper; - beforeEach(async () => { - await act(async () => { - wrapper = mountWithContexts( - {}} - onClose={onClose} - title="Add user permissions" - onError={onError} - /> - ); + related_search_fields: [], + }, +}; + +// Returns the PF Wizard footer navigation button by its visible label. +function footerButton(label) { + return screen + .getAllByRole('button') + .find((b) => b.textContent.trim() === label); +} + +// Returns the wizard nav