foreman_remote_execution 16.6.5 → 17.0.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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/api/v2/job_invocations_controller.rb +22 -13
  3. data/app/views/api/v2/job_invocations/main.json.rabl +16 -11
  4. data/app/views/job_templates/_alerts.html.erb +4 -0
  5. data/lib/foreman_remote_execution/plugin.rb +1 -1
  6. data/lib/foreman_remote_execution/version.rb +1 -1
  7. data/package.json +3 -3
  8. data/test/functional/api/v2/job_invocations_controller_test.rb +68 -3
  9. data/webpack/JobInvocationDetail/CheckboxesActions.js +4 -10
  10. data/webpack/JobInvocationDetail/JobInvocationActions.js +80 -90
  11. data/webpack/JobInvocationDetail/JobInvocationConstants.js +7 -6
  12. data/webpack/JobInvocationDetail/JobInvocationEmptyState.js +52 -0
  13. data/webpack/JobInvocationDetail/JobInvocationHostTable.js +76 -42
  14. data/webpack/JobInvocationDetail/JobInvocationSelectors.js +1 -19
  15. data/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js +202 -153
  16. data/webpack/JobInvocationDetail/TemplateInvocation.js +31 -25
  17. data/webpack/JobInvocationDetail/__tests__/JobInvocationDetailEmptyState.test.js +53 -0
  18. data/webpack/JobInvocationDetail/__tests__/JobInvocationHostTablePolling.test.js +336 -0
  19. data/webpack/JobInvocationDetail/__tests__/JobInvocationPolling.test.js +223 -0
  20. data/webpack/JobInvocationDetail/__tests__/MainInformation.test.js +260 -111
  21. data/webpack/JobInvocationDetail/__tests__/TableToolbarActions.test.js +3 -1
  22. data/webpack/JobInvocationDetail/__tests__/TemplateInvocationPolling.test.js +254 -0
  23. data/webpack/JobInvocationDetail/__tests__/fixtures.js +2 -0
  24. data/webpack/JobInvocationDetail/__tests__/foremanTestHelpers.js +30 -0
  25. data/webpack/JobInvocationDetail/index.js +50 -30
  26. data/webpack/__mocks__/foremanReact/Root/Context/ForemanContext/index.js +15 -0
  27. data/webpack/__mocks__/foremanReact/common/hooks/Permissions/permissionHooks.js +1 -0
  28. data/webpack/__mocks__/foremanReact/redux/API/APISelectors.js +5 -0
  29. data/webpack/test_setup.js +1 -13
  30. metadata +11 -3
@@ -1,17 +1,20 @@
1
+ /* eslint-disable max-lines */
1
2
  import React from 'react';
2
- import configureMockStore from 'redux-mock-store';
3
- import { fireEvent, render, screen, act } from '@testing-library/react';
4
- import '@testing-library/jest-dom/extend-expect';
5
- import { Provider } from 'react-redux';
6
- import thunk from 'redux-thunk';
3
+ import { fireEvent, screen, act } from '@testing-library/react';
4
+ import '@testing-library/jest-dom';
5
+ import { createMemoryHistory } from 'history';
6
+ import { Router } from 'react-router-dom';
7
+ import { rtlHelpers } from 'foremanReact/common/testHelpers';
8
+ import { STATUS } from 'foremanReact/constants';
7
9
  import { foremanUrl } from 'foremanReact/common/helpers';
10
+ import { usePermissions } from 'foremanReact/common/hooks/Permissions/permissionHooks';
8
11
  import * as api from 'foremanReact/redux/API';
12
+ import { APIActions } from 'foremanReact/redux/API';
9
13
  import JobInvocationDetailPage from '../index';
10
14
  import {
11
15
  jobInvocationData,
12
16
  jobInvocationDataScheduled,
13
17
  jobInvocationDataRecurring,
14
- mockPermissionsData,
15
18
  mockReportTemplatesResponse,
16
19
  mockReportTemplateInputsResponse,
17
20
  } from './fixtures';
@@ -20,6 +23,7 @@ import {
20
23
  enableRecurringLogic,
21
24
  cancelRecurringLogic,
22
25
  } from '../JobInvocationActions';
26
+ import { createForemanContextWrapper } from './foremanTestHelpers';
23
27
  import {
24
28
  CANCEL_JOB,
25
29
  CANCEL_RECURRING_LOGIC,
@@ -29,14 +33,38 @@ import {
29
33
  JOB_INVOCATION_KEY,
30
34
  } from '../JobInvocationConstants';
31
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
+ });
52
+
32
53
  jest.spyOn(api, 'get');
