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
@@ -0,0 +1,369 @@
1
+ import React from 'react';
2
+ import { render, act } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+ import { Provider } from 'react-redux';
5
+ import configureMockStore from 'redux-mock-store';
6
+ import thunk from 'redux-thunk';
7
+ import { Router } from 'react-router-dom';
8
+ import { createMemoryHistory } from 'history';
9
+ import { APIActions } from 'foremanReact/redux/API';
10
+ import { createForemanContextWrapper } from './foremanTestHelpers';
11
+ import JobInvocationHostTable from '../JobInvocationHostTable';
12
+ import { JOB_INVOCATION_HOSTS } from '../JobInvocationConstants';
13
+
14
+ jest.useFakeTimers();
15
+
16
+ jest.mock('foremanReact/redux/API/APISelectors', () =>
17
+ jest.requireActual('foremanReact/redux/API/APISelectors')
18
+ );
19
+
20
+ jest.mock('foremanReact/common/hooks/Permissions/permissionHooks', () => ({
21
+ usePermissions: jest.fn(() => true),
22
+ }));
23
+
24
+ jest.mock('../TemplateInvocation', () => ({
25
+ TemplateInvocation: () => <div data-testid="template-invocation" />,
26
+ }));
27
+
28
+ jest.mock('../TemplateInvocationComponents/TemplateActionButtons', () => ({
29
+ RowActions: () => <div data-testid="row-actions" />,
30
+ }));
31
+
32
+ const mockStore = configureMockStore([thunk]);
33
+
34
+ const hostsResponse = {
35
+ total: 1,
36
+ subtotal: 1,
37
+ page: 1,
38
+ per_page: 20,
39
+ results: [
40
+ {
41
+ id: 1,
42
+ name: 'host1.example.com',
43
+ operatingsystem_id: 1,
44
+ operatingsystem_name: 'RHEL 9',
45
+ hostgroup_id: 1,
46
+ hostgroup_name: 'default',
47
+ job_status: 'success',
48
+ smart_proxy_id: 1,
49
+ smart_proxy_name: 'proxy1',
50
+ },
51
+ ],
52
+ };
53
+
54
+ let apiGetSpy;
55
+ let hostsCalls;
56
+ let pendingCallbacks;
57
+
58
+ const createStore = () =>
59
+ mockStore({
60
+ API: {},
61
+ });
62
+
63
+ const flushPendingCallbacks = () => {
64
+ const batch = [...pendingCallbacks];
65
+ pendingCallbacks = [];
66
+ batch.forEach(cb => cb());
67
+ };
68
+
69
+ const renderTable = (props = {}) => {
70
+ const store = createStore();
71
+ const history = createMemoryHistory();
72
+ const Wrapper = createForemanContextWrapper();
73
+
74
+ const defaultProps = {
75
+ id: '42',
76
+ targeting: { targeting_type: 'static_query', search_query: '' },
77
+ initialFilter: 'all_statuses',
78
+ jobFinished: false,
79
+ onFilterUpdate: jest.fn(),
80
+ ...props,
81
+ };
82
+
83
+ const result = render(
84
+ <Provider store={store}>
85
+ <Router history={history}>
86
+ <Wrapper>
87
+ <JobInvocationHostTable {...defaultProps} />
88
+ </Wrapper>
89
+ </Router>
90
+ </Provider>
91
+ );
92
+
93
+ return { ...result, store, history };
94
+ };
95
+
96
+ const setupSuccessMock = () => {
97
+ apiGetSpy = jest
98
+ .spyOn(APIActions, 'get')
99
+ .mockImplementation(opts => dispatch => {
100
+ if (opts.key === JOB_INVOCATION_HOSTS) {
101
+ hostsCalls.push(opts);
102
+ if (opts.handleSuccess) {
103
+ pendingCallbacks.push(() =>
104
+ opts.handleSuccess({ data: hostsResponse })
105
+ );
106
+ }
107
+ }
108
+ });
109
+ };
110
+
111
+ const setupErrorMock = () => {
112
+ apiGetSpy = jest
113
+ .spyOn(APIActions, 'get')
114
+ .mockImplementation(opts => dispatch => {
115
+ if (opts.key === JOB_INVOCATION_HOSTS) {
116
+ hostsCalls.push(opts);
117
+ if (opts.handleError) {
118
+ pendingCallbacks.push(() => opts.handleError());
119
+ }
120
+ }
121
+ });
122
+ };
123
+
124
+ describe('JobInvocationHostTable polling', () => {
125
+ beforeEach(() => {
126
+ hostsCalls = [];
127
+ pendingCallbacks = [];
128
+ setupSuccessMock();
129
+ });
130
+
131
+ afterEach(() => {
132
+ apiGetSpy.mockRestore();
133
+ jest.clearAllTimers();
134
+ });
135
+
136
+ it('schedules a poll after the initial fetch succeeds', () => {
137
+ renderTable();
138
+
139
+ expect(hostsCalls).toHaveLength(1);
140
+
141
+ act(() => {
142
+ flushPendingCallbacks();
143
+ });
144
+
145
+ expect(hostsCalls).toHaveLength(1);
146
+
147
+ act(() => {
148
+ jest.advanceTimersByTime(5000);
149
+ });
150
+
151
+ act(() => {
152
+ flushPendingCallbacks();
153
+ });
154
+
155
+ expect(hostsCalls).toHaveLength(2);
156
+
157
+ act(() => {
158
+ jest.advanceTimersByTime(5000);
159
+ });
160
+
161
+ act(() => {
162
+ flushPendingCallbacks();
163
+ });
164
+
165
+ expect(hostsCalls).toHaveLength(3);
166
+ });
167
+
168
+ it('does not schedule a poll when jobFinished is true', () => {
169
+ renderTable({ jobFinished: true });
170
+
171
+ expect(hostsCalls).toHaveLength(1);
172
+
173
+ act(() => {
174
+ flushPendingCallbacks();
175
+ });
176
+
177
+ act(() => {
178
+ jest.advanceTimersByTime(10000);
179
+ });
180
+
181
+ act(() => {
182
+ flushPendingCallbacks();
183
+ });
184
+
185
+ expect(hostsCalls).toHaveLength(1);
186
+ });
187
+
188
+ it('stops polling when jobFinished transitions to true', () => {
189
+ const { rerender } = renderTable();
190
+
191
+ act(() => {
192
+ flushPendingCallbacks();
193
+ });
194
+
195
+ expect(hostsCalls).toHaveLength(1);
196
+
197
+ const store = createStore();
198
+ const history = createMemoryHistory();
199
+ const Wrapper = createForemanContextWrapper();
200
+
201
+ act(() => {
202
+ rerender(
203
+ <Provider store={store}>
204
+ <Router history={history}>
205
+ <Wrapper>
206
+ <JobInvocationHostTable
207
+ id="42"
208
+ targeting={{
209
+ targeting_type: 'static_query',
210
+ search_query: '',
211
+ }}
212
+ initialFilter="all_statuses"
213
+ jobFinished
214
+ onFilterUpdate={jest.fn()}
215
+ />
216
+ </Wrapper>
217
+ </Router>
218
+ </Provider>
219
+ );
220
+ });
221
+
222
+ act(() => {
223
+ flushPendingCallbacks();
224
+ });
225
+
226
+ const callsAtTransition = hostsCalls.length;
227
+
228
+ act(() => {
229
+ jest.advanceTimersByTime(10000);
230
+ });
231
+
232
+ act(() => {
233
+ flushPendingCallbacks();
234
+ });
235
+
236
+ expect(hostsCalls).toHaveLength(callsAtTransition);
237
+ });
238
+
239
+ it('cleans up the poll timer on unmount', () => {
240
+ const { unmount } = renderTable();
241
+
242
+ act(() => {
243
+ flushPendingCallbacks();
244
+ });
245
+
246
+ const callsBeforeUnmount = hostsCalls.length;
247
+
248
+ unmount();
249
+
250
+ act(() => {
251
+ jest.advanceTimersByTime(10000);
252
+ });
253
+
254
+ act(() => {
255
+ flushPendingCallbacks();
256
+ });
257
+
258
+ expect(hostsCalls).toHaveLength(callsBeforeUnmount);
259
+ });
260
+
261
+ it('restarts polling when filter changes', () => {
262
+ const { rerender } = renderTable({ initialFilter: 'all_statuses' });
263
+
264
+ act(() => {
265
+ flushPendingCallbacks();
266
+ });
267
+
268
+ const callsBeforeFilterChange = hostsCalls.length;
269
+
270
+ const store = createStore();
271
+ const history = createMemoryHistory();
272
+ const Wrapper = createForemanContextWrapper();
273
+
274
+ act(() => {
275
+ rerender(
276
+ <Provider store={store}>
277
+ <Router history={history}>
278
+ <Wrapper>
279
+ <JobInvocationHostTable
280
+ id="42"
281
+ targeting={{
282
+ targeting_type: 'static_query',
283
+ search_query: '',
284
+ }}
285
+ initialFilter="success"
286
+ jobFinished={false}
287
+ onFilterUpdate={jest.fn()}
288
+ />
289
+ </Wrapper>
290
+ </Router>
291
+ </Provider>
292
+ );
293
+ });
294
+
295
+ expect(hostsCalls.length).toBeGreaterThan(callsBeforeFilterChange);
296
+
297
+ act(() => {
298
+ flushPendingCallbacks();
299
+ });
300
+
301
+ const callsAfterFilterChange = hostsCalls.length;
302
+
303
+ act(() => {
304
+ jest.advanceTimersByTime(5000);
305
+ });
306
+
307
+ act(() => {
308
+ flushPendingCallbacks();
309
+ });
310
+
311
+ expect(hostsCalls.length).toBeGreaterThan(callsAfterFilterChange);
312
+ });
313
+
314
+ it('sends include_permissions only on the first request', () => {
315
+ renderTable();
316
+
317
+ expect(hostsCalls).toHaveLength(1);
318
+ expect(hostsCalls[0].params.include_permissions).toBe(true);
319
+
320
+ act(() => {
321
+ flushPendingCallbacks();
322
+ });
323
+
324
+ act(() => {
325
+ jest.advanceTimersByTime(5000);
326
+ });
327
+
328
+ act(() => {
329
+ flushPendingCallbacks();
330
+ });
331
+
332
+ expect(hostsCalls).toHaveLength(2);
333
+ expect(hostsCalls[1].params.include_permissions).toBeUndefined();
334
+
335
+ act(() => {
336
+ jest.advanceTimersByTime(5000);
337
+ });
338
+
339
+ act(() => {
340
+ flushPendingCallbacks();
341
+ });
342
+
343
+ expect(hostsCalls).toHaveLength(3);
344
+ expect(hostsCalls[2].params.include_permissions).toBeUndefined();
345
+ });
346
+
347
+ it('stops polling on API error', () => {
348
+ apiGetSpy.mockRestore();
349
+ setupErrorMock();
350
+
351
+ renderTable();
352
+
353
+ expect(hostsCalls).toHaveLength(1);
354
+
355
+ act(() => {
356
+ flushPendingCallbacks();
357
+ });
358
+
359
+ act(() => {
360
+ jest.advanceTimersByTime(10000);
361
+ });
362
+
363
+ act(() => {
364
+ flushPendingCallbacks();
365
+ });
366
+
367
+ expect(hostsCalls).toHaveLength(1);
368
+ });
369
+ });
@@ -0,0 +1,228 @@
1
+ import { createStore, applyMiddleware } from 'redux';
2
+ import thunk from 'redux-thunk';
3
+ import { APIActions } from 'foremanReact/redux/API';
4
+ import {
5
+ getJobInvocation,
6
+ stopJobInvocationPolling,
7
+ } from '../JobInvocationActions';
8
+ import {
9
+ JOB_INVOCATION_KEY,
10
+ AUTO_REFRESH_INTERVAL_MS,
11
+ } from '../JobInvocationConstants';
12
+
13
+ jest.useFakeTimers();
14
+
15
+ const reducer = (state = {}) => state;
16
+ const makeStore = () => createStore(reducer, applyMiddleware(thunk));
17
+ const makeRef = () => ({ current: { timeoutId: null, cancel: () => {} } });
18
+
19
+ const runningData = { status_label: 'running' };
20
+ const succeededData = { status_label: 'succeeded' };
21
+ const failedData = { status_label: 'failed' };
22
+ const cancelledData = { status_label: 'cancelled' };
23
+
24
+ let apiGetSpy;
25
+
26
+ const setupGetMock = responseData => {
27
+ apiGetSpy = jest
28
+ .spyOn(APIActions, 'get')
29
+ .mockImplementation(({ handleSuccess, ...action }) => dispatch => {
30
+ handleSuccess && handleSuccess({ data: responseData });
31
+ return dispatch({ type: 'MOCK_GET', ...action });
32
+ });
33
+ };
34
+
35
+ const url = '/api/job_invocations/1';
36
+
37
+ describe('job invocation polling', () => {
38
+ let ref;
39
+
40
+ beforeEach(() => {
41
+ ref = makeRef();
42
+ jest.clearAllTimers();
43
+ });
44
+
45
+ afterEach(() => {
46
+ if (apiGetSpy) {
47
+ apiGetSpy.mockRestore();
48
+ }
49
+ });
50
+
51
+ it('sends include_permissions and include_hosts on the initial fetch', () => {
52
+ setupGetMock(succeededData);
53
+ const store = makeStore();
54
+
55
+ store.dispatch(getJobInvocation(url, ref));
56
+
57
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
58
+ expect(apiGetSpy.mock.calls[0][0]).toMatchObject({
59
+ key: JOB_INVOCATION_KEY,
60
+ url,
61
+ params: { include_hosts: false, include_permissions: true },
62
+ });
63
+ });
64
+
65
+ it('schedules the next poll when job is still running', () => {
66
+ setupGetMock(runningData);
67
+ const store = makeStore();
68
+
69
+ store.dispatch(getJobInvocation(url, ref));
70
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
71
+
72
+ jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS);
73
+ expect(apiGetSpy).toHaveBeenCalledTimes(2);
74
+ });
75
+
76
+ it('does not include include_permissions on subsequent poll calls', () => {
77
+ setupGetMock(runningData);
78
+ const store = makeStore();
79
+
80
+ store.dispatch(getJobInvocation(url, ref));
81
+ jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS);
82
+
83
+ expect(apiGetSpy).toHaveBeenCalledTimes(2);
84
+ expect(apiGetSpy.mock.calls[1][0].params).toMatchObject({
85
+ include_hosts: false,
86
+ });
87
+ expect(apiGetSpy.mock.calls[1][0].params).not.toHaveProperty(
88
+ 'include_permissions'
89
+ );
90
+ });
91
+
92
+ it('stops polling when job succeeds', () => {
93
+ setupGetMock(succeededData);
94
+ const store = makeStore();
95
+
96
+ store.dispatch(getJobInvocation(url, ref));
97
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
98
+
99
+ jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS);
100
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
101
+ });
102
+
103
+ it('stops polling when job fails', () => {
104
+ setupGetMock(failedData);
105
+ const store = makeStore();
106
+
107
+ store.dispatch(getJobInvocation(url, ref));
108
+ jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS);
109
+
110
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
111
+ });
112
+
113
+ it('stops polling when job is cancelled', () => {
114
+ setupGetMock(cancelledData);
115
+ const store = makeStore();
116
+
117
+ store.dispatch(getJobInvocation(url, ref));
118
+ jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS);
119
+
120
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
121
+ });
122
+
123
+ it('stops polling on fetch error', () => {
124
+ apiGetSpy = jest
125
+ .spyOn(APIActions, 'get')
126
+ .mockImplementation(({ handleError, ...action }) => dispatch => {
127
+ handleError && handleError();
128
+ return dispatch({ type: 'MOCK_GET', ...action });
129
+ });
130
+ const store = makeStore();
131
+
132
+ store.dispatch(getJobInvocation(url, ref));
133
+ jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS);
134
+
135
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
136
+ });
137
+
138
+ it('stopJobInvocationPolling cancels a pending timeout', () => {
139
+ setupGetMock(runningData);
140
+ const store = makeStore();
141
+
142
+ store.dispatch(getJobInvocation(url, ref));
143
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
144
+
145
+ stopJobInvocationPolling(ref);
146
+ jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS);
147
+
148
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
149
+ });
150
+
151
+ it('cancels pending timeout before starting a new poll when called multiple times', () => {
152
+ setupGetMock(runningData);
153
+ const store = makeStore();
154
+
155
+ store.dispatch(getJobInvocation(url, ref));
156
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
157
+
158
+ // Second call before the timeout fires cancels the first timeout and starts fresh
159
+ store.dispatch(getJobInvocation(url, ref));
160
+ expect(apiGetSpy).toHaveBeenCalledTimes(2);
161
+
162
+ jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS);
163
+
164
+ // Only the second poll chain fires (first was cancelled)
165
+ expect(apiGetSpy).toHaveBeenCalledTimes(3);
166
+ });
167
+
168
+ it('keeps polling as long as the job is running', () => {
169
+ setupGetMock(runningData);
170
+ const store = makeStore();
171
+
172
+ store.dispatch(getJobInvocation(url, ref));
173
+ jest.advanceTimersByTime(AUTO_REFRESH_INTERVAL_MS * 3);
174
+
175
+ expect(apiGetSpy).toHaveBeenCalledTimes(4);
176
+ });
177
+
178
+ it('cancels in-flight callback when new poll starts before previous resolves', () => {
179
+ let firstCallHandleSuccess;
180
+ let callCount = 0;
181
+ apiGetSpy = jest
182
+ .spyOn(APIActions, 'get')
183
+ .mockImplementation(({ handleSuccess, handleError }) => dispatch => {
184
+ callCount++;
185
+ if (callCount === 1) {
186
+ firstCallHandleSuccess = handleSuccess;
187
+ }
188
+ return dispatch({ type: 'MOCK_GET' });
189
+ });
190
+
191
+ const store = makeStore();
192
+ store.dispatch(getJobInvocation(url, ref));
193
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
194
+
195
+ // Start a new poll cycle before the first resolves
196
+ store.dispatch(getJobInvocation(url, ref));
197
+ expect(apiGetSpy).toHaveBeenCalledTimes(2);
198
+
199
+ // Old callback from first call tries to schedule next poll
200
+ firstCallHandleSuccess({ data: runningData });
201
+
202
+ // Timeout should not be set by the stale callback
203
+ expect(ref.current.timeoutId).toBeNull();
204
+ });
205
+
206
+ it('ignores a stale response resolving after stopJobInvocationPolling (e.g. unmount)', () => {
207
+ let capturedHandleSuccess;
208
+ apiGetSpy = jest
209
+ .spyOn(APIActions, 'get')
210
+ .mockImplementation(({ handleSuccess }) => dispatch => {
211
+ capturedHandleSuccess = handleSuccess;
212
+ return dispatch({ type: 'MOCK_GET' });
213
+ });
214
+
215
+ const store = makeStore();
216
+ store.dispatch(getJobInvocation(url, ref));
217
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
218
+
219
+ // Simulate unmount: stop polling while the request is still in flight.
220
+ stopJobInvocationPolling(ref);
221
+
222
+ // The in-flight request resolves after the "unmount".
223
+ capturedHandleSuccess({ data: runningData });
224
+
225
+ // The stale response must not revive polling.
226
+ expect(ref.current.timeoutId).toBeNull();
227
+ });
228
+ });