foreman_remote_execution 17.2.0 → 17.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e240991349f9cb6bc60f979202f8de2bde03d3d8100c13e701469aa2f8e29a97
4
- data.tar.gz: f9936d90d62ce33221969367e5625cdf750c36922855142f460f46b18a8bc925
3
+ metadata.gz: b2273dcbf9a1bd18e838f9312d802e752f181723049c543895e2ba9bbe47309b
4
+ data.tar.gz: 4be8496d42f9abcd54116d8e0dc09669d0e8c67674341e9672601450dc77a57a
5
5
  SHA512:
6
- metadata.gz: eaf96768e4b112d387e660cca80d6b9dd360dd1145cf40b5540ea428bdf4bf3482e47591a05d2e2f0bcc05e36808e8163346258f323cc7f7f60917db3b930686
7
- data.tar.gz: 56dbf3ac2a8cb8f90c8ae719fc5213c2fbf40588812bf98552fa8bdd967b6d1150632543dfb6992df09050d74813dd3eeb367cc857c3709a921a404b3772c823
6
+ metadata.gz: 0656e10eb461a75898d1359ab6c03f5e10a6bbac18e03bac8ca804afd06f1050dc6bb6fb8a05ca0ced9a725dc4cd6b6202e164fca9a0ecf74179708aaa05d955
7
+ data.tar.gz: 76bfdccd452647bbdd56866eb6175c1efbb1efde242ef416a23902e429b1be6099e3a1f00ae9ddd1f2e956a21cb28baf099e31934ab501e112532934fdc7eadd
@@ -2,6 +2,7 @@ class JobInvocationsController < ApplicationController
2
2
  include ::Foreman::Controller::AutoCompleteSearch
3
3
  include ::ForemanTasks::Concerns::Parameters::Triggering
4
4
  include ::JobInvocationsChartHelper
5
+ include RemoteExecutionHelper
5
6
 
6
7
  def new
7
8
  return @composer = prepare_composer if params[:feature].present?
@@ -110,6 +111,20 @@ class JobInvocationsController < ApplicationController
110
111
  render :partial => 'job_invocations/preview_hosts_list'
111
112
  end
112
113
 
114
+ def report
115
+ @job_invocation = resource_base.find(params[:id])
116
+ template = job_report_template
117
+ unless template
118
+ return not_found(_('Report template not found or not configured properly'))
119
+ end
120
+
121
+ unless ReportTemplate.authorized(:generate_report_templates).find_by(:id => template.id)
122
+ return render_403(_('Missing permissions to generate report templates'))
123
+ end
124
+
125
+ redirect_to generate_report_template_path(template, job_report_template_parameters(@job_invocation, template))
126
+ end
127
+
113
128
  def cancel
114
129
  @job_invocation = resource_base.find(params[:id])
115
130
  result = @job_invocation.cancel(params[:force])
@@ -159,7 +174,7 @@ class JobInvocationsController < ApplicationController
159
174
  'create'
160
175
  when 'cancel'
161
176
  'cancel'
162
- when 'chart', 'preview_job_invocations_per_host'
177
+ when 'chart', 'preview_job_invocations_per_host', 'report'
163
178
  'view'
164
179
  else
165
180
  super
data/config/routes.rb CHANGED
@@ -36,6 +36,7 @@ Rails.application.routes.draw do
36
36
  end
37
37
  member do
38
38
  post 'cancel'
39
+ get 'report'
39
40
  end
40
41
  end
41
42
 
@@ -70,7 +71,7 @@ Rails.application.routes.draw do
70
71
  get 'hosts'
71
72
  post 'cancel'
72
73
  post 'rerun'
73
- get 'template_invocations', :to => 'template_invocations#template_invocations'
74
+ get 'template_invocations', :to => 'template_invocations#template_invocations'
74
75
  get 'outputs'
75
76
  post 'outputs'
76
77
  end
@@ -143,7 +143,7 @@ Foreman::Plugin.register :foreman_remote_execution do
143
143
  permission :lock_job_templates, { :job_templates => [:lock, :unlock] }, :resource_type => 'JobTemplate'
144
144
  permission :create_job_invocations, { :job_invocations => [:new, :create, :legacy_create, :refresh, :rerun, :preview_hosts],
145
145
  'api/v2/job_invocations' => [:create, :rerun] }, :resource_type => 'JobInvocation'