54
+ jest.spyOn(APIActions, 'post');
55
+ jest.spyOn(APIActions, 'put');
56
+
57
+ usePermissions.mockReturnValue(true);
33
58
 
34
59
  // Mock toLocaleString to always use UTC timezone for consistent test results
35
60
  const originalToLocaleString = Date.prototype.toLocaleString;
36
61
  beforeAll(() => {
37
62
  // eslint-disable-next-line no-extend-native
38
- Date.prototype.toLocaleString = function (locale, options) {
39
- return originalToLocaleString.call(this, locale, { ...options, timeZone: 'UTC' });
63
+ Date.prototype.toLocaleString = function toLocaleStringUTC(locale, options) {
64
+ return originalToLocaleString.call(this, locale, {
65
+ ...options,
66
+ timeZone: 'UTC',
67
+ });
40
68
  };
41
69
  });
42
70
  afterAll(() => {
@@ -44,12 +72,6 @@ afterAll(() => {
44
72
  Date.prototype.toLocaleString = originalToLocaleString;
45
73
  });
46
74
 
47
- jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({
48
- useAPI: jest.fn(() => ({
49
- response: mockPermissionsData,
50
- })),
51
- }));
52
-
53
75
  jest.mock('foremanReact/routes/common/PageLayout/PageLayout', () =>
54
76
  jest.fn(props => (
55
77
  <div>
@@ -60,36 +82,50 @@ jest.mock('foremanReact/routes/common/PageLayout/PageLayout', () =>
60
82
  ))
61
83
  );
62
84
 
63
- const initialState = {
64
- JOB_INVOCATION_KEY: {
65
- response: jobInvocationData,
66
- },
67
- GET_REPORT_TEMPLATES: mockReportTemplatesResponse,
68
- extendable: {},
69
- };
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
+ }
70
100
 
71
- const initialStateScheduled = {
72
- JOB_INVOCATION_KEY: {
73
- response: jobInvocationDataScheduled,
74
- },
75
- extendable: {},
101
+ return { type: 'get', key, ...action };
102
+ });
103
+ APIActions.post.mockImplementation(payload => ({ type: 'post', ...payload }));
104
+ APIActions.put.mockImplementation(payload => ({ type: 'put', ...payload }));
76
105
  };
77
106
 
78
- api.get.mockImplementation(({ handleSuccess, ...action }) => {
79
- if (action.key === 'GET_REPORT_TEMPLATES') {
80
- handleSuccess &&
81
- handleSuccess({
82
- data: mockReportTemplatesResponse,
83
- });
84
- } else if (action.key === 'GET_REPORT_TEMPLATE_INPUTS') {
85
- handleSuccess &&
86
- handleSuccess({
87
- data: mockReportTemplateInputsResponse,
88
- });
89
- }
107
+ const createJobInvocationDetailState = ({
108
+ jobInvocation = jobInvocationData,
109
+ jobInvocationStatus = STATUS.RESOLVED,
110
+ jobInvocationError = null,
111
+ } = {}) => {
112
+ const jobInvocationResponse =
113
+ jobInvocationError || JSON.parse(JSON.stringify(jobInvocation));
90
114
 
91
- return { type: 'get', ...action };
92
- });
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
+ };
128
+ };
93
129
 
