foreman_remote_execution 16.7.0 → 17.1.0

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/api/v2/job_invocations_controller.rb +24 -2
  3. data/app/controllers/job_invocations_controller.rb +1 -28
  4. data/app/views/api/v2/job_invocations/hosts.json.rabl +8 -0
  5. data/app/views/api/v2/job_invocations/main.json.rabl +1 -0
  6. data/app/views/job_templates/_alerts.html.erb +4 -0
  7. data/config/routes.rb +0 -1
  8. data/lib/foreman_remote_execution/plugin.rb +3 -3
  9. data/lib/foreman_remote_execution/version.rb +1 -1
  10. data/test/functional/api/v2/job_invocations_controller_test.rb +46 -7
  11. data/webpack/JobInvocationDetail/JobInvocationActions.js +86 -91
  12. data/webpack/JobInvocationDetail/JobInvocationConstants.js +6 -4
  13. data/webpack/JobInvocationDetail/JobInvocationDetail.scss +5 -3
  14. data/webpack/JobInvocationDetail/JobInvocationHostTable.js +92 -46
  15. data/webpack/JobInvocationDetail/JobInvocationSelectors.js +1 -9
  16. data/webpack/JobInvocationDetail/JobInvocationSystemStatusChart.js +5 -2
  17. data/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js +10 -12
  18. data/webpack/JobInvocationDetail/TemplateInvocation.js +38 -26
  19. data/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputCodeBlock.js +2 -1
  20. data/webpack/JobInvocationDetail/TemplateInvocationComponents/TemplateActionButtons.js +17 -6
  21. data/webpack/JobInvocationDetail/TemplateInvocationComponents/index.scss +0 -1
  22. data/webpack/JobInvocationDetail/__tests__/JobInvocationHostTablePolling.test.js +369 -0
  23. data/webpack/JobInvocationDetail/__tests__/JobInvocationPolling.test.js +228 -0
  24. data/webpack/JobInvocationDetail/__tests__/MainInformation.test.js +205 -154
  25. data/webpack/JobInvocationDetail/__tests__/TemplateInvocationPolling.test.js +254 -0
  26. data/webpack/JobInvocationDetail/__tests__/fixtures.js +2 -0
  27. data/webpack/JobInvocationDetail/index.js +25 -25
  28. data/webpack/JobWizard/JobWizard.js +5 -1
  29. data/webpack/JobWizard/JobWizard.scss +21 -0
  30. data/webpack/JobWizard/JobWizardConstants.js +10 -1
  31. data/webpack/JobWizard/steps/Schedule/RepeatHour.js +4 -2
  32. data/webpack/JobWizard/steps/Schedule/RepeatWeek.js +4 -2
  33. data/webpack/JobWizard/steps/Schedule/ScheduleRecurring.js +2 -1
  34. data/webpack/JobWizard/steps/Schedule/ScheduleType.js +4 -1
  35. data/webpack/JobWizard/steps/form/DateTimePicker.js +12 -6
  36. data/webpack/JobWizard/steps/form/FormHelpers.js +5 -4
  37. data/webpack/react_app/components/FeaturesDropdown/index.scss +2 -0
  38. data/webpack/react_app/components/TargetingHosts/TargetingHostsLabelsRow.scss +6 -2
  39. data/webpack/react_app/components/TargetingHosts/__tests__/__snapshots__/TargetingHostsPage.test.js.snap +1 -1
  40. data/webpack/react_app/components/TargetingHosts/index.js +2 -1
  41. data/webpack/react_app/redux/actions/jobInvocations/index.js +3 -2
  42. data/webpack/test_setup.js +1 -13
  43. metadata +7 -4
  44. data/webpack/__mocks__/foremanReact/constants.js +0 -25
@@ -1,15 +1,15 @@
1
1
  /* eslint-disable max-lines */
2
2
  import React from 'react';
3
- import configureMockStore from 'redux-mock-store';
4
- import { fireEvent, render, screen, act } from '@testing-library/react';
5
- import '@testing-library/jest-dom/extend-expect';
3
+ import { fireEvent, screen, act } from '@testing-library/react';
4
+ import '@testing-library/jest-dom';
6
5
  import { createMemoryHistory } from 'history';