146
- permission :view_job_invocations, { :job_invocations => [:index, :chart, :show, :auto_complete_search, :preview_job_invocations_per_host], :template_invocations => [:show, :show_template_invocation_by_host],
146
+ permission :view_job_invocations, { :job_invocations => [:index, :chart, :show, :auto_complete_search, :preview_job_invocations_per_host, :report], :template_invocations => [:show, :show_template_invocation_by_host],
147
147
  'api/v2/job_invocations' => [:index, :show, :output, :raw_output, :outputs, :hosts] }, :resource_type => 'JobInvocation'
148
148
  permission :view_template_invocations, { :template_invocations => [:show, :template_invocation_preview, :show_template_invocation_by_host],
149
149
  'api/v2/template_invocations' => [:template_invocations], :ui_job_wizard => [:job_invocation] }, :resource_type => 'TemplateInvocation'
@@ -1,3 +1,3 @@
1
1
  module ForemanRemoteExecution
2
- VERSION = '17.2.0'.freeze
2
+ VERSION = '17.2.1'.freeze
3
3
  end
@@ -60,6 +60,90 @@ class JobInvocationsControllerTest < ActionController::TestCase
60
60
  assert_response :success
61
61
  end
62
62
 
63
+ context '#report' do
64
+ setup do
65
+ @invocation = FactoryBot.create(:job_invocation, :with_template, :with_task)
66
+ @report_template = FactoryBot.create(:report_template, :name => 'Job - Invocation Report', :template => '<%= "report output" %>')
67
+ @report_template.template_inputs.create!(:name => 'job_id', :input_type => 'user')
68
+ Setting['remote_execution_job_invocation_report_template'] = @report_template.name
69
+ end
70
+
71
+ test 'should redirect to report generation page' do
72
+ get :report, params: { :id => @invocation.id }, session: set_session_user
73
+ template_input = @report_template.template_inputs.where(name: 'job_id').first
74
+ expected_params = {
75
+ report_template_report: {
76
+ input_values: {
77
+ "#{template_input.id}": {
78
+ value: @invocation.id,
79
+ },
80
+ },
81
+ },
82
+ }
83
+ assert_redirected_to generate_report_template_path(@report_template, expected_params)
84
+ end
85
+
86
+ test 'should redirect to report generation page with custom template name' do
87
+ custom_template = FactoryBot.create(:report_template, :name => 'My Custom Job Report', :template => '<%= "custom report" %>')
88
+ custom_template.template_inputs.create!(:name => 'job_id', :input_type => 'user')
89
+ Setting['remote_execution_job_invocation_report_template'] = custom_template.name
90
+
91
+ get :report, params: { :id => @invocation.id }, session: set_session_user
92
+ template_input = custom_template.template_inputs.where(name: 'job_id').first
93
+ expected_params = {
94
+ report_template_report: {
95
+ input_values: {
96
+ "#{template_input.id}": {
97
+ value: @invocation.id,
98
+ },
99
+ },
100
+ },
101
+ }
102
+ assert_redirected_to generate_report_template_path(custom_template, expected_params)
103
+ end
104
+
105
+ test 'should return 404 when report template is not configured' do
106
+ Setting['remote_execution_job_invocation_report_template'] = 'Nonexistent Template'
107
+ get :report, params: { :id => @invocation.id }, session: set_session_user
108
+ assert_response :not_found
109
+ end
110
+
111
+ test 'should deny access when user lacks generate_report_templates permission' do
112
+ user = FactoryBot.create(:user, :admin => false)
113
+ @report_template.organizations = user.organizations
114
+ @report_template.locations = user.locations
115
+ setup_user('view', 'job_invocations', nil, user)
116
+ setup_user('view', 'hosts', nil, user)
117
+ setup_user('view', 'report_templates', nil, user)
118
+
119
+ get :report, params: { :id => @invocation.id }, session: set_session_user(user)
120
+ assert_response :forbidden
121
+ end
122
+
123
+ test 'should redirect when user has generate_report_templates permission' do
124
+ user = FactoryBot.create(:user, :admin => false)
125
+ @report_template.organizations = user.organizations
126
+ @report_template.locations = user.locations
127
+ setup_user('view', 'job_invocations', nil, user)
128
+ setup_user('view', 'hosts', nil, user)
129
+ setup_user('view', 'report_templates', nil, user)
130
+ setup_user('generate', 'report_templates', nil, user)
131
+
132
+ get :report, params: { :id => @invocation.id }, session: set_session_user(user)
133
+ template_input = @report_template.template_inputs.where(name: 'job_id').first
134
+ expected_params = {
135
+ report_template_report: {
136
+ input_values: {
137
+ "#{template_input.id}": {
138
+ value: @invocation.id,
139
+ },
140
+ },
141
+ },
142
+ }
143
+ assert_redirected_to generate_report_template_path(@report_template, expected_params)
144
+ end
145
+ end
146
+
63
147
  context 'restricted access' do
64
148
  setup do
65
149
  @admin = users(:admin)
@@ -10,8 +10,6 @@ export const CANCEL_JOB = 'CANCEL_JOB';
10
10
  export const GET_TEMPLATE_INVOCATIONS = 'GET_TEMPLATE_INVOCATIONS';
11
11
  export const CHANGE_ENABLED_RECURRING_LOGIC = 'CHANGE_ENABLED_RECURRING_LOGIC';
12
12
  export const CANCEL_RECURRING_LOGIC = 'CANCEL_RECURRING_LOGIC';
13
- export const GET_REPORT_TEMPLATES = 'GET_REPORT_TEMPLATES';
14
- export const GET_REPORT_TEMPLATE_INPUTS = 'GET_REPORT_TEMPLATE_INPUTS';
15
13
  export const JOB_INVOCATION_HOSTS = 'JOB_INVOCATION_HOSTS';
16
14
  export const GET_TEMPLATE_INVOCATION = 'GET_TEMPLATE_INVOCATION';
17
15
  export const DIRECT_OPEN_HOST_LIMIT = 3;
@@ -1,6 +1,5 @@
1
- /* eslint-disable max-lines */
2
1
  import PropTypes from 'prop-types';
3
- import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import React, { useCallback, useMemo, useState } from 'react';
4
3
  import { useDispatch, useSelector } from 'react-redux';
5
4
  import { Button, Split, SplitItem } from '@patternfly/react-core';
6
5
  import { UndoIcon } from '@patternfly/react-icons';
@@ -14,17 +13,12 @@ import {
14
13
  import { translate as __ } from 'foremanReact/common/I18n';
15
14
  import { foremanUrl } from 'foremanReact/common/helpers';
16
15
  import { usePermissions } from 'foremanReact/common/hooks/Permissions/permissionHooks';
17
- import { get } from 'foremanReact/redux/API';
18
16
  import {
19
17
  cancelJob,
20
18
  cancelRecurringLogic,
21
19
  enableRecurringLogic,
22
20
  } from './JobInvocationActions';
23
- import {
24
- STATUS,
25
- GET_REPORT_TEMPLATES,
26
- GET_REPORT_TEMPLATE_INPUTS,
27
- } from './JobInvocationConstants';
21
+ import { STATUS } from './JobInvocationConstants';
28
22
  import { selectTaskCancelable } from './JobInvocationSelectors';
29
23
 
30
24
  const JobInvocationToolbarButtons = ({ jobId, data }) => {
@@ -43,26 +37,13 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => {
43
37
  'generate_report_templates',
44
38
  ]);
45
39
  const [isActionOpen, setIsActionOpen] = useState(false);
46
- const [reportTemplateJobId, setReportTemplateJobId] = useState(undefined);
47
- const [templateInputId, setTemplateInputId] = useState(undefined);
48
40
  const dispatch = useDispatch();
49
- const reportHref = useMemo(() => {
50
- if (reportTemplateJobId === undefined || templateInputId === undefined) {
51
- return undefined;
52
- }
53
- const queryParams = new URLSearchParams({
54
- [`report_template_report[input_values][${templateInputId}][value]`]: jobId,
55
- });
56
- return foremanUrl(
57
- `/templates/report_templates/${reportTemplateJobId}/generate?${queryParams.toString()}`
58
- );
59
- }, [jobId, reportTemplateJobId, templateInputId]);
41
+ const reportHref = foremanUrl(`/job_invocations/${jobId}/report`);
60
42
 
61
43
  const isCreateReportDisabled =
62
44
  !canGenerateReportTemplates ||
63
45
  task?.state === STATUS.RUNNING ||
64
- task?.state === STATUS.PENDING ||
65
- reportHref === undefined;
46
+ task?.state === STATUS.PENDING;
66
47
 
67
48
  const onActionFocus = useCallback(() => {
68
49
  const element = document.getElementById(
@@ -78,58 +59,6 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => {
78
59
  }, [onActionFocus]);
79
60
  const onActionToggle = useCallback((_event, val) => setIsActionOpen(val), []);
80
61
 
81
- useEffect(() => {
82
- let isMounted = true;
83
- dispatch(
84
- get({
85
- key: GET_REPORT_TEMPLATES,
86
- url: '/api/report_templates',
87
- handleSuccess: ({ data: { results } }) => {
88
- if (isMounted) {
89
- setReportTemplateJobId(
90
- results.find(result => result.name === 'Job - Invocation Report')
91
- ?.id
92
- );
93
- }
94
- },
95
- handleError: () => {
96
- if (isMounted) {
97
- setReportTemplateJobId(undefined);
98
- }
99
- },
100
- })
101
- );
102
- return () => {
103
- isMounted = false;
104
- };
105
- }, [dispatch]);
106
- useEffect(() => {
107
- let isMounted = true;
108
- if (reportTemplateJobId !== undefined) {
109
- dispatch(
110
- get({
111
- key: GET_REPORT_TEMPLATE_INPUTS,
112
- url: `/api/templates/${reportTemplateJobId}/template_inputs`,
113
- handleSuccess: ({ data: { results } }) => {
114
- if (isMounted) {
115
- setTemplateInputId(
116
- results.find(result => result.name === 'job_id')?.id
117
- );
118
- }
119
- },
120
- handleError: () => {
121
- if (isMounted) {
122
- setTemplateInputId(undefined);
123
- }
124
- },
125
- })
126
- );
127
- }
128
- return () => {
129
- isMounted = false;
130
- };
131
- }, [dispatch, reportTemplateJobId]);
132
-
133
62
  const recurrenceDropdownItems = useMemo(
134
63
  () =>
135
64
  recurrence
@@ -262,7 +191,7 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => {
262
191
  variant="control"
263
192
  isDisabled={!canCreateJobInvocations}
264
193
  >
265
- {__(`Rerun all`)}
194
+ {__('Rerun all')}
266
195
  </Button>,
267
196
  ]}
268
197
  splitButtonVariant="action"
@@ -279,12 +208,11 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => {
279
208
  <Button
280
209
  component="a"
281
210
  ouiaId="button-create-report"
282
- className="button-create-report"
283
211
  href={reportHref}
284
212
  variant="secondary"
285
213
  isDisabled={isCreateReportDisabled}
286
214
  >
287
- {__(`Create report`)}
215
+ {__('Create report')}
288
216
  </Button>
289
217
  </SplitItem>
290
218
  <SplitItem>
@@ -15,8 +15,6 @@ import {
15
15
  jobInvocationData,
16
16
  jobInvocationDataScheduled,
17
17
  jobInvocationDataRecurring,
18
- mockReportTemplatesResponse,
19
- mockReportTemplateInputsResponse,
20
18
  } from './fixtures';
21
19
  import {
22
20
  cancelJob,
@@ -28,8 +26,6 @@ import {
28
26
  CANCEL_JOB,
29
27
  CANCEL_RECURRING_LOGIC,
30
28
  CHANGE_ENABLED_RECURRING_LOGIC,
31
- GET_REPORT_TEMPLATES,
32
- GET_REPORT_TEMPLATE_INPUTS,
33
29
  JOB_INVOCATION_KEY,
34
30
  } from '../JobInvocationConstants';
35
31
 
@@ -83,23 +79,9 @@ jest.mock('foremanReact/routes/common/PageLayout/PageLayout', () =>
83
79
  );
84
80
 
85
81
  const setupApiMocks = () => {
86
- api.get.mockImplementation(({ handleSuccess, key, ...action }) => {
87
- if (key === GET_REPORT_TEMPLATES) {
88
- if (handleSuccess) {
89
- handleSuccess({
90
- data: mockReportTemplatesResponse,
91
- });
92
- }
93
- } else if (key === GET_REPORT_TEMPLATE_INPUTS) {
94
- if (handleSuccess) {
95
- handleSuccess({
96
- data: mockReportTemplateInputsResponse,
97
- });
98
- }
99
- }
100
-
101
- return { type: 'get', key, ...action };
102
- });
82
+ api.get.mockImplementation(({ handleSuccess, key, ...action }) =>
83
+ ({ type: 'get', key, ...action })
84
+ );
103
85
  APIActions.post.mockImplementation(payload => ({ type: 'post', ...payload }));
104
86
  APIActions.put.mockImplementation(payload => ({ type: 'put', ...payload }));
105
87
  };
@@ -131,8 +113,6 @@ jest.mock('../JobInvocationHostTable.js', () => () => (
131
113
  <div data-testid="mock-table">Mock Table</div>
132
114
  ));
133
115
 
134
- const reportTemplateJobId = mockReportTemplatesResponse.results[0].id;
135
-
136
116
  const defaultHistory = { push: jest.fn() };
137
117
 
138
118
  const renderJobInvocationDetailPage = (
@@ -235,9 +215,7 @@ describe('JobInvocationDetailPage', () => {
235
215
 
236
216
  // checks the global actions and if they link to the correct url
237
217
  expect(screen.getByText('Create report').getAttribute('href')).toEqual(
238
- foremanUrl(
239
- `/templates/report_templates/${mockReportTemplatesResponse.results[0].id}/generate?report_template_report%5Binput_values%5D%5B${mockReportTemplateInputsResponse.results[0].id}%5D%5Bvalue%5D=${jobId}`
240
- )
218
+ foremanUrl(`/job_invocations/${jobId}/report`)
241
219
  );
242
220
  expect(screen.getByText('Rerun all').getAttribute('href')).toEqual(
243
221
  foremanUrl(`/job_invocations/${jobId}/rerun`)
@@ -319,19 +297,6 @@ describe('JobInvocationDetailPage', () => {
319
297
  { jobId }
320
298
  );
321
299
 
322
- expect(api.get).toHaveBeenCalledWith(
323
- expect.objectContaining({
324
- key: GET_REPORT_TEMPLATES,
325
- url: '/api/report_templates',
326
- })
327
- );
328
- expect(api.get).toHaveBeenCalledWith(
329
- expect.objectContaining({
330
- key: GET_REPORT_TEMPLATE_INPUTS,
331
- url: `/api/templates/${reportTemplateJobId}/template_inputs`,
332
- })
333
- );
334
-
335
300
  api.get.mockClear();
336
301
  APIActions.post.mockClear();
337
302
  APIActions.put.mockClear();
@@ -140,14 +140,6 @@ export const mockPermissionsData = {
140
140
  edit_recurring_logics: true,
141
141
  };
142
142
 
143
- export const mockReportTemplatesResponse = {
144
- results: [{ id: '12', name: 'Job - Invocation Report' }],
145
- };
146
-
147
- export const mockReportTemplateInputsResponse = {
148
- results: [{ id: '34', name: 'job_id' }],
149
- };
150
-
151
143
  const templateInvocationID = 157;
152
144
 
153
145
  export const jobInvocationOutput = [
@@ -22,6 +22,19 @@ export const Footer = ({ canSubmit, onSave }) => {
22
22
  activeStep && activeStep.enableNext !== undefined
23
23
  ? activeStep.enableNext
24
24
  : true;
25
+ const isReviewStep = activeStep.name === WIZARD_TITLES.review;
26
+ const canSkipToReview = canSubmit && !isReviewStep;
27
+ const skipToReviewTooltip = () => {
28
+ if (canSkipToReview) {
29
+ return __('Skip to review step');
30
+ }
31
+ if (isReviewStep) {
32
+ return __('Already on the review step');
33
+ }
34
+ return __(
35
+ 'Fill all required fields in all the steps to start the job'
36
+ );
37
+ };
25
38
 
26
39
  return (
27
40
  <>
@@ -32,9 +45,7 @@ export const Footer = ({ canSubmit, onSave }) => {
32
45
  onClick={onNext}
33
46
  isDisabled={!isValid || isSubmitting}
34
47
  >
35
- {activeStep.name === WIZARD_TITLES.review
36
- ? __('Submit')
37
- : __('Next')}
48
+ {isReviewStep ? __('Submit') : __('Next')}
38
49
  </Button>
39
50
  <Button
40
51
  ouiaId="back-footer"
@@ -68,22 +79,14 @@ export const Footer = ({ canSubmit, onSave }) => {
68
79
  </Button>
69
80
  </Tooltip>
70
81
  <Tooltip
71
- content={
72
- <div>
73
- {canSubmit
74
- ? __('Skip to review step')
75
- : __(
76
- 'Fill all required fields in all the steps to start the job'
77
- )}
78
- </div>
79
- }
82
+ content={<div>{skipToReviewTooltip()}</div>}
80
83
  triggerRef={tooltipSkipTo}
81
84
  >
82
85
  <Button
83
86
  ouiaId="skip-to-review-footer"
84
87
  variant="tertiary"
85
88
  onClick={() => goToStepByName(WIZARD_TITLES.review)}
86
- isAriaDisabled={!canSubmit}
89
+ isAriaDisabled={!canSkipToReview}
87
90
  isDisabled={isSubmitting}
88
91
  ref={tooltipSkipTo}
89
92
  >
@@ -0,0 +1,64 @@
1
+ import React from 'react';
2
+ import { Provider } from 'react-redux';
3
+ import { render, fireEvent, screen, act } from '@testing-library/react';
4
+ import '@testing-library/jest-dom';
5
+ import { Wizard } from '@patternfly/react-core/deprecated';
6
+ import configureMockStore from 'redux-mock-store';
7
+ import { Footer } from '../Footer';
8
+ import { WIZARD_TITLES } from '../JobWizardConstants';
9
+
10
+ const store = configureMockStore([])({ API: {} });
11
+
12
+ const renderFooterWizard = ({ canSubmit = true, startAtStep = 1 } = {}) =>
13
+ render(
14
+ <Provider store={store}>
15
+ <Wizard
16
+ steps={[
17
+ {
18
+ name: WIZARD_TITLES.categoryAndTemplate,
19
+ component: <div>category step</div>,
20
+ },
21
+ {
22
+ name: WIZARD_TITLES.review,
23
+ component: <div>review step</div>,
24
+ },
25
+ ]}
26
+ startAtStep={startAtStep}
27
+ footer={<Footer canSubmit={canSubmit} onSave={jest.fn()} />}
28
+ />
29
+ </Provider>
30
+ );
31
+
32
+ describe('Job wizard footer', () => {
33
+ it('enables Skip to review when required fields are filled', () => {
34
+ renderFooterWizard({ canSubmit: true });
35
+
36
+ expect(screen.getByText('Skip to review')).not.toHaveAttribute(
37
+ 'aria-disabled',
38
+ 'true'
39
+ );
40
+ });
41
+
42
+ it('disables Skip to review when required fields are missing', () => {
43
+ renderFooterWizard({ canSubmit: false });
44
+
45
+ expect(screen.getByText('Skip to review')).toHaveAttribute(
46
+ 'aria-disabled',
47
+ 'true'
48
+ );
49
+ });
50
+
51
+ it('disables Skip to review on the review step', async () => {
52
+ renderFooterWizard({ canSubmit: true });
53
+
54
+ await act(async () => {
55
+ fireEvent.click(screen.getByText('Skip to review'));
56
+ });
57
+
58
+ expect(screen.getByText('Submit')).toBeInTheDocument();
59
+ expect(screen.getByText('Skip to review')).toHaveAttribute(
60
+ 'aria-disabled',
61
+ 'true'
62
+ );
63
+ });
64
+ });
@@ -1,7 +1,8 @@
1
1
  import React from 'react';
2
2
  import { Provider } from 'react-redux';
3
- import { mount } from '@theforeman/test';
4
- import { render, fireEvent, screen, act } from '@testing-library/react';
3
+ import { render, screen, within, waitFor } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import '@testing-library/jest-dom';
5
6
  import { MockedProvider } from '@apollo/client/testing';
6
7
  import * as api from 'foremanReact/redux/API';
7
8
  import { JobWizard } from '../JobWizard';
@@ -17,17 +18,55 @@ import {
17
18
 
18
19
  const store = testSetup(selectors, api);
19
20
 
21
+ const renderJobWizard = ({ withGql = false } = {}) => {
22
+ const wizard = (
23
+ <Provider store={store}>
24
+ <JobWizard />
25
+ </Provider>
26
+ );
27
+
28
+ if (withGql) {
29
+ return render(
30
+ <MockedProvider mocks={gqlMock} addTypename={false}>
31
+ {wizard}
32
+ </MockedProvider>
33
+ );
34
+ }
35
+
36
+ return render(wizard);
37
+ };
38
+
39
+ const getWizardNavigation = () =>
40
+ screen.getByRole('navigation', { name: 'Run Job steps' });
41
+
42
+ const getWizardStep = stepName =>
43
+ within(getWizardNavigation()).getByRole('button', { name: stepName });
44
+
45
+ const getDisabledWizardSteps = () =>
46
+ within(getWizardNavigation())
47
+ .getAllByRole('button')
48
+ .filter(button => button.getAttribute('aria-disabled') === 'true');
49
+
50
+ const expectDispatchedGet = expected => {
51
+ expect(store.getActions()).toEqual(
52
+ expect.arrayContaining([expect.objectContaining({ type: 'get', ...expected })])
53
+ );
54
+ };
55
+
20
56
  describe('Job wizard fill', () => {
21
57
  beforeEach(() => {
58
+ store.clearActions();
22
59
  jest.spyOn(selectors, 'selectRouterSearch');
23
60
  selectors.selectRouterSearch.mockImplementation(() => ({
24
61
  'host_ids[]': ['105', '37'],
25
62
  }));
26
63
  });
64
+
27
65
  afterEach(() => {
28
66
  selectors.selectRouterSearch.mockRestore();
29
67
  });
30
- it('should select template', async () => {
68
+
69
+ it('selects a template and enables wizard steps', async () => {
31
70
  api.get.mockImplementation(({ handleSuccess, ...action }) => {
32
71
  if (action.key === 'JOB_CATEGORIES') {
33
72
  handleSuccess &&
@@ -54,74 +93,80 @@ describe('Job wizard fill', () => {
54
93
  selectors.selectJobTemplate.mockRestore();
55
94
  jest.spyOn(selectors, 'selectJobTemplate');
56
95
  selectors.selectJobTemplate.mockImplementation(() => ({}));
57
- const wrapper = mount(
58
- <Provider store={store}>
59
- <JobWizard />
60
- </Provider>
61
- );
62
- expect(
63
- wrapper.find('.pf-v5-c-wizard__nav-link.pf-m-disabled')
64
- ).toHaveLength(5);
96
+
97
+ renderJobWizard();
98
+
99
+ expect(getDisabledWizardSteps()).toHaveLength(5);
100
+ expect(getWizardStep(WIZARD_TITLES.categoryAndTemplate)).toBeInTheDocument();
101
+
102
+ expectDispatchedGet({
103
+ key: 'JOB_CATEGORIES',
104
+ url: '/ui_job_wizard/categories',
105
+ });
106
+ expectDispatchedGet({
107
+ key: 'HOST_IDS',
108
+ params: { search: 'id = 105 or id = 37' },
109
+ url: '/api/hosts',
110
+ });
111
+ expectDispatchedGet({
112
+ key: 'JOB_TEMPLATES',
113
+ });
114
+
65
115
  selectors.selectJobCategoriesStatus.mockImplementation(() => 'RESOLVED');
66
- expect(store.getActions()).toMatchSnapshot('initial');
67
116
  selectors.selectJobTemplate.mockRestore();
68
117
  jest.spyOn(selectors, 'selectJobTemplate');
69
118
  selectors.selectJobTemplate.mockImplementation(() => jobTemplate);
70
- wrapper
71
- .find('.pf-v5-c-button.pf-v5-c-select__toggle-button')
72
- .simulate('click');
73
- await act(async () => {
74
- await wrapper
75
- .find('.pf-v5-c-select__menu-item')
76
- .first()
77
- .simulate('click');
119
+
120
+ await userEvent.click(
121
+ screen.getByRole('button', { name: 'Job template toggle' })
122
+ );
123
+ await userEvent.click(screen.getByText(jobTemplate.job_template.name));
124
+
125
+ expect(store.getActions().at(-1)).toEqual(
126
+ expect.objectContaining({
127
+ key: 'JOB_TEMPLATE',
128
+ type: 'get',
129
+ url: '/ui_job_wizard/template/178',
130
+ })
131
+ );
132
+
133
+ await waitFor(() => {
134
+ expect(getDisabledWizardSteps()).toHaveLength(0);
78
135
  });
79
- expect(store.getActions().slice(-1)).toMatchSnapshot('select template');
80
- wrapper.update();
81
- expect(
82
- wrapper.find('.pf-v5-c-wizard__nav-link.pf-m-disabled')
83
- ).toHaveLength(0);
84
136
  });
85
137
 
86
- it('have all steps', async () => {
138
+ it('renders all wizard steps and navigates between them', async () => {
87
139
  selectors.selectJobCategoriesStatus.mockImplementation(() => null);
88
140
  selectors.selectJobTemplates.mockRestore();
89
141
  selectors.selectJobCategories.mockRestore();
90
142
  mockApi(api);
91
143
 
92
- render(
93
- <MockedProvider mocks={gqlMock} addTypename={false}>
94
- <Provider store={store}>
95
- <JobWizard />
96
- </Provider>
97
- </MockedProvider>
98
- );
144
+ renderJobWizard({ withGql: true });
145
+
99
146
  const steps = [
100
147
  WIZARD_TITLES.hostsAndInputs,
101
148
  WIZARD_TITLES.categoryAndTemplate,
102
149
  WIZARD_TITLES.advanced,
103
150
  WIZARD_TITLES.review,
104
151
  ];
105
- // eslint-disable-next-line no-unused-vars
106
- for await (const step of steps) {
107
- const stepSelector = screen.getByText(step);
108
- const stepTitle = screen.getAllByText(step);
109
- expect(stepTitle).toHaveLength(1);
110
- await act(async () => {
111
- await fireEvent.click(stepSelector);
112
- });
113
- const stepTitles = screen.getAllByText(step);
114
- expect(stepTitles).toHaveLength(3);
152
+
153
+ for (const step of steps) {
154
+ expect(screen.getAllByText(step)).toHaveLength(1);
155
+
156
+ await userEvent.click(getWizardStep(step));
157
+
158
+ expect(screen.getAllByText(step)).toHaveLength(3);
115
159
  }
116
- const step = WIZARD_TITLES.typeOfExecution;
117
- const stepTitle = screen.getAllByText(step);
118
- expect(stepTitle).toHaveLength(1);
119
- expect(screen.queryAllByText('Select the type of execution')).toHaveLength(
120
- 0
121
- );
122
- await act(async () => {
123
- await fireEvent.click(stepTitle[0]);
124
- });
125
- expect(screen.getAllByText('Select the type of execution')).toHaveLength(1);
160
+
161
+ expect(screen.getAllByText(WIZARD_TITLES.typeOfExecution)).toHaveLength(1);
162
+ expect(
163
+ screen.queryByText('Select the type of execution')
164
+ ).not.toBeInTheDocument();
165
+
166
+ await userEvent.click(getWizardStep(WIZARD_TITLES.typeOfExecution));
167
+
168
+ expect(
169
+ screen.getByText('Select the type of execution')
170
+ ).toBeInTheDocument();
126
171
  });
127
172
  });
@@ -1,6 +1,76 @@
1
- import { testComponentSnapshotsWithFixtures } from '@theforeman/test';
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import '@testing-library/jest-dom';
2
5
  import HostItem from '../components/HostItem';
3
6
  import { HostItemFixtures } from './fixtures';
4
7
 
5
- describe('HostItem', () =>
6
- testComponentSnapshotsWithFixtures(HostItem, HostItemFixtures));
8
+ jest.unmock('foremanReact/components/common/ActionButtons/ActionButtons');
9
+
10
+ const renderHostItem = props =>
11
+ render(
12
+ <table>
13
+ <tbody>
14
+ <HostItem {...props} />
15
+ </tbody>
16
+ </table>
17
+ );
18
+
19
+ describe('HostItem', () => {
20
+ it('renders the host name as a link with the provided URL', () => {
21
+ renderHostItem(HostItemFixtures.renders);
22
+
23
+ expect(screen.getByRole('link', { name: 'Host1' })).toHaveAttribute(
24
+ 'href',
25
+ '/host1'
26
+ );
27
+ });
28
+
29
+ it('renders the host status', () => {
30
+ renderHostItem(HostItemFixtures.renders);
31
+
32
+ expect(screen.getByText('Succeeded')).toBeInTheDocument();
33
+ });
34
+
35
+ it('renders no action buttons when actions are empty', () => {
36
+ renderHostItem(HostItemFixtures.renders);
37
+
38
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
39
+ });
40
+
41
+ it('renders a disabled link when link is not provided', () => {
42
+ renderHostItem({
43
+ ...HostItemFixtures.renders,
44
+ link: '',
45
+ });
46
+
47
+ expect(screen.getByRole('link', { name: 'Host1' })).toHaveAttribute(
48
+ 'href',
49
+ '#'
50
+ );
51
+ });
52
+
53
+ it('renders action buttons and calls action on click', async () => {
54
+ const onClick = jest.fn();
55
+
56
+ renderHostItem({
57
+ ...HostItemFixtures.renders,
58
+ actions: [{ title: 'View output', action: { onClick } }],
59
+ });
60
+
61
+ await userEvent.click(
62
+ screen.getByRole('button', { name: 'View output' })
63
+ );
64
+
65
+ expect(onClick).toHaveBeenCalledTimes(1);
66
+ });
67
+
68
+ it('renders failed status text for error status', () => {
69
+ renderHostItem({
70
+ ...HostItemFixtures.renders,
71
+ status: 'error',
72
+ });
73
+
74
+ expect(screen.getByText('Failed')).toBeInTheDocument();
75
+ });
76
+ });
@@ -1,43 +1,76 @@
1
1
  import React from 'react';
2
- import { shallow } from '@theforeman/test';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import '@testing-library/jest-dom';
3
5
  import AggregateStatus from './index';
4
6
 
5
- jest.unmock('./index.js');
7
+ const defaultStatuses = {
8
+ success: 19,
9
+ failed: 20,
10
+ pending: 3,
11
+ cancelled: 31,
12
+ };
13
+
14
+ const renderAggregateStatus = ({
15
+ statuses = defaultStatuses,
16
+ chartFilter = jest.fn(),
17
+ } = {}) => {
18
+ const view = render(
19
+ <AggregateStatus statuses={statuses} chartFilter={chartFilter} />
20
+ );
21
+
22
+ return { chartFilter, ...view };
23
+ };
6
24
 
7
25
  describe('AggregateStatus', () => {
8
- describe('has no data', () => {
9
- it('renders cards with no data', () => {
10
- const chartNumbers = shallow(
11
- <AggregateStatus statuses={{}} chartFilter={_x => {}} />
12
- );
13
- const success = chartNumbers.find('#success_count').text();
14
- const failed = chartNumbers.find('#failed_count').text();
15
- const pending = chartNumbers.find('#pending_count').text();
16
- const cancelled = chartNumbers.find('#cancelled_count').text();
17
- expect(success).toBe('');
18
- expect(failed).toBe('');
19
- expect(cancelled).toBe('');
20
- expect(pending).toBe('');
21
- });
22
-
23
- it('renders cards with props passed', () => {
24
- const statuses = {
25
- success: 19,
26
- failed: 20,
27
- cancelled: 31,
28
- pending: 3,
29
- };
30
- const chartNumbers = shallow(
31
- <AggregateStatus statuses={statuses} chartFilter={_x => {}} />
32
- );
33
- const success = chartNumbers.find('#success_count').text();
34
- const failed = chartNumbers.find('#failed_count').text();
35
- const pending = chartNumbers.find('#pending_count').text();
36
- const cancelled = chartNumbers.find('#cancelled_count').text();
37
- expect(success).toBe(statuses.success.toString());
38
- expect(failed).toBe(statuses.failed.toString());
39
- expect(cancelled).toBe(statuses.cancelled.toString());
40
- expect(pending).toBe(statuses.pending.toString());
41
- });
26
+ it('renders status notifications without counts when statuses are empty', () => {
27
+ const { container } = renderAggregateStatus({ statuses: {} });
28
+
29
+ expect(container.textContent.trim()).toBe('');
30
+ });
31
+
32
+ it('renders status counts from props', () => {
33
+ renderAggregateStatus();
34
+
35
+ expect(screen.getByText('19')).toBeInTheDocument();
36
+ expect(screen.getByText('20')).toBeInTheDocument();
37
+ expect(screen.getByText('3')).toBeInTheDocument();
38
+ expect(screen.getByText('31')).toBeInTheDocument();
39
+ });
40
+
41
+ it('calls chartFilter with success when the success count is clicked', async () => {
42
+ const { chartFilter } = renderAggregateStatus();
43
+
44
+ await userEvent.click(screen.getByText('19'));
45
+
46
+ expect(chartFilter).toHaveBeenCalledTimes(1);
47
+ expect(chartFilter).toHaveBeenCalledWith('success');
48
+ });
49
+
50
+ it('calls chartFilter with failed when the failed count is clicked', async () => {
51
+ const { chartFilter } = renderAggregateStatus();
52
+
53
+ await userEvent.click(screen.getByText('20'));
54
+
55
+ expect(chartFilter).toHaveBeenCalledTimes(1);
56
+ expect(chartFilter).toHaveBeenCalledWith('failed');
57
+ });
58
+
59
+ it('calls chartFilter with pending when the pending count is clicked', async () => {
60
+ const { chartFilter } = renderAggregateStatus();
61
+
62
+ await userEvent.click(screen.getByText('3'));
63
+
64
+ expect(chartFilter).toHaveBeenCalledTimes(1);
65
+ expect(chartFilter).toHaveBeenCalledWith('pending');
66
+ });
67
+
68
+ it('calls chartFilter with cancelled when the cancelled count is clicked', async () => {
69
+ const { chartFilter } = renderAggregateStatus();
70
+
71
+ await userEvent.click(screen.getByText('31'));
72
+
73
+ expect(chartFilter).toHaveBeenCalledTimes(1);
74
+ expect(chartFilter).toHaveBeenCalledWith('cancelled');
42
75
  });
43
76
  });
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman_remote_execution
3
3
  version: !ruby/object:Gem::Version
4
- version: 17.2.0
4
+ version: 17.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foreman Remote Execution team
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-08-24 00:00:00.000000000 Z
10
+ date: 2026-09-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: deface
@@ -417,8 +417,8 @@ files:
417
417
  - webpack/JobWizard/JobWizardSelectors.js
418
418
  - webpack/JobWizard/PermissionDenied.js
419
419
  - webpack/JobWizard/StartsErrorAlert.js
420
+ - webpack/JobWizard/__tests__/Footer.test.js
420
421
  - webpack/JobWizard/__tests__/JobWizardPageRerun.test.js
421
- - webpack/JobWizard/__tests__/__snapshots__/integration.test.js.snap
422
422
  - webpack/JobWizard/__tests__/fixtures.js
423
423
  - webpack/JobWizard/__tests__/integration.test.js
424
424
  - webpack/JobWizard/__tests__/validation.test.js
@@ -525,7 +525,6 @@ files:
525
525
  - webpack/react_app/components/TargetingHosts/__tests__/TargetingHosts.test.js
526
526
  - webpack/react_app/components/TargetingHosts/__tests__/TargetingHostsPage.test.js
527
527
  - webpack/react_app/components/TargetingHosts/__tests__/TargetingHostsSelectors.test.js
528
- - webpack/react_app/components/TargetingHosts/__tests__/__snapshots__/HostItem.test.js.snap
529
528
  - webpack/react_app/components/TargetingHosts/__tests__/__snapshots__/HostStatus.test.js.snap
530
529
  - webpack/react_app/components/TargetingHosts/__tests__/__snapshots__/TargetingHosts.test.js.snap
531
530
  - webpack/react_app/components/TargetingHosts/__tests__/__snapshots__/TargetingHostsPage.test.js.snap
@@ -1,51 +0,0 @@
1
- // Jest Snapshot v1, https://goo.gl/fbAQLP
2
-
3
- exports[`Job wizard fill should select template: initial 1`] = `
4
- Array [
5
- Object {
6
- "key": "JOB_CATEGORIES",
7
- "type": "get",
8
- "url": "/ui_job_wizard/categories",
9
- },
10
- Object {
11
- "key": "HOST_IDS",
12
- "params": Object {
13
- "search": "id = 105 or id = 37",
14
- },
15
- "type": "get",
16
- "url": "/api/hosts",
17
- },
18
- Object {
19
- "key": "JOB_TEMPLATES",
20
- "type": "get",
21
- "url": URI {
22
- "_deferred_build": true,
23
- "_parts": Object {
24
- "duplicateQueryParameters": false,
25
- "escapeQuerySpace": true,
26
- "fragment": null,
27
- "hostname": null,
28
- "password": null,
29
- "path": "foreman/api/v2/job_templates",
30
- "port": null,
31
- "preventInvalidHostname": false,
32
- "protocol": null,
33
- "query": "search=job_category%3D%22Ansible+Commands%22&per_page=all",
34
- "urn": null,
35
- "username": null,
36
- },
37
- "_string": "",
38
- },
39
- },
40
- ]
41
- `;
42
-
43
- exports[`Job wizard fill should select template: select template 1`] = `
44
- Array [
45
- Object {
46
- "key": "JOB_TEMPLATE",
47
- "type": "get",
48
- "url": "/ui_job_wizard/template/178",
49
- },
50
- ]
51
- `;
@@ -1,31 +0,0 @@
1
- // Jest Snapshot v1, https://goo.gl/fbAQLP
2
-
3
- exports[`HostItem renders 1`] = `
4
- <tr
5
- id="targeting-host-Host1"
6
- >
7
- <td
8
- className="host_name"
9
- >
10
- <a
11
- href="/host1"
12
- >
13
- Host1
14
- </a>
15
- </td>
16
- <td
17
- className="host_status"
18
- >
19
- <HostStatus
20
- status="success"
21
- />
22
- </td>
23
- <td
24
- className="host_actions"
25
- >
26
- <ActionButtons
27
- buttons={Array []}
28
- />
29
- </td>
30
- </tr>
31
- `;