94
130
  jest.mock('../JobInvocationHostTable.js', () => () => (
95
131
  <div data-testid="mock-table">Mock Table</div>
@@ -97,21 +133,62 @@ jest.mock('../JobInvocationHostTable.js', () => () => (
97
133
 
98
134
  const reportTemplateJobId = mockReportTemplatesResponse.results[0].id;
99
135
 
100
- const mockStore = configureMockStore([thunk]);
101
- 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
+ };
102
154
 
103
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
+
104
187
  it('renders main information', async () => {
105
188
  const jobId = jobInvocationData.id;
106
- const store = mockStore(initialState);
107
-
108
- const { container } = render(
109
- <Provider store={store}>
110
- <JobInvocationDetailPage
111
- match={{ params: { id: `${jobId}` } }}
112
- {...props}
113
- />
114
- </Provider>
189
+
190
+ const { container } = renderJobInvocationDetailPage(
191
+ createJobInvocationDetailState()
115
192
  );
116
193
 
117
194
  expect(screen.getByText('Description')).toBeInTheDocument();
@@ -198,87 +275,159 @@ describe('JobInvocationDetailPage', () => {
198
275
  ).toEqual(`/legacy/job_invocations/${jobId}`);
199
276
  });
200
277
 
201
- it('shows scheduled date', async () => {
202
- const store = mockStore(initialStateScheduled);
203
- render(
204
- <Provider store={store}>
205
- <JobInvocationDetailPage
206
- match={{ params: { id: `${jobInvocationDataScheduled.id}` } }}
207
- {...props}
208
- />
209
- </Provider>
278
+ it('keeps toolbar buttons mounted while job invocation data is refreshing', async () => {
279
+ renderJobInvocationDetailPage(
280
+ createJobInvocationDetailState({
281
+ jobInvocationStatus: STATUS.PENDING,
282
+ })
210
283
  );
211
284
 
212
- expect(screen.getByText('Scheduled at:')).toBeInTheDocument();
213
- expect(screen.getByText('Jan 1, 3000, 11:34 UTC')).toBeInTheDocument();
214
- expect(screen.getByText('Not yet')).toBeInTheDocument();
285
+ expect(screen.getByText('Create report')).toBeInTheDocument();
286
+ expect(screen.getByText('Rerun all')).toBeInTheDocument();
287
+ });
288
+
289
+ it('disables Create report when the job task state is running', async () => {
290
+ usePermissions.mockImplementation(requiredPermissions =>
291
+ requiredPermissions.includes('generate_report_templates')
292
+ );
293
+
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');
215
309
  });
216
310
 
217
311
  it('should dispatch global actions', async () => {
218
- // recurring in the future
219
312
  const jobId = jobInvocationDataRecurring.id;
220
313
  const recurrenceId = jobInvocationDataRecurring.recurrence.id;
221
- const store = mockStore(jobInvocationDataRecurring);
222
- render(
223
- <Provider store={store}>
224
- <JobInvocationDetailPage
225
- match={{ params: { id: `${jobId}` } }}
226
- {...props}
227
- />
228
- </Provider>
314
+
315
+ const { store } = renderJobInvocationDetailPage(
316
+ createJobInvocationDetailState({
317
+ jobInvocation: jobInvocationDataRecurring,
318
+ }),
319
+ { jobId }
229
320
  );
230
321
 
231
- const expectedActions = [
232
- { key: GET_REPORT_TEMPLATES, url: '/api/report_templates' },
233
- {
234
- key: JOB_INVOCATION_KEY,
235
- url: `/api/job_invocations/${jobId}?host_status=true`,
236
- },
237
- {
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({
238
330
  key: GET_REPORT_TEMPLATE_INPUTS,
239
331
  url: `/api/templates/${reportTemplateJobId}/template_inputs`,
240
- },
241
- {
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({
242
348
  key: CANCEL_JOB,
243
349
  url: `/job_invocations/${jobId}/cancel`,
244
- },
245
- {
350
+ })
351
+ );
352
+ expect(APIActions.post).toHaveBeenNthCalledWith(
353
+ 2,
354
+ expect.objectContaining({
246
355
  key: CANCEL_JOB,
247
356
  url: `/job_invocations/${jobId}/cancel?force=true`,
248
- },
249
- {
357
+ })
358
+ );
359
+ expect(APIActions.put).toHaveBeenNthCalledWith(
360
+ 1,
361
+ expect.objectContaining({
250
362
  key: CHANGE_ENABLED_RECURRING_LOGIC,
251
363
  url: `/foreman_tasks/api/recurring_logics/${recurrenceId}`,
252
- },
253
- {
364
+ })
365
+ );
366
+ expect(APIActions.put).toHaveBeenNthCalledWith(
367
+ 2,
368
+ expect.objectContaining({
254
369
  key: CHANGE_ENABLED_RECURRING_LOGIC,
255
370
  url: `/foreman_tasks/api/recurring_logics/${recurrenceId}`,
256
- },
257
- {
371
+ })
372
+ );
373
+ expect(APIActions.post).toHaveBeenNthCalledWith(
374
+ 3,
375
+ expect.objectContaining({
258
376
  key: CANCEL_RECURRING_LOGIC,
259
377
  url: `/foreman_tasks/recurring_logics/${recurrenceId}/cancel`,
260
- },
261
- ];
378
+ })
379
+ );
380
+ });
262
381
 
263
- store.dispatch(cancelJob(jobId, false));
264
- store.dispatch(cancelJob(jobId, true));
265
- store.dispatch(enableRecurringLogic(recurrenceId, true, jobId));
266
- store.dispatch(enableRecurringLogic(recurrenceId, false, jobId));
267
- store.dispatch(cancelRecurringLogic(recurrenceId, jobId));
268
-
269
- const actualActions = store.getActions();
270
- expect(actualActions).toHaveLength(expectedActions.length);
271
-
272
- expectedActions.forEach((expectedAction, index) => {
273
- if (actualActions[index].type === 'WITH_INTERVAL') {
274
- expect(actualActions[index].key.key).toEqual(expectedAction.key);
275
- expect(actualActions[index].key.url).toEqual(expectedAction.url);
276
- } else {
277
- expect(actualActions[index].key).toEqual(expectedAction.key);
278
- if (expectedAction.url) {
279
- expect(actualActions[index].url).toEqual(expectedAction.url);
280
- }
281
- }
282
- });
382
+ it('renders empty state when the job invocation fails to load', () => {
383
+ const jobId = '99';
384
+ const errorMessage = 'Record not found';
385
+ const history = createMemoryHistory();
386
+ const ForemanContextWrapper = createForemanContextWrapper();
387
+
388
+ renderWithStore(
389
+ <Router history={history}>
390
+ <ForemanContextWrapper>
391
+ <JobInvocationDetailPage
392
+ match={{ params: { id: jobId } }}
393
+ history={history}
394
+ />
395
+ </ForemanContextWrapper>
396
+ </Router>,
397
+ createJobInvocationDetailState({
398
+ jobInvocationError: {
399
+ message: errorMessage,
400
+ response: { status: 404 },
401
+ },
402
+ }),
403
+ undefined
404
+ );
405
+
406
+ expect(
407
+ screen.getByRole('heading', {
408
+ name: 'Unable to load job invocation',
409
+ level: 5,
410
+ })
411
+ ).toBeInTheDocument();
412
+ expect(
413
+ screen.getByText(
414
+ (_, element) =>
415
+ element?.tagName === 'P' &&
416
+ element.textContent?.includes(
417
+ `The job invocation with id ${jobId} could not be found. It may have been deleted or may not be available in your current organization or location scope.`
418
+ )
419
+ )
420
+ ).toBeInTheDocument();
421
+ expect(
422
+ screen.getByText(`Server returned: ${errorMessage}`)
423
+ ).toBeInTheDocument();
424
+ expect(
425
+ screen.getByRole('button', { name: 'Go to job invocations' })
426
+ ).toBeInTheDocument();
427
+ expect(
428
+ screen.getByRole('button', { name: 'Create a new job invocation' })
429
+ ).toBeInTheDocument();
430
+ expect(screen.queryByText('Description')).not.toBeInTheDocument();
431
+ expect(screen.queryByTestId('mock-table')).not.toBeInTheDocument();
283
432
  });
284
433
  });
@@ -2,6 +2,7 @@ import '@testing-library/jest-dom/extend-expect';
2
2
  import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
3
  import axios from 'axios';
4
4
  import { foremanUrl } from 'foremanReact/common/helpers';
5
+ import { usePermissions } from 'foremanReact/common/hooks/Permissions/permissionHooks';
5
6
  import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
6
7
  import React from 'react';
7
8
  import { Provider } from 'react-redux';
@@ -13,6 +14,7 @@ import { PopupAlert } from '../OpenAllInvocationsModal';
13
14
 
14
15
  jest.mock('axios');
15
16
  jest.mock('foremanReact/common/hooks/API/APIHooks');
17
+ jest.mock('foremanReact/common/hooks/Permissions/permissionHooks');
16
18
  jest.mock('../JobInvocationSelectors');
17
19
 
18
20
  jest.mock('../JobInvocationConstants', () => ({
@@ -26,8 +28,8 @@ jest.mock('../JobInvocationConstants', () => ({
26
28
  selectors.selectItems.mockImplementation(() => ({
27
29
  targeting: { search_query: 'name~*' },
28
30
  }));
29
- selectors.selectHasPermission.mockImplementation(() => () => true);
30
31
  selectors.selectTaskCancelable.mockImplementation(() => true);
32
+ usePermissions.mockReturnValue(true);
31
33
 
32
34
  const mockStore = configureStore([]);
33
35
  const store = mockStore({});