7
6
  import { Router } from 'react-router-dom';
8
- import { Provider } from 'react-redux';
9
- import thunk from 'redux-thunk';
7
+ import { rtlHelpers } from 'foremanReact/common/testHelpers';
10
8
  import { STATUS } from 'foremanReact/constants';
11
9
  import { foremanUrl } from 'foremanReact/common/helpers';
10
+ import { usePermissions } from 'foremanReact/common/hooks/Permissions/permissionHooks';
12
11
  import * as api from 'foremanReact/redux/API';
12
+ import { APIActions } from 'foremanReact/redux/API';
13
13
  import JobInvocationDetailPage from '../index';
14
14
  import {
15
15
  jobInvocationData,
@@ -23,18 +23,38 @@ import {
23
23
  enableRecurringLogic,
24
24
  cancelRecurringLogic,
25
25
  } from '../JobInvocationActions';
26
+ import { createForemanContextWrapper } from './foremanTestHelpers';
26
27
  import {
27
28
  CANCEL_JOB,
28
29
  CANCEL_RECURRING_LOGIC,
29
30
  CHANGE_ENABLED_RECURRING_LOGIC,
30
31
  GET_REPORT_TEMPLATES,
31
32
  GET_REPORT_TEMPLATE_INPUTS,
32
- GET_TASK,
33
33
  JOB_INVOCATION_KEY,
34
34
  } from '../JobInvocationConstants';
35
- import { createForemanContextWrapper } from './foremanTestHelpers';
35
+
36
+ const { renderWithStore } = rtlHelpers;
37
+
38
+ jest.mock('foremanReact/redux/API/APISelectors', () =>
39
+ jest.requireActual('foremanReact/redux/API/APISelectors')
40
+ );
41
+
42
+ jest.mock('foremanReact/common/hooks/Permissions/permissionHooks');
43
+ jest.mock('../JobInvocationActions', () => {
44
+ const actual = jest.requireActual('../JobInvocationActions');
45
+
46
+ return {
47
+ ...actual,
48
+ getJobInvocation: jest.fn(() => () => undefined),
49
+ stopJobInvocationPolling: jest.fn(),
50
+ };
51
+ });
36
52
 
37
53
  jest.spyOn(api, 'get');
54
+ jest.spyOn(APIActions, 'post');
55
+ jest.spyOn(APIActions, 'put');
56
+
57
+ usePermissions.mockReturnValue(true);
38
58
 
39
59
  // Mock toLocaleString to always use UTC timezone for consistent test results
40
60
  const originalToLocaleString = Date.prototype.toLocaleString;
@@ -62,46 +82,49 @@ jest.mock('foremanReact/routes/common/PageLayout/PageLayout', () =>
62
82
  ))
63
83
  );
64
84
 
65
- const initialState = {
66
- JOB_INVOCATION_KEY: {
67
- response: jobInvocationData,
68
- status: STATUS.RESOLVED,
69
- },
70
- GET_REPORT_TEMPLATES: mockReportTemplatesResponse,
71
- extendable: {},
72
- };
85
+ 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
+ }
73
100
 
74
- const initialStateScheduled = {
75
- JOB_INVOCATION_KEY: {
76
- response: jobInvocationDataScheduled,
77
- status: STATUS.RESOLVED,
78
- },
79
- extendable: {},
101
+ return { type: 'get', key, ...action };
102
+ });
103
+ APIActions.post.mockImplementation(payload => ({ type: 'post', ...payload }));
104
+ APIActions.put.mockImplementation(payload => ({ type: 'put', ...payload }));
80
105
  };
81
106
 
