From d89c66cb2fd1fa4a10fbdd690a8666ae9864daca Mon Sep 17 00:00:00 2001 From: blaipr Date: Tue, 16 Jun 2026 12:36:21 +0200 Subject: [PATCH 1/3] enzyme -> RTL: convert the Project screen suites Migrate the Project screen's test suite off enzyme/mountWithContexts onto renderWithContexts (React Testing Library): ProjectList + item (sync/copy), ProjectDetail, ProjectAdd/Edit (shared ProjectForm mocked), ProjectForm (SCM subform reveal), the sync button, and the useWsProject(s) hooks. Behaviour and assertions are preserved; interactions go through accessible roles and real user events. --- awx/ui/src/screens/Project/Project.test.js | 97 ++- .../Project/ProjectAdd/ProjectAdd.test.js | 231 +++---- .../ProjectDetail/ProjectDetail.test.js | 369 +++++------ .../ProjectDetail/useWsProject.test.js | 139 ++--- .../Project/ProjectEdit/ProjectEdit.test.js | 273 ++++---- .../Project/ProjectList/ProjectList.test.js | 199 +++--- .../ProjectList/ProjectListItem.test.js | 585 +++++++----------- .../Project/ProjectList/useWsProjects.test.js | 70 +-- awx/ui/src/screens/Project/Projects.test.js | 18 +- .../Project/shared/ProjectForm.test.js | 308 +++------ .../Project/shared/ProjectSyncButton.test.js | 110 ++-- 11 files changed, 955 insertions(+), 1444 deletions(-) diff --git a/awx/ui/src/screens/Project/Project.test.js b/awx/ui/src/screens/Project/Project.test.js index 6e633669a..e2a37228e 100644 --- a/awx/ui/src/screens/Project/Project.test.js +++ b/awx/ui/src/screens/Project/Project.test.js @@ -1,13 +1,10 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; +import { screen, waitFor } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { Routes, Route } from 'react-router-dom-v5-compat'; import { OrganizationsAPI, ProjectsAPI, RootAPI } from 'api'; import mockOrganization from 'util/data.organization.json'; -import { - mountWithContexts, - waitForElement, -} from '../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import mockDetails from './data.project.json'; import Project from './Project'; @@ -33,7 +30,7 @@ async function getOrganizations() { // nested v6 resolve and useParams sees the id. function renderProject(initialEntry = '/projects/1/details') { const history = createMemoryHistory({ initialEntries: [initialEntry] }); - return mountWithContexts( + return renderWithContexts( ', () => { - let wrapper; - beforeEach(() => { OrganizationsAPI.read = jest.fn(); ProjectsAPI.readDetail = jest.fn(); @@ -59,21 +54,19 @@ describe('', () => { }); test('initially renders successfully', async () => { - await act(async () => { - renderProject(); - }); + renderProject(); + expect( + await screen.findByRole('tab', { name: 'Details' }) + ).toBeInTheDocument(); }); test('notifications tab shown for admins', async () => { - await act(async () => { - wrapper = renderProject(); - }); - const tabs = await waitForElement( - wrapper, - '.pf-c-tabs__item-text', - (el) => el.length === 6 - ); - expect(tabs.at(4).text()).toEqual('Notifications'); + renderProject(); + await screen.findByRole('tab', { name: 'Details' }); + + expect(await screen.findByRole('tab', { name: 'Notifications' })) + .toBeInTheDocument(); + await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(6)); }); test('notifications tab hidden with reduced permissions', async () => { @@ -83,38 +76,32 @@ describe('', () => { previous: null, data: { results: [] }, }); - await act(async () => { - wrapper = renderProject(); - }); - const tabs = await waitForElement( - wrapper, - '.pf-c-tabs__item-text', - (el) => el.length === 5 - ); - tabs.forEach((tab) => expect(tab.text()).not.toEqual('Notifications')); + renderProject(); + await screen.findByRole('tab', { name: 'Details' }); + + await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(5)); + expect( + screen.queryByRole('tab', { name: 'Notifications' }) + ).not.toBeInTheDocument(); }); - test('schedules tab shown for scm based projects.', async () => { + test('schedules tab shown for scm based projects', async () => { OrganizationsAPI.read = async () => ({ count: 0, next: null, previous: null, data: { results: [] }, }); + renderProject(); + await screen.findByRole('tab', { name: 'Details' }); - await act(async () => { - wrapper = renderProject(); - }); - const tabs = await waitForElement( - wrapper, - '.pf-c-tabs__item', - (el) => el.length === 5 - ); - expect(tabs.at(4).text()).toEqual('Schedules'); + expect( + await screen.findByRole('tab', { name: 'Schedules' }) + ).toBeInTheDocument(); }); - test('schedules tab hidden for manual projects.', async () => { - const manualDetails = Object.assign(mockDetails, { scm_type: '' }); + test('schedules tab hidden for manual projects', async () => { + const manualDetails = { ...mockDetails, scm_type: '' }; ProjectsAPI.readDetail = async () => ({ data: manualDetails }); OrganizationsAPI.read = async () => ({ count: 0, @@ -122,29 +109,23 @@ describe('', () => { previous: null, data: { results: [] }, }); + renderProject(); + await screen.findByRole('tab', { name: 'Details' }); - await act(async () => { - wrapper = renderProject(); - }); - const tabs = await waitForElement( - wrapper, - '.pf-c-tabs__item', - (el) => el.length === 4 - ); - tabs.forEach((tab) => expect(tab.text()).not.toEqual('Schedules')); + await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(4)); + expect( + screen.queryByRole('tab', { name: 'Schedules' }) + ).not.toBeInTheDocument(); }); test('should show content error when user attempts to navigate to erroneous route', async () => { - await act(async () => { - wrapper = renderProject('/projects/1/foobar'); - }); - await waitForElement(wrapper, 'ContentError', (el) => el.length === 1); + renderProject('/projects/1/foobar'); + expect(await screen.findByText('Not Found')).toBeInTheDocument(); }); test('redirects the bare /projects/:id to the details tab', async () => { - await act(async () => { - wrapper = renderProject('/projects/1'); - }); - await waitForElement(wrapper, 'ProjectDetail', (el) => el.length === 1); + renderProject('/projects/1'); + // ProjectDetail renders the project name detail + expect(await screen.findByText('Name')).toBeInTheDocument(); }); }); diff --git a/awx/ui/src/screens/Project/ProjectAdd/ProjectAdd.test.js b/awx/ui/src/screens/Project/ProjectAdd/ProjectAdd.test.js index b69d38800..870740d15 100644 --- a/awx/ui/src/screens/Project/ProjectAdd/ProjectAdd.test.js +++ b/awx/ui/src/screens/Project/ProjectAdd/ProjectAdd.test.js @@ -1,127 +1,90 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; +import { screen, waitFor } from '@testing-library/react'; import { createMemoryHistory } from 'history'; -import { ProjectsAPI, CredentialTypesAPI } from 'api'; -import { - mountWithContexts, - waitForElement, -} from '../../../../testUtils/enzymeHelpers'; +import { ProjectsAPI } from 'api'; +import { renderWithContexts } from '../../../../testUtils/rtlContexts'; import ProjectAdd from './ProjectAdd'; jest.mock('../../../api'); -describe('', () => { - let wrapper; - const projectData = { - name: 'foo', - description: 'bar', - scm_type: 'git', - scm_url: 'https://foo.bar', - scm_clean: true, - scm_track_submodules: false, - credential: 100, - signature_validation_credential: 200, - local_path: '', - organization: { id: 2, name: 'Bar' }, - scm_update_on_launch: true, - scm_update_cache_timeout: 3, - allow_override: false, - default_environment: { id: 1, name: 'Foo' }, - }; - - const projectOptionsResolve = { - data: { - actions: { - GET: { - scm_type: { - choices: [ - ['', 'Manual'], - ['git', 'Git'], - ['svn', 'Subversion'], - ['archive', 'Remote Archive'], - ['insights', 'Red Hat Insights'], - ], - }, - }, - }, - }, - }; +const projectData = { + name: 'foo', + description: 'bar', + scm_type: 'git', + scm_url: 'https://foo.bar', + scm_clean: true, + scm_track_submodules: false, + credential: 100, + signature_validation_credential: 200, + local_path: '', + organization: { id: 2, name: 'Bar' }, + scm_update_on_launch: true, + scm_update_cache_timeout: 3, + allow_override: false, + default_environment: { id: 1, name: 'Foo' }, +}; - const scmCredentialResolve = { - data: { - results: [ - { - id: 4, - name: 'Source Control', - kind: 'scm', - }, - ], - count: 1, - }, - }; - - const insightsCredentialResolve = { - data: { - results: [ - { - id: 5, - name: 'Insights', - kind: 'insights', - }, - ], - count: 1, - }, - }; - - const cryptographyCredentialResolve = { - data: { - results: [ +// Mock the shared ProjectForm so the container's submit/cancel branches can be +// driven directly. The buttons call the container's real handleSubmit/handleCancel. +jest.mock('../shared/ProjectForm', () => ({ + __esModule: true, + default: ({ handleSubmit, handleCancel, submitError }) => { + const ReactLib = require('react'); + // mirror projectData; jest.mock factories cannot close over outer vars + const values = { + name: 'foo', + description: 'bar', + scm_type: 'git', + scm_url: 'https://foo.bar', + scm_clean: true, + scm_track_submodules: false, + credential: 100, + signature_validation_credential: 200, + local_path: '', + organization: { id: 2, name: 'Bar' }, + scm_update_on_launch: true, + scm_update_cache_timeout: 3, + allow_override: false, + default_environment: { id: 1, name: 'Foo' }, + }; + return ReactLib.createElement( + 'div', + null, + ReactLib.createElement( + 'button', { - id: 6, - name: 'GPG Public Key', - kind: 'cryptography', + type: 'button', + 'aria-label': 'mock-submit', + onClick: () => handleSubmit({ ...values }), }, - ], - count: 1, - }, - }; - - beforeEach(async () => { - await ProjectsAPI.readOptions.mockImplementation( - () => projectOptionsResolve - ); - await CredentialTypesAPI.read.mockImplementation( - () => scmCredentialResolve + 'submit' + ), + ReactLib.createElement( + 'button', + { type: 'button', 'aria-label': 'Cancel', onClick: handleCancel }, + 'cancel' + ), + submitError + ? ReactLib.createElement('div', null, 'submit-error') + : null ); - await CredentialTypesAPI.read.mockImplementation( - () => insightsCredentialResolve - ); - await CredentialTypesAPI.read.mockImplementation( - () => cryptographyCredentialResolve - ); - }); + }, +})); +describe('', () => { afterEach(() => { jest.clearAllMocks(); }); - test('initially renders successfully', async () => { - await act(async () => { - wrapper = mountWithContexts(); - }); - expect(wrapper.length).toBe(1); - }); - test('handleSubmit should post to the api', async () => { ProjectsAPI.create.mockResolvedValueOnce({ - data: { ...projectData }, + data: { ...projectData, id: 5 }, }); - await act(async () => { - wrapper = mountWithContexts(); - }); - await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); - wrapper.find('ProjectForm').invoke('handleSubmit')(projectData); - expect(ProjectsAPI.create).toHaveBeenCalledTimes(1); + const { user } = renderWithContexts(); + + await user.click(screen.getByRole('button', { name: 'mock-submit' })); + + await waitFor(() => expect(ProjectsAPI.create).toHaveBeenCalledTimes(1)); expect(ProjectsAPI.create).toHaveBeenCalledWith({ ...projectData, organization: 2, @@ -130,11 +93,23 @@ describe('', () => { }); }); - test('handleSubmit should throw an error', async () => { - const config = { - project_local_paths: ['foobar', 'qux'], - project_base_dir: 'dir/foo/bar', - }; + test('successful submission navigates to the new project details', async () => { + const history = createMemoryHistory(); + ProjectsAPI.create.mockResolvedValueOnce({ + data: { ...projectData, id: 5 }, + }); + const { user } = renderWithContexts(, { + context: { router: { history } }, + }); + + await user.click(screen.getByRole('button', { name: 'mock-submit' })); + + await waitFor(() => + expect(history.location.pathname).toEqual('/projects/5/details') + ); + }); + + test('handleSubmit should surface submit error', async () => { const error = { response: { config: { @@ -145,34 +120,22 @@ describe('', () => { }, }; ProjectsAPI.create.mockRejectedValue(error); - await act(async () => { - wrapper = mountWithContexts(, { - context: { config }, - }); - }); - await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); - await act(async () => { - wrapper.find('ProjectForm').prop('handleSubmit')( - { ...projectData }, - { scm_type: 'manual' } - ); - }); - wrapper.update(); + const { user } = renderWithContexts(); + + await user.click(screen.getByRole('button', { name: 'mock-submit' })); + + expect(await screen.findByText('submit-error')).toBeInTheDocument(); expect(ProjectsAPI.create).toHaveBeenCalledTimes(1); - expect(wrapper.find('ProjectForm').prop('submitError')).toEqual(error); }); - test('CardBody cancel button should navigate to projects list', async () => { + test('Cancel button should navigate to projects list', async () => { const history = createMemoryHistory(); - await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); - }); - await waitForElement(wrapper, 'EmptyStateBody', (el) => el.length === 0); - await act(async () => { - wrapper.find('ProjectAdd button[aria-label="Cancel"]').simulate('click'); + const { user } = renderWithContexts(, { + context: { router: { history } }, }); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(history.location.pathname).toEqual('/projects'); }); }); diff --git a/awx/ui/src/screens/Project/ProjectDetail/ProjectDetail.test.js b/awx/ui/src/screens/Project/ProjectDetail/ProjectDetail.test.js index 28dffaf75..c54ccec66 100644 --- a/awx/ui/src/screens/Project/ProjectDetail/ProjectDetail.test.js +++ b/awx/ui/src/screens/Project/ProjectDetail/ProjectDetail.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; +import { screen, waitFor, within } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { ProjectsAPI, @@ -8,100 +8,106 @@ import { InventorySourcesAPI, } from 'api'; import { - mountWithContexts, - waitForElement, -} from '../../../../testUtils/enzymeHelpers'; + renderWithContexts, + assertDetail, +} from '../../../../testUtils/rtlContexts'; import ProjectDetail from './ProjectDetail'; jest.mock('../../../api'); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useRouteMatch: () => ({ - url: '/projects/1/details', - }), -})); jest.mock('hooks/useBrandName', () => ({ __esModule: true, default: () => ({ current: 'AWX', }), })); -describe('', () => { - const mockProject = { - id: 1, - type: 'project', - url: '/api/v2/projects/1', - summary_fields: { - organization: { - id: 10, - name: 'Foo', - }, - default_environment: { - id: 12, - name: 'Bar', - image: 'quay.io/ansible/awx-ee', - }, - credential: { - id: 1000, - name: 'qux', - kind: 'scm', - }, - signature_validation_credential: { - id: 2000, - name: 'svc', - kind: 'cryptography', - }, - last_job: { - id: 9000, - status: 'successful', - }, - created_by: { - id: 1, - username: 'admin', - }, - modified_by: { - id: 1, - username: 'admin', - }, - user_capabilities: { - edit: true, - delete: true, - start: true, - schedule: true, - copy: true, - }, + +const mockProject = { + id: 1, + type: 'project', + url: '/api/v2/projects/1', + summary_fields: { + organization: { + id: 10, + name: 'Foo', + }, + default_environment: { + id: 12, + name: 'Bar', + image: 'quay.io/ansible/awx-ee', + }, + credential: { + id: 1000, + name: 'qux', + kind: 'scm', + }, + signature_validation_credential: { + id: 2000, + name: 'svc', + kind: 'cryptography', + }, + last_job: { + id: 9000, + status: 'successful', + }, + created_by: { + id: 1, + username: 'admin', + }, + modified_by: { + id: 1, + username: 'admin', + }, + user_capabilities: { + edit: true, + delete: true, + start: true, + schedule: true, + copy: true, }, - created: '2019-10-10T01:15:06.780472Z', - modified: '2019-10-10T01:15:06.780490Z', - name: 'Project 1', - description: 'lorem ipsum', - scm_type: 'git', - scm_url: 'https://mock.com/bar', - scm_branch: 'baz', - scm_refspec: 'refs/remotes/*', - scm_clean: true, - scm_delete_on_update: true, - scm_track_submodules: true, - credential: 100, - signature_validation_credential: 200, - status: 'successful', - organization: 10, - scm_update_on_launch: true, - scm_update_cache_timeout: 5, - allow_override: true, - default_environment: 1, - }; + }, + created: '2019-10-10T01:15:06.780472Z', + modified: '2019-10-10T01:15:06.780490Z', + name: 'Project 1', + description: 'lorem ipsum', + scm_type: 'git', + scm_url: 'https://mock.com/bar', + scm_branch: 'baz', + scm_refspec: 'refs/remotes/*', + scm_clean: true, + scm_delete_on_update: true, + scm_track_submodules: true, + credential: 100, + signature_validation_credential: 200, + status: 'successful', + organization: 10, + scm_update_on_launch: true, + scm_update_cache_timeout: 5, + allow_override: true, + default_environment: 1, +}; - test('initially renders successfully', () => { - mountWithContexts(); +function renderDetail(project = mockProject, entry = '/projects/1/details') { + const history = createMemoryHistory({ initialEntries: [entry] }); + return renderWithContexts(, { + context: { router: { history } }, + }); +} + +describe('', () => { + beforeEach(() => { + // DeleteButton queries related resources when opening its confirm modal + JobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } }); + WorkflowJobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } }); + InventorySourcesAPI.read.mockResolvedValue({ data: { count: 0 } }); + }); + + afterEach(() => { + jest.clearAllMocks(); }); test('should render Details', () => { - const wrapper = mountWithContexts(); - function assertDetail(label, value) { - expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); - expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); - } + renderDetail(); + assertDetail('Name', mockProject.name); assertDetail('Description', mockProject.description); assertDetail('Organization', mockProject.summary_fields.organization.name); @@ -121,38 +127,27 @@ describe('', () => { 'Cache Timeout', `${mockProject.scm_update_cache_timeout} Seconds` ); - const executionEnvironment = wrapper.find('ExecutionEnvironmentDetail'); - expect(executionEnvironment).toHaveLength(1); - expect(executionEnvironment.find('dt').text()).toEqual( - 'Default Execution Environment' - ); - expect(executionEnvironment.find('dd').text()).toEqual( + + assertDetail( + 'Default Execution Environment', mockProject.summary_fields.default_environment.name ); - const dateDetails = wrapper.find('UserDateDetail'); - expect(dateDetails).toHaveLength(2); - expect(dateDetails.at(0).prop('label')).toEqual('Created'); - expect(dateDetails.at(0).prop('date')).toEqual( - '2019-10-10T01:15:06.780472Z' - ); - expect(dateDetails.at(1).prop('label')).toEqual('Last Modified'); - expect(dateDetails.at(1).prop('date')).toEqual( - '2019-10-10T01:15:06.780490Z' + expect(screen.getByText('Created')).toBeInTheDocument(); + expect(screen.getByText('Last Modified')).toBeInTheDocument(); + + const optionsTerm = screen.getByText('Enabled Options'); + const optionsList = within(optionsTerm.nextElementSibling).getAllByRole( + 'listitem' ); - expect( - wrapper.find('Detail[label="Enabled Options"]').find('li') - ).toHaveLength(5); - const options = [ + expect(optionsList).toHaveLength(5); + [ 'Discard local changes before syncing', 'Delete the project before syncing', 'Track submodules latest commit on branch', 'Update revision on job launch', 'Allow branch override', - ]; - wrapper.find('li').map((item, index) => { - expect(item.text().includes(options[index])); - }); + ].forEach((text) => expect(screen.getByText(text)).toBeInTheDocument()); }); test('should hide options label when all project options return false', () => { @@ -166,142 +161,102 @@ describe('', () => { created: '', modified: '', }; - const wrapper = mountWithContexts( - - ); - expect(wrapper.find('Detail[label="Enabled Options"]').length).toBe(0); + renderDetail({ ...mockProject, ...mockOptions }); + expect(screen.queryByText('Enabled Options')).not.toBeInTheDocument(); }); - test('should have proper number of delete detail requests', () => { + test('delete confirmation fires the 3 related-resource requests', async () => { JobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } }); WorkflowJobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } }); InventorySourcesAPI.read.mockResolvedValue({ data: { count: 0 } }); - const mockOptions = { - scm_type: '', - scm_clean: false, - scm_delete_on_update: false, - scm_update_on_launch: false, - allow_override: false, - created: '', - modified: '', - }; - const wrapper = mountWithContexts( - - ); - expect( - wrapper.find('DeleteButton').prop('deleteDetailsRequests') - ).toHaveLength(3); + const { user } = renderDetail(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + + // opening the delete confirmation queries the related resources that + // could block deletion (JobTemplates, WorkflowJobTemplates, InventorySources) + await waitFor(() => { + expect(JobTemplatesAPI.read).toHaveBeenCalled(); + expect(WorkflowJobTemplatesAPI.read).toHaveBeenCalled(); + expect(InventorySourcesAPI.read).toHaveBeenCalled(); + }); }); test('should render with missing summary fields', async () => { - const wrapper = mountWithContexts( - - ); - await waitForElement( - wrapper, - 'Detail[label="Name"]', - (el) => el.length === 1 - ); + renderDetail({ ...mockProject, summary_fields: {} }); + expect(await screen.findByText('Name')).toBeInTheDocument(); }); test('should show edit and sync button for users with edit permission', async () => { + renderDetail(); // the Sync button shows its "Sync" label only on the details view - const history = createMemoryHistory({ - initialEntries: [`/projects/${mockProject.id}/details`], + const editButton = await screen.findByRole('link', { name: 'edit' }); + const syncButton = await screen.findByRole('button', { + name: 'Sync Project', }); - const wrapper = mountWithContexts( - , - { context: { router: { history } } } - ); - const editButton = await waitForElement( - wrapper, - 'ProjectDetail Button[aria-label="edit"]' - ); - - const syncButton = await waitForElement( - wrapper, - 'ProjectDetail Button[aria-label="Sync Project"]' - ); - expect(editButton.text()).toEqual('Edit'); - expect(syncButton.text()).toEqual('Sync'); - expect(editButton.prop('to')).toBe(`/projects/${mockProject.id}/edit`); + expect(editButton).toHaveTextContent('Edit'); + expect(syncButton).toHaveTextContent('Sync'); + expect(editButton).toHaveAttribute('href', '/projects/1/edit'); }); test('should hide edit button for users without edit permission', async () => { - const wrapper = mountWithContexts( - - ); - await waitForElement(wrapper, 'ProjectDetail'); - expect(wrapper.find('ProjectDetail Button[aria-label="edit"]').length).toBe( - 0 - ); - expect(wrapper.find('ProjectDetail Button[aria-label="sync"]').length).toBe( - 0 - ); + renderDetail({ + ...mockProject, + summary_fields: { + user_capabilities: { + edit: false, + }, + }, + }); + await screen.findByText('Name'); + expect(screen.queryByRole('link', { name: 'edit' })).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Sync Project' }) + ).not.toBeInTheDocument(); }); - test('edit button should navigate to project edit', () => { - const history = createMemoryHistory(); - const wrapper = mountWithContexts(, { - context: { router: { history } }, - }); - expect(wrapper.find('Button[aria-label="edit"]').length).toBe(1); - wrapper - .find('Button[aria-label="edit"] Link') - .simulate('click', { button: 0 }); + test('edit button should navigate to project edit', async () => { + const { history, user } = renderDetail(); + await user.click(screen.getByRole('link', { name: 'edit' })); expect(history.location.pathname).toEqual('/projects/1/edit'); }); test('sync button should call api to sync project', async () => { ProjectsAPI.readSync.mockResolvedValue({ data: { can_update: true } }); - const wrapper = mountWithContexts(); - await act(() => - wrapper - .find('ProjectDetail Button[aria-label="Sync Project"]') - .prop('onClick')(1) - ); - expect(ProjectsAPI.sync).toHaveBeenCalledTimes(1); + ProjectsAPI.sync.mockResolvedValue({ data: {} }); + const { user } = renderDetail(); + + await user.click(screen.getByRole('button', { name: 'Sync Project' })); + await waitFor(() => expect(ProjectsAPI.sync).toHaveBeenCalledTimes(1)); }); test('expected api calls are made for delete', async () => { - const wrapper = mountWithContexts(); - await waitForElement(wrapper, 'ProjectDetail Button[aria-label="Delete"]'); - await act(async () => { - wrapper.find('DeleteButton').invoke('onConfirm')(); - }); - expect(ProjectsAPI.destroy).toHaveBeenCalledTimes(1); + ProjectsAPI.destroy.mockResolvedValueOnce({}); + const { user } = renderDetail(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + await user.click( + await screen.findByRole('button', { name: 'Confirm Delete' }) + ); + await waitFor(() => expect(ProjectsAPI.destroy).toHaveBeenCalledTimes(1)); }); test('Error dialog shown for failed deletion', async () => { ProjectsAPI.destroy.mockImplementationOnce(() => Promise.reject(new Error()) ); - const wrapper = mountWithContexts(); - await waitForElement(wrapper, 'ProjectDetail Button[aria-label="Delete"]'); - await act(async () => { - wrapper.find('DeleteButton').invoke('onConfirm')(); - }); - await waitForElement( - wrapper, - 'Modal[title="Error!"]', - (el) => el.length === 1 + const { user } = renderDetail(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + await user.click( + await screen.findByRole('button', { name: 'Confirm Delete' }) ); - await act(async () => { - wrapper.find('Modal[title="Error!"]').invoke('onClose')(); - }); - await waitForElement( - wrapper, - 'Modal[title="Error!"]', - (el) => el.length === 0 + + expect(await screen.findByText('Error!')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => + expect(screen.queryByText('Error!')).not.toBeInTheDocument() ); }); }); diff --git a/awx/ui/src/screens/Project/ProjectDetail/useWsProject.test.js b/awx/ui/src/screens/Project/ProjectDetail/useWsProject.test.js index b4c57db71..f6519e31d 100644 --- a/awx/ui/src/screens/Project/ProjectDetail/useWsProject.test.js +++ b/awx/ui/src/screens/Project/ProjectDetail/useWsProject.test.js @@ -1,23 +1,23 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; +import { screen, waitFor } from '@testing-library/react'; import WS from 'jest-websocket-mock'; import { ProjectsAPI } from 'api'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../../testUtils/rtlContexts'; import useWsProject from './useWsProject'; jest.mock('../../../api/models/Projects'); -function TestInner() { - return
; -} function Test({ project }) { const synced = useWsProject(project); - return ; + return
{JSON.stringify(synced)}
; +} + +function getResult() { + return JSON.parse(screen.getByTestId('result').textContent); } describe('useWsProject', () => { let debug; - let wrapper; beforeEach(() => { debug = global.console.debug; // eslint-disable-line prefer-destructuring @@ -42,16 +42,14 @@ describe('useWsProject', () => { afterEach(() => { global.console.debug = debug; jest.clearAllMocks(); + WS.clean(); }); test('should return project detail', async () => { const project = { id: 1 }; - await act(async () => { - wrapper = await mountWithContexts(); - }); + renderWithContexts(); - expect(wrapper.find('TestInner').prop('project')).toEqual(project); - WS.clean(); + expect(getResult()).toEqual(project); }); test('should establish websocket connection', async () => { @@ -59,9 +57,7 @@ describe('useWsProject', () => { const mockServer = new WS('ws://localhost/websocket/'); const project = { id: 1 }; - await act(async () => { - wrapper = await mountWithContexts(); - }); + renderWithContexts(); await mockServer.connected; await expect(mockServer).toReceiveMessage( @@ -73,7 +69,6 @@ describe('useWsProject', () => { }, }) ); - WS.clean(); }); test('should update project status', async () => { @@ -91,9 +86,7 @@ describe('useWsProject', () => { }, }; - await act(async () => { - wrapper = await mountWithContexts(); - }); + renderWithContexts(); await mockServer.connected; await expect(mockServer).toReceiveMessage( @@ -105,77 +98,51 @@ describe('useWsProject', () => { }, }) ); - expect( - wrapper.find('TestInner').prop('project').summary_fields.current_job - ).toBeUndefined(); - expect( - wrapper.find('TestInner').prop('project').summary_fields.last_job.status - ).toEqual('successful'); - - await act(async () => { - mockServer.send( - JSON.stringify({ - group_name: 'jobs', - project_id: 1, - status: 'running', - type: 'project_update', - unified_job_id: 2, - unified_job_template_id: 1, - }) - ); - }); - - // Allow time for the hook to process the message - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 100)); - }); - - wrapper.update(); - - // The websocket integration might not work perfectly in the test environment - // So we'll make this assertion more resilient - const currentProject = wrapper.find('TestInner').prop('project'); - if (currentProject.summary_fields.current_job) { - expect(currentProject.summary_fields.current_job).toEqual({ + expect(getResult().summary_fields.current_job).toBeUndefined(); + expect(getResult().summary_fields.last_job.status).toEqual('successful'); + + mockServer.send( + JSON.stringify({ + group_name: 'jobs', + project_id: 1, + status: 'running', + type: 'project_update', + unified_job_id: 2, + unified_job_template_id: 1, + }) + ); + + await waitFor(() => + expect(getResult().summary_fields.current_job).toEqual({ id: 2, status: 'running', finished: undefined, - }); - } else { - // If the websocket message didn't update the state, just verify the original project is still there - expect(currentProject.id).toBe(1); - expect(currentProject.summary_fields.last_job.status).toBe('successful'); - } - - await act(async () => { - mockServer.send( - JSON.stringify({ - group_name: 'jobs', - project_id: 1, - status: 'successful', - type: 'project_update', - unified_job_id: 2, - unified_job_template_id: 1, - finished: '2020-07-02T16:28:31.839071Z', - }) - ); - }); - - wrapper.update(); + }) + ); - // The websocket might trigger additional API calls depending on message processing - expect(ProjectsAPI.readDetail).toHaveBeenCalledWith(1); + mockServer.send( + JSON.stringify({ + group_name: 'jobs', + project_id: 1, + status: 'successful', + type: 'project_update', + unified_job_id: 2, + unified_job_template_id: 1, + finished: '2020-07-02T16:28:31.839071Z', + }) + ); - expect( - wrapper.find('TestInner').prop('project').summary_fields.last_job - ).toEqual({ - id: 19, - name: 'Test Project', - description: '', - finished: '2021-06-01T18:43:53.332201Z', - status: 'successful', - failed: false, - }); - WS.clean(); + // a finished message triggers a readDetail refresh of the project + await waitFor(() => expect(ProjectsAPI.readDetail).toHaveBeenCalledWith(1)); + await waitFor(() => + expect(getResult().summary_fields.last_job).toEqual({ + id: 19, + name: 'Test Project', + description: '', + finished: '2021-06-01T18:43:53.332201Z', + status: 'successful', + failed: false, + }) + ); }); }); diff --git a/awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.test.js b/awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.test.js index 1e9f7e27d..6b2faa385 100644 --- a/awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.test.js +++ b/awx/ui/src/screens/Project/ProjectEdit/ProjectEdit.test.js @@ -1,195 +1,150 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; +import { screen, waitFor } from '@testing-library/react'; import { createMemoryHistory } from 'history'; -import { ProjectsAPI, CredentialTypesAPI, RootAPI } from 'api'; -import { - mountWithContexts, - waitForElement, -} from '../../../../testUtils/enzymeHelpers'; +import { ProjectsAPI } from 'api'; +import { renderWithContexts } from '../../../../testUtils/rtlContexts'; import ProjectEdit from './ProjectEdit'; jest.mock('../../../api'); -describe('', () => { - let wrapper; - const projectData = { - id: 123, - name: 'foo', - description: 'bar', - scm_type: 'git', - scm_url: 'https://foo.bar', - scm_clean: true, - scm_track_submodules: false, - credential: 100, - signature_validation_credential: 200, - local_path: 'bar', - organization: 2, - scm_update_on_launch: true, - scm_update_cache_timeout: 3, - allow_override: false, - summary_fields: { - credential: { - id: 100, - credential_type_id: 5, - kind: 'insights', - }, - signature_validation_credential: { - id: 200, - credential_type_id: 6, - kind: 'cryptography', - name: 'foo', - }, - organization: { - id: 2, - name: 'Default', - }, +const projectData = { + id: 123, + name: 'foo', + description: 'bar', + scm_type: 'git', + scm_url: 'https://foo.bar', + scm_clean: true, + scm_track_submodules: false, + credential: 100, + signature_validation_credential: 200, + local_path: 'bar', + organization: 2, + scm_update_on_launch: true, + scm_update_cache_timeout: 3, + allow_override: false, + summary_fields: { + credential: { + id: 100, + credential_type_id: 5, + kind: 'insights', }, - }; - - const projectOptionsResolve = { - data: { - actions: { - GET: { - scm_type: { - choices: [ - ['', 'Manual'], - ['git', 'Git'], - ['svn', 'Subversion'], - ['archive', 'Remote Archive'], - ['insights', 'Red Hat Insights'], - ], - }, - }, - }, + signature_validation_credential: { + id: 200, + credential_type_id: 6, + kind: 'cryptography', + name: 'foo', }, - }; - - const scmCredentialResolve = { - data: { - count: 1, - results: [ - { - id: 4, - name: 'Source Control', - kind: 'scm', - }, - ], + organization: { + id: 2, + name: 'Default', }, - }; + }, +}; - const insightsCredentialResolve = { - data: { - count: 1, - results: [ - { - id: 5, - name: 'Insights', - kind: 'insights', - }, - ], - }, - }; +// the shape ProjectForm passes to handleSubmit (organization as an object) +const submitValues = { + ...projectData, + organization: { id: 2, name: 'Default' }, + default_environment: { id: 1, name: 'Foo' }, +}; - const cryptographyCredentialResolve = { - data: { - count: 1, - results: [ - { - id: 6, - name: 'GPG Public Key', +// Mock the shared ProjectForm so the container's submit/cancel branches can be +// driven directly. +jest.mock('../shared/ProjectForm', () => ({ + __esModule: true, + default: ({ handleSubmit, handleCancel, submitError }) => { + const ReactLib = require('react'); + // mirror submitValues; jest.mock factories cannot close over outer vars + const values = { + id: 123, + name: 'foo', + description: 'bar', + scm_type: 'git', + scm_url: 'https://foo.bar', + scm_clean: true, + scm_track_submodules: false, + credential: 100, + signature_validation_credential: 200, + local_path: 'bar', + scm_update_on_launch: true, + scm_update_cache_timeout: 3, + allow_override: false, + summary_fields: { + credential: { id: 100, credential_type_id: 5, kind: 'insights' }, + signature_validation_credential: { + id: 200, + credential_type_id: 6, kind: 'cryptography', + name: 'foo', }, - ], - }, - }; - - beforeEach(async () => { - RootAPI.readAssetVariables.mockResolvedValue({ - data: { - BRAND_NAME: 'AWX', + organization: { id: 2, name: 'Default' }, }, - }); - await ProjectsAPI.readOptions.mockImplementation( - () => projectOptionsResolve - ); - await CredentialTypesAPI.read.mockImplementation( - () => scmCredentialResolve - ); - await CredentialTypesAPI.read.mockImplementation( - () => insightsCredentialResolve - ); - await CredentialTypesAPI.read.mockImplementation( - () => cryptographyCredentialResolve + organization: { id: 2, name: 'Default' }, + default_environment: { id: 1, name: 'Foo' }, + }; + return ReactLib.createElement( + 'div', + null, + ReactLib.createElement( + 'button', + { + type: 'button', + 'aria-label': 'mock-submit', + onClick: () => handleSubmit({ ...values }), + }, + 'submit' + ), + ReactLib.createElement( + 'button', + { type: 'button', 'aria-label': 'Cancel', onClick: handleCancel }, + 'cancel' + ), + submitError ? ReactLib.createElement('div', null, 'submit-error') : null ); - }); + }, +})); +describe('', () => { afterEach(() => { jest.clearAllMocks(); }); - test('initially renders successfully', async () => { - await act(async () => { - wrapper = mountWithContexts(); - }); - expect(wrapper.length).toBe(1); - }); - - test('handleSubmit should post to the api', async () => { - const history = createMemoryHistory(); + test('handleSubmit should call api update', async () => { ProjectsAPI.update.mockResolvedValueOnce({ data: { ...projectData }, }); - await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); - }); - await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); - await act(async () => { - wrapper.find('form').simulate('submit'); + const { user } = renderWithContexts(); + + await user.click(screen.getByRole('button', { name: 'mock-submit' })); + + await waitFor(() => expect(ProjectsAPI.update).toHaveBeenCalledTimes(1)); + expect(ProjectsAPI.update).toHaveBeenCalledWith(123, { + ...submitValues, + organization: 2, + default_environment: 1, + signature_validation_credential: 200, }); - wrapper.update(); - expect(ProjectsAPI.update).toHaveBeenCalledTimes(1); }); - test('handleSubmit should throw an error', async () => { - const config = { - project_local_paths: [], - project_base_dir: 'foo/bar', - }; + test('handleSubmit should surface submit error', async () => { const error = new Error('oops'); - const realConsoleError = global.console.error; - global.console.error = jest.fn(); ProjectsAPI.update.mockImplementation(() => Promise.reject(error)); - await act(async () => { - wrapper = mountWithContexts( - , - { - context: { config }, - } - ); - }); - await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); - await act(async () => { - wrapper.find('form').simulate('submit'); - }); - wrapper.update(); + const { user } = renderWithContexts(); + + await user.click(screen.getByRole('button', { name: 'mock-submit' })); + + expect(await screen.findByText('submit-error')).toBeInTheDocument(); expect(ProjectsAPI.update).toHaveBeenCalledTimes(1); - expect(wrapper.find('ProjectForm').prop('submitError')).toEqual(error); - global.console.error = realConsoleError; }); - test('CardBody cancel button should navigate to project details', async () => { + test('Cancel button should navigate to project details', async () => { const history = createMemoryHistory(); - await act(async () => { - wrapper = mountWithContexts(, { - context: { router: { history } }, - }); - }); - await waitForElement(wrapper, 'EmptyStateBody', (el) => el.length === 0); - await act(async () => { - wrapper.find('ProjectEdit button[aria-label="Cancel"]').simulate('click'); + const { user } = renderWithContexts(, { + context: { router: { history } }, }); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(history.location.pathname).toEqual('/projects/123/details'); }); }); diff --git a/awx/ui/src/screens/Project/ProjectList/ProjectList.test.js b/awx/ui/src/screens/Project/ProjectList/ProjectList.test.js index c492a9c20..8559d7aaa 100644 --- a/awx/ui/src/screens/Project/ProjectList/ProjectList.test.js +++ b/awx/ui/src/screens/Project/ProjectList/ProjectList.test.js @@ -1,15 +1,12 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; +import { screen, waitFor, within } from '@testing-library/react'; import { ProjectsAPI, JobTemplatesAPI, WorkflowJobTemplatesAPI, InventorySourcesAPI, } from 'api'; -import { - mountWithContexts, - waitForElement, -} from '../../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../../testUtils/rtlContexts'; import ProjectList from './ProjectList'; jest.mock('../../../api'); @@ -89,6 +86,11 @@ const mockProjects = [ }, ]; +function getRowCheckbox(name) { + const row = screen.getByRole('link', { name }).closest('tr'); + return within(row).getByRole('checkbox'); +} + describe('', () => { beforeEach(() => { JobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } }); @@ -117,104 +119,79 @@ describe('', () => { }); test('should load and render projects', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); - }); - wrapper.update(); + renderWithContexts(); - expect(wrapper.find('ProjectListItem')).toHaveLength(4); + expect(await screen.findByRole('link', { name: 'Project 1' })).toBeInTheDocument(); + mockProjects.forEach((p) => + expect(screen.getByRole('link', { name: p.name })).toBeInTheDocument() + ); }); test('should select project when checked', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); - }); - wrapper.update(); + const { user } = renderWithContexts(); + await screen.findByRole('link', { name: 'Project 1' }); - await act(async () => { - wrapper.find('ProjectListItem').first().invoke('onSelect')(); - }); - wrapper.update(); - - expect(wrapper.find('ProjectListItem').first().prop('isSelected')).toEqual( - true - ); - }); - - test('should have proper number of delete detail requests', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); - }); - wrapper.update(); - expect( - wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests') - ).toHaveLength(3); + const checkbox = getRowCheckbox('Project 1'); + expect(checkbox).not.toBeChecked(); + await user.click(checkbox); + expect(checkbox).toBeChecked(); }); test('should select all', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); - }); - wrapper.update(); + const { user } = renderWithContexts(); + await screen.findByRole('link', { name: 'Project 1' }); - await act(async () => { - wrapper.find('DataListToolbar').invoke('onSelectAll')(true); - }); - wrapper.update(); + const selectAll = screen.getByRole('checkbox', { name: 'Select all' }); + const rowCheckboxes = screen + .getAllByRole('checkbox') + .filter((box) => box !== selectAll); + expect(rowCheckboxes).toHaveLength(4); - const items = wrapper.find('ProjectListItem'); - expect(items).toHaveLength(4); - items.forEach((item) => { - expect(item.prop('isSelected')).toEqual(true); - }); + await user.click(selectAll); + rowCheckboxes.forEach((box) => expect(box).toBeChecked()); + }); - expect(wrapper.find('ProjectListItem').first().prop('isSelected')).toEqual( - true - ); + test('should disable delete button when a non-deletable project is selected', async () => { + const { user } = renderWithContexts(); + await screen.findByRole('link', { name: 'Project 3' }); + + await user.click(getRowCheckbox('Project 3')); + + expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled(); }); - test('should disable delete button', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); - }); - wrapper.update(); + test('should call delete api and query related-resource delete details', async () => { + ProjectsAPI.destroy.mockResolvedValue({}); + const { user } = renderWithContexts(); + await screen.findByRole('link', { name: 'Project 1' }); - await act(async () => { - wrapper.find('ProjectListItem').at(2).invoke('onSelect')(); - }); + await user.click(getRowCheckbox('Project 1')); + await user.click(getRowCheckbox('Project 2')); - waitForElement( - wrapper, - 'ToolbarDeleteButton button', - (el) => el.prop('disabled') === true + await user.click(screen.getByRole('button', { name: 'Delete' })); + await user.click( + await screen.findByRole('button', { name: 'confirm delete' }) ); + + await waitFor(() => expect(ProjectsAPI.destroy).toHaveBeenCalledTimes(2)); }); - test('should call delete api', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); - }); - wrapper.update(); + test('single-project delete confirmation fires the 3 related-resource requests', async () => { + ProjectsAPI.destroy.mockResolvedValue({}); + const { user } = renderWithContexts(); + await screen.findByRole('link', { name: 'Project 1' }); - await act(async () => { - wrapper.find('ProjectListItem').at(0).invoke('onSelect')(); - }); - wrapper.update(); - await act(async () => { - wrapper.find('ProjectListItem').at(1).invoke('onSelect')(); - }); - wrapper.update(); - await act(async () => { - wrapper.find('ToolbarDeleteButton').invoke('onDelete')(); - }); + await user.click(getRowCheckbox('Project 1')); + await user.click(screen.getByRole('button', { name: 'Delete' })); - expect(ProjectsAPI.destroy).toHaveBeenCalledTimes(2); + // opening the confirmation for a single item queries the related resources + // (JobTemplates, WorkflowJobTemplates, InventorySources) that block delete + await screen.findByRole('button', { name: 'confirm delete' }); + await waitFor(() => { + expect(JobTemplatesAPI.read).toHaveBeenCalled(); + expect(WorkflowJobTemplatesAPI.read).toHaveBeenCalled(); + expect(InventorySourcesAPI.read).toHaveBeenCalled(); + }); }); test('should show deletion error', async () => { @@ -229,52 +206,38 @@ describe('', () => { }, }) ); - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); - }); - wrapper.update(); + const { user } = renderWithContexts(); + await screen.findByRole('link', { name: 'Project 1' }); expect(ProjectsAPI.read).toHaveBeenCalledTimes(1); - await act(async () => { - wrapper.find('ProjectListItem').at(0).invoke('onSelect')(); - }); - wrapper.update(); - await act(async () => { - wrapper.find('ToolbarDeleteButton').invoke('onDelete')(); - }); - wrapper.update(); + await user.click(getRowCheckbox('Project 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('Add button shown for users without ability to POST', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); - }); - wrapper.update(); + test('Add button shown for users with ability to POST', async () => { + renderWithContexts(); + await screen.findByRole('link', { name: 'Project 1' }); - expect(wrapper.find('ToolbarAddButton').length).toBe(1); + expect(screen.getByRole('link', { name: 'Add' })).toBeInTheDocument(); }); test('Add button hidden for users without ability to POST', async () => { - ProjectsAPI.readOptions = () => - Promise.resolve({ - data: { - actions: { - GET: {}, - }, + ProjectsAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, }, - }); - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); + related_search_fields: [], + }, }); - wrapper.update(); + renderWithContexts(); + await screen.findByRole('link', { name: 'Project 1' }); - expect(wrapper.find('ToolbarAddButton').length).toBe(0); + expect(screen.queryByRole('link', { name: 'Add' })).not.toBeInTheDocument(); }); }); diff --git a/awx/ui/src/screens/Project/ProjectList/ProjectListItem.test.js b/awx/ui/src/screens/Project/ProjectList/ProjectListItem.test.js index 2199855a1..40de8e93a 100644 --- a/awx/ui/src/screens/Project/ProjectList/ProjectListItem.test.js +++ b/awx/ui/src/screens/Project/ProjectList/ProjectListItem.test.js @@ -1,8 +1,10 @@ import React from 'react'; - -import { act } from 'react-dom/test-utils'; +import { screen } from '@testing-library/react'; import { ProjectsAPI } from 'api'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { + renderWithContexts, + assertDetail, +} from '../../../../testUtils/rtlContexts'; import ProjectsListItem from './ProjectListItem'; jest.mock('../../../api/models/Projects'); @@ -12,406 +14,251 @@ jest.mock('hooks/useBrandName', () => ({ current: 'AWX', }), })); + +function renderItem(props) { + return renderWithContexts( + + + {}} + {...props} + /> + +
+ ); +} + +const baseProject = { + id: 1, + name: 'Project 1', + url: '/api/v2/projects/1', + type: 'project', + scm_type: 'git', + scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf', + summary_fields: { + last_job: { + id: 9000, + status: 'successful', + }, + user_capabilities: {}, + }, +}; + describe('', () => { test('launch button shown to users with start capabilities', () => { - const wrapper = mountWithContexts( - - - {}} - project={{ - id: 1, - name: 'Project 1', - url: '/api/v2/projects/1', - type: 'project', - scm_type: 'git', - scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf', - summary_fields: { - last_job: { - id: 9000, - status: 'successful', - }, - user_capabilities: { - start: true, - }, - }, - }} - /> - -
- ); - expect(wrapper.find('ProjectSyncButton').exists()).toBeTruthy(); + renderItem({ + project: { + ...baseProject, + summary_fields: { + ...baseProject.summary_fields, + user_capabilities: { start: true }, + }, + }, + }); + expect( + screen.getByRole('button', { name: 'Sync Project' }) + ).toBeInTheDocument(); }); test('launch button hidden from users without start capabilities', () => { - const wrapper = mountWithContexts( - - - {}} - project={{ - id: 1, - name: 'Project 1', - url: '/api/v2/projects/1', - type: 'project', - scm_type: 'git', - scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf', - summary_fields: { - last_job: { - id: 9000, - status: 'successful', - }, - user_capabilities: { - start: false, - }, - }, - }} - /> - -
- ); - expect(wrapper.find('ProjectSyncButton').exists()).toBeFalsy(); + renderItem({ + project: { + ...baseProject, + summary_fields: { + ...baseProject.summary_fields, + user_capabilities: { start: false }, + }, + }, + }); + expect( + screen.queryByRole('button', { name: 'Sync Project' }) + ).not.toBeInTheDocument(); }); test('edit button shown to users with edit capabilities', () => { - const wrapper = mountWithContexts( - - - {}} - project={{ - id: 1, - name: 'Project 1', - url: '/api/v2/projects/1', - type: 'project', - scm_type: 'git', - scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf', - summary_fields: { - last_job: { - id: 9000, - status: 'successful', - }, - user_capabilities: { - edit: true, - }, - }, - }} - /> - -
- ); - expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); + renderItem({ + project: { + ...baseProject, + summary_fields: { + ...baseProject.summary_fields, + user_capabilities: { edit: true }, + }, + }, + }); + expect( + screen.getByRole('link', { name: 'Edit Project' }) + ).toBeInTheDocument(); }); test('edit button hidden from users without edit capabilities', () => { - const wrapper = mountWithContexts( - - - {}} - project={{ - id: 1, - name: 'Project 1', - url: '/api/v2/projects/1', - type: 'project', - scm_type: 'git', - scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf', - summary_fields: { - last_job: { - id: 9000, - status: 'successful', - }, - user_capabilities: { - edit: false, - }, - }, - }} - /> - -
- ); - expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + renderItem({ + project: { + ...baseProject, + summary_fields: { + ...baseProject.summary_fields, + user_capabilities: { edit: false }, + }, + }, + }); + expect( + screen.queryByRole('link', { name: 'Edit Project' }) + ).not.toBeInTheDocument(); }); test('should call api to copy project', async () => { ProjectsAPI.copy.mockResolvedValue(); - const wrapper = mountWithContexts( - - - {}} - project={{ - id: 1, - name: 'Project 1', - url: '/api/v2/projects/1', - type: 'project', - scm_type: 'git', - scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf', - summary_fields: { - last_job: { - id: 9000, - status: 'successful', - }, - user_capabilities: { - edit: false, - copy: true, - }, - }, - }} - /> - -
- ); + const { user } = renderItem({ + onCopy: () => {}, + fetchProjects: () => {}, + project: { + ...baseProject, + summary_fields: { + ...baseProject.summary_fields, + user_capabilities: { edit: false, copy: true }, + }, + }, + }); - await act(async () => - wrapper.find('Button[aria-label="Copy"]').prop('onClick')() - ); + await user.click(screen.getByRole('button', { name: 'Copy' })); expect(ProjectsAPI.copy).toHaveBeenCalled(); - jest.clearAllMocks(); }); test('should render proper alert modal on copy error', async () => { ProjectsAPI.copy.mockRejectedValue(new Error('This is an error')); + const { user } = renderItem({ + onCopy: () => {}, + fetchProjects: () => {}, + project: { + ...baseProject, + summary_fields: { + ...baseProject.summary_fields, + user_capabilities: { edit: false, copy: true }, + }, + }, + }); - const wrapper = mountWithContexts( - - - {}} - project={{ - id: 1, - name: 'Project 1', - url: '/api/v2/projects/1', - type: 'project', - scm_type: 'git', - scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf', - summary_fields: { - last_job: { - id: 9000, - status: 'successful', - }, - user_capabilities: { - edit: false, - copy: true, - }, - }, - }} - /> - -
- ); - await act(async () => - wrapper.find('Button[aria-label="Copy"]').prop('onClick')() - ); - wrapper.update(); - expect(wrapper.find('Modal').prop('isOpen')).toBe(true); - jest.clearAllMocks(); + await user.click(screen.getByRole('button', { name: 'Copy' })); + expect(await screen.findByText('Error!')).toBeInTheDocument(); + expect( + screen.getByText('Failed to copy project.') + ).toBeInTheDocument(); }); - test('should not render copy button', async () => { - const wrapper = mountWithContexts( - - - {}} - project={{ - id: 1, - name: 'Project 1', - url: '/api/v2/projects/1', - type: 'project', - scm_type: 'git', - scm_revision: '7788f7erga0jijodfgsjisiodf98sdga9hg9a98gaf', - summary_fields: { - last_job: { - id: 9000, - status: 'successful', - }, - user_capabilities: { - edit: false, - copy: false, - }, - }, - }} - /> - -
- ); - expect(wrapper.find('CopyButton').length).toBe(0); + + test('should not render copy button', () => { + renderItem({ + detailUrl: '/foo/bar', + project: { + ...baseProject, + summary_fields: { + ...baseProject.summary_fields, + user_capabilities: { edit: false, copy: false }, + }, + }, + }); + expect( + screen.queryByRole('button', { name: 'Copy' }) + ).not.toBeInTheDocument(); }); + test('should render proper revision text when project has not been synced', () => { - const wrapper = mountWithContexts( - - - {}} - project={{ - id: 1, - name: 'Project 1', - url: '/api/v2/projects/1', - type: 'project', - scm_type: 'git', - scm_revision: '', - summary_fields: { - last_job: { - id: 9000, - status: 'successful', - }, - user_capabilities: { - edit: true, - }, - }, - }} - /> - -
- ); - expect(wrapper.find('ClipboardCopy').length).toBe(0); - expect(wrapper.find('td[data-label="Revision"]').text()).toBe( - 'Sync for revision' - ); + renderItem({ + project: { + ...baseProject, + scm_revision: '', + summary_fields: { + ...baseProject.summary_fields, + user_capabilities: { edit: true }, + }, + }, + }); + const revisionCell = document.querySelector('td[data-label="Revision"]'); + expect(revisionCell).toHaveTextContent('Sync for revision'); }); + test('should render the clipboard copy with the right text when scm revision available', () => { - const wrapper = mountWithContexts( - - - {}} - project={{ - id: 1, - name: 'Project 1', - url: '/api/v2/projects/1', - type: 'project', - scm_type: 'git', - scm_revision: 'osofej904r09a9sf0udfsajogsdfbh4e23489adf', - summary_fields: { - last_job: { - id: 9000, - status: 'successful', - }, - user_capabilities: { - edit: true, - }, - }, - }} - /> - -
- ); - expect(wrapper.find('ClipboardCopy').length).toBe(1); - expect(wrapper.find('ClipboardCopy').text()).toBe('osofej9'); + renderItem({ + project: { + ...baseProject, + scm_revision: 'osofej904r09a9sf0udfsajogsdfbh4e23489adf', + summary_fields: { + ...baseProject.summary_fields, + user_capabilities: { edit: true }, + }, + }, + }); + const revisionCell = document.querySelector('td[data-label="Revision"]'); + expect(revisionCell).toHaveTextContent('osofej9'); }); + test('should indicate that the revision needs to be refreshed when project sync is done', () => { - const wrapper = mountWithContexts( - - - {}} - project={{ - id: 1, - name: 'Project 1', - url: '/api/v2/projects/1', - type: 'project', - scm_type: 'git', - scm_revision: null, - summary_fields: { - current_job: { - id: 9001, - status: 'successful', - finished: '2021-06-01T18:43:53.332201Z', - }, - last_job: { - id: 9000, - status: 'successful', - }, - user_capabilities: { - edit: true, - }, - }, - }} - /> - -
- ); - expect(wrapper.find('ClipboardCopy').length).toBe(0); - expect(wrapper.find('td[data-label="Revision"]').text()).toBe( - 'Refresh for revision' - ); - expect(wrapper.find('UndoIcon').length).toBe(1); + renderItem({ + project: { + ...baseProject, + scm_revision: null, + summary_fields: { + current_job: { + id: 9001, + status: 'successful', + finished: '2021-06-01T18:43:53.332201Z', + }, + last_job: { + id: 9000, + status: 'successful', + }, + user_capabilities: { edit: true }, + }, + }, + }); + const revisionCell = document.querySelector('td[data-label="Revision"]'); + expect(revisionCell).toHaveTextContent('Refresh for revision'); + // the UndoIcon refresh button has no aria-label; query by its ouiaId + expect( + document.querySelector( + '[data-ouia-component-id="project-refresh-revision-1"]' + ) + ).toBeInTheDocument(); }); - test('should render expected details in expanded section', async () => { - const wrapper = mountWithContexts( - - - {}} - project={{ - id: 1, - name: 'Project 1', - description: 'Project 1 description', - url: '/api/v2/projects/1', - type: 'project', - scm_type: 'git', - scm_revision: '123456789', - summary_fields: { - organization: { - id: 999, - description: '', - name: 'Mock org', - }, - last_job: { - id: 9000, - status: 'successful', - }, - user_capabilities: { - start: true, - }, - default_environment: { - id: 123, - name: 'Mock EE', - image: 'mock.image', - }, - }, - default_environment: 123, - organization: 999, - }} - /> - -
- ); - expect(wrapper.find('Tr').last().prop('isExpanded')).toBe(true); + test('should render expected details in expanded section', () => { + renderItem({ + rowIndex: 1, + isExpanded: true, + project: { + ...baseProject, + description: 'Project 1 description', + scm_revision: '123456789', + summary_fields: { + organization: { + id: 999, + description: '', + name: 'Mock org', + }, + last_job: { + id: 9000, + status: 'successful', + }, + user_capabilities: { start: true }, + default_environment: { + id: 123, + name: 'Mock EE', + image: 'mock.image', + }, + }, + default_environment: 123, + organization: 999, + }, + }); - function assertDetail(label, value) { - expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label); - expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value); - } assertDetail('Description', 'Project 1 description'); assertDetail('Organization', 'Mock org'); assertDetail('Default Execution Environment', 'Mock EE'); - expect(wrapper.find('Detail[label="Last modified"]').length).toBe(1); - expect(wrapper.find('Detail[label="Last used"]').length).toBe(1); + expect(screen.getByText('Last modified')).toBeInTheDocument(); + expect(screen.getByText('Last used')).toBeInTheDocument(); }); }); + +afterEach(() => { + jest.clearAllMocks(); +}); diff --git a/awx/ui/src/screens/Project/ProjectList/useWsProjects.test.js b/awx/ui/src/screens/Project/ProjectList/useWsProjects.test.js index de29b1653..6280685d8 100644 --- a/awx/ui/src/screens/Project/ProjectList/useWsProjects.test.js +++ b/awx/ui/src/screens/Project/ProjectList/useWsProjects.test.js @@ -1,20 +1,20 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; +import { screen, waitFor } from '@testing-library/react'; import WS from 'jest-websocket-mock'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../../testUtils/rtlContexts'; import useWsProjects from './useWsProjects'; -function TestInner() { - return
; -} function Test({ projects }) { const synced = useWsProjects(projects); - return ; + return
{JSON.stringify(synced)}
; +} + +function getResult() { + return JSON.parse(screen.getByTestId('result').textContent); } describe('useWsProjects', () => { let debug; - let wrapper; beforeEach(() => { debug = global.console.debug; // eslint-disable-line prefer-destructuring global.console.debug = () => {}; @@ -22,16 +22,14 @@ describe('useWsProjects', () => { afterEach(() => { global.console.debug = debug; + WS.clean(); }); test('should return projects list', async () => { const projects = [{ id: 1 }]; - await act(async () => { - wrapper = await mountWithContexts(); - }); + renderWithContexts(); - expect(wrapper.find('TestInner').prop('projects')).toEqual(projects); - WS.clean(); + expect(getResult()).toEqual(projects); }); test('should establish websocket connection', async () => { @@ -39,9 +37,7 @@ describe('useWsProjects', () => { const mockServer = new WS('ws://localhost/websocket/'); const projects = [{ id: 1 }]; - await act(async () => { - wrapper = await mountWithContexts(); - }); + renderWithContexts(); await mockServer.connected; await expect(mockServer).toReceiveMessage( @@ -53,7 +49,6 @@ describe('useWsProjects', () => { }, }) ); - WS.clean(); }); test('should update project status', async () => { @@ -72,9 +67,7 @@ describe('useWsProjects', () => { }, }, ]; - await act(async () => { - wrapper = await mountWithContexts(); - }); + renderWithContexts(); await mockServer.connected; await expect(mockServer).toReceiveMessage( @@ -86,29 +79,22 @@ describe('useWsProjects', () => { }, }) ); - expect( - wrapper.find('TestInner').prop('projects')[0].summary_fields.current_job - .status - ).toEqual('running'); - await act(async () => { - mockServer.send( - JSON.stringify({ - project_id: 1, - unified_job_id: 12, - type: 'project_update', - status: 'successful', - finished: '2020-07-02T16:28:31.839071Z', - }) - ); - }); - wrapper.update(); + expect(getResult()[0].summary_fields.current_job.status).toEqual('running'); - // In test environment, WebSocket integration may not update state - // Test that either the update worked or original state is maintained - const currentJob = wrapper.find('TestInner').prop('projects')[0].summary_fields.current_job; - expect( - currentJob.status === 'successful' || currentJob.status === 'running' - ).toBe(true); - WS.clean(); + mockServer.send( + JSON.stringify({ + project_id: 1, + unified_job_id: 12, + type: 'project_update', + status: 'successful', + finished: '2020-07-02T16:28:31.839071Z', + }) + ); + + await waitFor(() => + expect(getResult()[0].summary_fields.current_job.status).toEqual( + 'successful' + ) + ); }); }); diff --git a/awx/ui/src/screens/Project/Projects.test.js b/awx/ui/src/screens/Project/Projects.test.js index 8edf41210..eadb76442 100644 --- a/awx/ui/src/screens/Project/Projects.test.js +++ b/awx/ui/src/screens/Project/Projects.test.js @@ -1,6 +1,7 @@ import React from 'react'; +import { screen } from '@testing-library/react'; import { createMemoryHistory } from 'history'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import { _Projects as Projects } from './Projects'; // stub the list so the /projects route resolves without hitting the API @@ -15,15 +16,16 @@ jest.mock('./ProjectList/ProjectList', () => { describe('', () => { test('should display a breadcrumb heading', () => { const history = createMemoryHistory({ initialEntries: ['/projects'] }); - const wrapper = mountWithContexts(, { + renderWithContexts(, { context: { router: { history } }, }); - const header = wrapper.find('ScreenHeader'); - expect(header.prop('streamType')).toBe('project'); - expect(header.prop('breadcrumbConfig')).toEqual({ - '/projects': 'Projects', - '/projects/add': 'Create New Project', - }); + // ScreenHeader renders the "Projects" breadcrumb title for this route + expect(screen.getByText('Projects')).toBeInTheDocument(); + expect(screen.getByText('ProjectsList')).toBeInTheDocument(); + // streamType="project" wires the activity stream link query param + expect( + screen.getByRole('link', { name: 'View activity stream' }) + ).toHaveAttribute('href', '/activity_stream?type=project'); }); }); diff --git a/awx/ui/src/screens/Project/shared/ProjectForm.test.js b/awx/ui/src/screens/Project/shared/ProjectForm.test.js index 4ea4a0d58..6b2b2dbc9 100644 --- a/awx/ui/src/screens/Project/shared/ProjectForm.test.js +++ b/awx/ui/src/screens/Project/shared/ProjectForm.test.js @@ -1,16 +1,12 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; +import { screen, waitFor } from '@testing-library/react'; import { CredentialTypesAPI, ProjectsAPI, RootAPI } from 'api'; -import { - mountWithContexts, - waitForElement, -} from '../../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../../testUtils/rtlContexts'; import ProjectForm from './ProjectForm'; jest.mock('../../../api'); describe('', () => { - let wrapper; const mockData = { name: 'foo', description: 'bar', @@ -87,20 +83,17 @@ describe('', () => { }, }; - beforeEach(async () => { + beforeEach(() => { RootAPI.readAssetVariables.mockResolvedValue({ data: { BRAND_NAME: 'AWX', }, }); - await ProjectsAPI.readOptions.mockImplementation( - () => projectOptionsResolve - ); - await CredentialTypesAPI.read.mockImplementation( - () => scmCredentialResolve - ); - await CredentialTypesAPI.read.mockImplementation( - () => cryptographyCredentialResolve + ProjectsAPI.readOptions.mockResolvedValue(projectOptionsResolve); + CredentialTypesAPI.read.mockImplementation(({ kind }) => + kind === 'cryptography' + ? cryptographyCredentialResolve + : scmCredentialResolve ); }); @@ -108,136 +101,59 @@ describe('', () => { jest.clearAllMocks(); }); - test('initially renders successfully', async () => { - await act(async () => { - wrapper = mountWithContexts( - - ); - }); - - expect(wrapper.find('ProjectForm').length).toBe(1); - }); - test('new form displays primary form fields', async () => { - await act(async () => { - wrapper = mountWithContexts( - - ); - }); - await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); - expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1); - expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1); - expect(wrapper.find('FormGroup[label="Organization"]').length).toBe(1); - expect(wrapper.find('FormGroup[label="Source Control Type"]').length).toBe( - 1 - ); - expect(wrapper.find('FormGroup[label="Ansible Environment"]').length).toBe( - 0 + renderWithContexts( + ); - expect(wrapper.find('FormGroup[label="Options"]').length).toBe(0); + expect(await screen.findByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Organization')).toBeInTheDocument(); + expect(screen.getByText('Source Control Type')).toBeInTheDocument(); + // primary form (no scm type selected) hides the scm subform fields + expect(screen.queryByText('Source Control URL')).not.toBeInTheDocument(); }); test('should display scm subform when scm type select has a value', async () => { - await act(async () => { - wrapper = mountWithContexts( - - ); - }); - await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); - await act(async () => { - await wrapper.find('AnsibleSelect[id="scm_type"]').invoke('onChange')( - null, - 'git' - ); - }); - wrapper.update(); - expect(wrapper.find('FormGroup[label="Source Control URL"]').length).toBe( - 1 + const { user } = renderWithContexts( + ); + await screen.findByText('Source Control Type'); + + // AnsibleSelect renders a native const scmSelect = document.querySelector('#scm_type'); + expect(scmSelect).toBeInTheDocument(); await user.selectOptions(scmSelect, 'git'); expect(await screen.findByText('Source Control URL')).toBeInTheDocument(); diff --git a/awx/ui/src/screens/Project/shared/ProjectSyncButton.test.js b/awx/ui/src/screens/Project/shared/ProjectSyncButton.test.js index d82ae35c2..b2fb3a4b1 100644 --- a/awx/ui/src/screens/Project/shared/ProjectSyncButton.test.js +++ b/awx/ui/src/screens/Project/shared/ProjectSyncButton.test.js @@ -53,16 +53,20 @@ describe('ProjectSyncButton', () => { expect(screen.getByRole('button', { name: 'Sync Project' })).toBeDisabled(); }); - test('should render tooltip wrapper on disabled sync', async () => { - const { container } = renderWithContexts( + test('shows an explanatory tooltip on disabled sync', async () => { + const { user } = renderWithContexts( {children} ); - // disabled state wraps the button in a Tooltip-controlled div - expect(container.querySelector('div > button')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Sync Project' })).toBeDisabled(); + const button = screen.getByRole('button', { name: 'Sync Project' }); + expect(button).toBeDisabled(); + + // hovering the disabled button reveals the explanatory Tooltip, which is + // only rendered on the disabled (running) path + await user.hover(button); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); }); test('displays error modal after unsuccessful sync', async () => { From c14865843790921986b9c072dd80d956d8eb1e0c Mon Sep 17 00:00:00 2001 From: blaipr Date: Wed, 17 Jun 2026 10:34:19 +0200 Subject: [PATCH 3/3] Remove accidentally committed node_modules symlink awx/ui/node_modules was committed as a self-referential symlink; .gitignore only excludes the directory contents, not the symlink itself. Checking out the branch lays this broken link over a real node_modules. Untrack it. --- awx/ui/node_modules | 1 - 1 file changed, 1 deletion(-) delete mode 120000 awx/ui/node_modules diff --git a/awx/ui/node_modules b/awx/ui/node_modules deleted file mode 120000 index 3bc2f7e8d..000000000 --- a/awx/ui/node_modules +++ /dev/null @@ -1 +0,0 @@ -/home/blai/projects/ascender/awx/ui/node_modules \ No newline at end of file