82
- api.get.mockImplementation(({ handleSuccess, ...action }) => {
83
- if (action.key === 'GET_REPORT_TEMPLATES') {
84
- handleSuccess &&
85
- handleSuccess({
86
- data: mockReportTemplatesResponse,
87
- });
88
- } else if (action.key === 'GET_REPORT_TEMPLATE_INPUTS') {
89
- handleSuccess &&
90
- handleSuccess({
91
- data: mockReportTemplateInputsResponse,
92
- });
93
- }
107
+ const createJobInvocationDetailState = ({
108
+ jobInvocation = jobInvocationData,
109
+ jobInvocationStatus = STATUS.RESOLVED,
110
+ jobInvocationError = null,
111
+ } = {}) => {
112
+ const jobInvocationResponse =
113
+ jobInvocationError || JSON.parse(JSON.stringify(jobInvocation));
94
114
 
95
- return { type: 'get', ...action };
96
- });
97
-
98
- const initialStateRecurring = {
99
- JOB_INVOCATION_KEY: {
100
- response: jobInvocationDataRecurring,
101
- status: STATUS.RESOLVED,
102
- },
103
- GET_REPORT_TEMPLATES: mockReportTemplatesResponse,
104
- extendable: {},
115
+ return {
116
+ API: {
117
+ [JOB_INVOCATION_KEY]: jobInvocationError
118
+ ? {
119
+ response: jobInvocationResponse,
120
+ status: STATUS.ERROR,
121
+ }
122
+ : {
123
+ response: jobInvocationResponse,
124
+ status: jobInvocationStatus,
125
+ },
126
+ },
127
+ };
105
128
  };
106
129
 
107
130
  jest.mock('../JobInvocationHostTable.js', () => () => (
@@ -110,21 +133,62 @@ jest.mock('../JobInvocationHostTable.js', () => () => (
110
133
 
111
134
  const reportTemplateJobId = mockReportTemplatesResponse.results[0].id;
112
135
 
113
- const mockStore = configureMockStore([thunk]);
114
- const props = { history: { push: jest.fn() } };
136
+ const defaultHistory = { push: jest.fn() };
137
+
138
+ const renderJobInvocationDetailPage = (
139
+ initialState,
140
+ { jobId, history = defaultHistory } = {}
141
+ ) => {
142
+ const id =
143
+ jobId ?? initialState.API?.[JOB_INVOCATION_KEY]?.response?.id ?? '1';
144
+
145
+ return renderWithStore(
146
+ <JobInvocationDetailPage
147
+ match={{ params: { id: `${id}` } }}
148
+ history={history}
149
+ />,
150
+ initialState,
151
+ undefined
152
+ );
153
+ };
115
154
 
116
155
  describe('JobInvocationDetailPage', () => {
156
+ beforeEach(() => {
157
+ setupApiMocks();
158
+ usePermissions.mockReturnValue(true);
159
+
160
+ const { getJobInvocation } = jest.requireMock('../JobInvocationActions');
161
+ getJobInvocation.mockImplementation(() => () => undefined);
162
+ });
163
+
164
+ afterEach(() => {
165
+ api.get.mockClear();
166
+ APIActions.post.mockClear();
167
+ APIActions.put.mockClear();
168
+ });
169
+
170
+ it('shows scheduled date', async () => {
171
+ const scheduledJobInvocation = JSON.parse(
172
+ JSON.stringify(jobInvocationDataScheduled)
173
+ );
174
+
175
+ renderJobInvocationDetailPage(
176
+ createJobInvocationDetailState({
177
+ jobInvocation: scheduledJobInvocation,
178
+ }),
179
+ { jobId: jobInvocationDataScheduled.id }
180
+ );
181
+
182
+ expect(screen.getByText('Scheduled at:')).toBeInTheDocument();
183
+ expect(screen.getByText('Jan 1, 3000, 11:34 UTC')).toBeInTheDocument();
184
+ expect(screen.getByText('Not yet')).toBeInTheDocument();
185
+ });
186
+
117
187
  it('renders main information', async () => {
118
188
  const jobId = jobInvocationData.id;
119
- const store = mockStore(initialState);
120
-
121
- const { container } = render(
122
- <Provider store={store}>
123
- <JobInvocationDetailPage
124
- match={{ params: { id: `${jobId}` } }}
125
- {...props}
126
- />
127
- </Provider>
189
+
190
+ const { container } = renderJobInvocationDetailPage(
191
+ createJobInvocationDetailState()
128
192
  );
129
193
 
130
194
  expect(screen.getByText('Description')).toBeInTheDocument();
@@ -212,115 +276,107 @@ describe('JobInvocationDetailPage', () => {
212
276
  });
213
277
 
214
278
  it('keeps toolbar buttons mounted while job invocation data is refreshing', async () => {
215
- const jobId = jobInvocationData.id;
216
- const store = mockStore({
217
- ...initialState,
218
- JOB_INVOCATION_KEY: {
219
- response: jobInvocationData,
220
- status: STATUS.PENDING,
221
- },
222
- });
223
-
224
- render(
225
- <Provider store={store}>
226
- <JobInvocationDetailPage
227
- match={{ params: { id: `${jobId}` } }}
228
- {...props}
229
- />
230
- </Provider>
279
+ renderJobInvocationDetailPage(
280
+ createJobInvocationDetailState({
281
+ jobInvocationStatus: STATUS.PENDING,
282
+ })
231
283
  );
232
284
 
233
285
  expect(screen.getByText('Create report')).toBeInTheDocument();
234
286
  expect(screen.getByText('Rerun all')).toBeInTheDocument();
235
287
  });
236
288
 
237
- it('shows scheduled date', async () => {
238
- const store = mockStore(initialStateScheduled);
239
- render(
240
- <Provider store={store}>
241
- <JobInvocationDetailPage
242
- match={{ params: { id: `${jobInvocationDataScheduled.id}` } }}
243
- {...props}
244
- />
245
- </Provider>
289
+ it('disables Create report when the job task state is running', async () => {
290
+ usePermissions.mockImplementation(requiredPermissions =>
291
+ requiredPermissions.includes('generate_report_templates')
246
292
  );
247
293
 
248
- expect(screen.getByText('Scheduled at:')).toBeInTheDocument();
249
- expect(screen.getByText('Jan 1, 3000, 11:34 UTC')).toBeInTheDocument();
250
- expect(screen.getByText('Not yet')).toBeInTheDocument();
294
+ renderJobInvocationDetailPage(
295
+ createJobInvocationDetailState({
296
+ jobInvocation: {
297
+ ...jobInvocationData,
298
+ task: { ...jobInvocationData.task, state: 'running' },
299
+ },
300
+ })
301
+ );
302
+
303
+ const createReportButton = screen.getByRole('link', {
304
+ name: 'Create report',
305
+ });
306
+
307
+ expect(createReportButton).toHaveAttribute('aria-disabled', 'true');
308
+ expect(createReportButton).toHaveClass('pf-m-disabled');
251
309
  });
252
310
 
253
311
  it('should dispatch global actions', async () => {
254
- // recurring in the future
255
312
  const jobId = jobInvocationDataRecurring.id;
256
- const taskId = jobInvocationDataRecurring.task.id;
257
313
  const recurrenceId = jobInvocationDataRecurring.recurrence.id;
258
- const store = mockStore(initialStateRecurring);
259
- render(
260
- <Provider store={store}>
261
- <JobInvocationDetailPage
262
- match={{ params: { id: `${jobId}` } }}
263
- {...props}
264
- />
265
- </Provider>
314
+
315
+ const { store } = renderJobInvocationDetailPage(
316
+ createJobInvocationDetailState({
317
+ jobInvocation: jobInvocationDataRecurring,
318
+ }),
319
+ { jobId }
266
320
  );
267
321
 
268
- const expectedActions = [
269
- { key: GET_REPORT_TEMPLATES, url: '/api/report_templates' },
270
- {
271
- key: JOB_INVOCATION_KEY,
272
- url: `/api/job_invocations/${jobId}`,
273
- },
274
- {
275
- key: GET_TASK,
276
- url: `/foreman_tasks/api/tasks/${taskId}`,
277
- },
278
- {
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({
279
330
  key: GET_REPORT_TEMPLATE_INPUTS,
280
331
  url: `/api/templates/${reportTemplateJobId}/template_inputs`,
281
- },
282
- {
332
+ })
333
+ );
334
+
335
+ api.get.mockClear();
336
+ APIActions.post.mockClear();
337
+ APIActions.put.mockClear();
338
+
339
+ store.dispatch(cancelJob(jobId, false));
340
+ store.dispatch(cancelJob(jobId, true));
341
+ store.dispatch(enableRecurringLogic(recurrenceId, true));
342
+ store.dispatch(enableRecurringLogic(recurrenceId, false));
343
+ store.dispatch(cancelRecurringLogic(recurrenceId));
344
+
345
+ expect(APIActions.post).toHaveBeenNthCalledWith(
346
+ 1,
347
+ expect.objectContaining({
283
348
  key: CANCEL_JOB,
284
349
  url: `/job_invocations/${jobId}/cancel`,
285
- },
286
- {
350
+ })
351
+ );
352
+ expect(APIActions.post).toHaveBeenNthCalledWith(
353
+ 2,
354
+ expect.objectContaining({
287
355
  key: CANCEL_JOB,
288
356
  url: `/job_invocations/${jobId}/cancel?force=true`,
289
- },
290
- {
357
+ })
358
+ );
359
+ expect(APIActions.put).toHaveBeenNthCalledWith(
360
+ 1,
361
+ expect.objectContaining({
291
362
  key: CHANGE_ENABLED_RECURRING_LOGIC,
292
363
  url: `/foreman_tasks/api/recurring_logics/${recurrenceId}`,
293
- },
294
- {
364
+ })
365
+ );
366
+ expect(APIActions.put).toHaveBeenNthCalledWith(
367
+ 2,
368
+ expect.objectContaining({
295
369
  key: CHANGE_ENABLED_RECURRING_LOGIC,
296
370
  url: `/foreman_tasks/api/recurring_logics/${recurrenceId}`,
297
- },
298
- {
371
+ })
372
+ );
373
+ expect(APIActions.post).toHaveBeenNthCalledWith(
374
+ 3,
375
+ expect.objectContaining({
299
376
  key: CANCEL_RECURRING_LOGIC,
300
377
  url: `/foreman_tasks/recurring_logics/${recurrenceId}/cancel`,
301
- },
302
- ];
303
-
304
- store.dispatch(cancelJob(jobId, false));
305
- store.dispatch(cancelJob(jobId, true));
306
- store.dispatch(enableRecurringLogic(recurrenceId, true, jobId));
307
- store.dispatch(enableRecurringLogic(recurrenceId, false, jobId));
308
- store.dispatch(cancelRecurringLogic(recurrenceId, jobId));
309
-
310
- const actualActions = store.getActions();
311
- expect(actualActions).toHaveLength(expectedActions.length);
312
-
313
- expectedActions.forEach((expectedAction, index) => {
314
- if (actualActions[index].type === 'WITH_INTERVAL') {
315
- expect(actualActions[index].key.key).toEqual(expectedAction.key);
316
- expect(actualActions[index].key.url).toEqual(expectedAction.url);
317
- } else {
318
- expect(actualActions[index].key).toEqual(expectedAction.key);
319
- if (expectedAction.url) {
320
- expect(actualActions[index].url).toEqual(expectedAction.url);
321
- }
322
- }
323
- });
378
+ })
379
+ );
324
380
  });
325
381
 
326
382
  it('renders empty state when the job invocation fails to load', () => {
@@ -328,28 +384,23 @@ describe('JobInvocationDetailPage', () => {
328
384
  const errorMessage = 'Record not found';
329
385
  const history = createMemoryHistory();
330
386
  const ForemanContextWrapper = createForemanContextWrapper();
331
- const store = mockStore({
332
- JOB_INVOCATION_KEY: {
333
- status: STATUS.ERROR,
334
- response: {
335
- message: errorMessage,
336
- response: { status: 404 },
337
- },
338
- },
339
- extendable: {},
340
- });
341
387
 
342
- render(
388
+ renderWithStore(
343
389
  <Router history={history}>
344
390
  <ForemanContextWrapper>
345
- <Provider store={store}>
346
- <JobInvocationDetailPage
347
- match={{ params: { id: jobId } }}
348
- history={history}
349
- />
350
- </Provider>
391
+ <JobInvocationDetailPage
392
+ match={{ params: { id: jobId } }}
393
+ history={history}
394
+ />
351
395
  </ForemanContextWrapper>
352
- </Router>
396
+ </Router>,
397
+ createJobInvocationDetailState({
398
+ jobInvocationError: {
399
+ message: errorMessage,
400
+ response: { status: 404 },
401
+ },
402
+ }),
403
+ undefined
353
404
  );
354
405
 
355
406
  expect(