foreman_remote_execution 16.7.0 → 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.
@@ -0,0 +1,254 @@
1
+ import React from 'react';
2
+ import { createStore, applyMiddleware } from 'redux';
3
+ import thunk from 'redux-thunk';
4
+ import { Provider } from 'react-redux';
5
+ import { render, act } from '@testing-library/react';
6
+ import '@testing-library/jest-dom/extend-expect';
7
+ import * as api from 'foremanReact/redux/API';
8
+ import * as selectors from '../JobInvocationSelectors';
9
+ import { TemplateInvocation } from '../TemplateInvocation';
10
+ import { mockTemplateInvocationResponse } from './fixtures';
11
+
12
+ jest.spyOn(api, 'get');
13
+ jest.mock('../JobInvocationSelectors');
14
+
15
+ jest.mock('foremanReact/components/ToastsList', () => ({
16
+ addToast: jest.fn(payload => ({ type: 'ADD_TOAST', payload })),
17
+ }));
18
+
19
+ describe('TemplateInvocation polling', () => {
20
+ const noop = () => {};
21
+ const reducer = (state = {}) => state;
22
+ const makeStore = () => createStore(reducer, applyMiddleware(thunk));
23
+
24
+ const pollingProps = {
25
+ hostID: '1',
26
+ jobID: '1',
27
+ isInTableView: false,
28
+ isExpanded: true,
29
+ hostName: 'example-host',
30
+ hostProxy: { name: 'example-proxy', href: '#' },
31
+ showOutputType: { stderr: true, stdout: true, debug: true },
32
+ setShowOutputType: noop,
33
+ showTemplatePreview: false,
34
+ setShowTemplatePreview: noop,
35
+ showCommand: false,
36
+ setShowCommand: noop,
37
+ };
38
+
39
+ let apiGetSpy;
40
+
41
+ beforeEach(() => {
42
+ jest.useFakeTimers({ legacyFakeTimers: true });
43
+ selectors.selectTemplateInvocationStatus.mockImplementation(() => () =>
44
+ 'RESOLVED'
45
+ );
46
+ selectors.selectTemplateInvocation.mockImplementation(() => () =>
47
+ mockTemplateInvocationResponse
48
+ );
49
+ apiGetSpy = jest
50
+ .spyOn(api.APIActions, 'get')
51
+ .mockImplementation(({ handleSuccess }) => {
52
+ handleSuccess &&
53
+ handleSuccess({ data: { finished: false, auto_refresh: true } });
54
+ return { type: 'MOCK_GET' };
55
+ });
56
+ });
57
+
58
+ afterEach(() => {
59
+ jest.clearAllTimers();
60
+ jest.useRealTimers();
61
+ apiGetSpy.mockRestore();
62
+ });
63
+
64
+ it('fetches on mount when isExpanded is true', () => {
65
+ const localStore = makeStore();
66
+ render(
67
+ <Provider store={localStore}>
68
+ <TemplateInvocation {...pollingProps} isExpanded />
69
+ </Provider>
70
+ );
71
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
72
+ });
73
+
74
+ it('does not fetch on mount when isExpanded is false', () => {
75
+ const localStore = makeStore();
76
+ render(
77
+ <Provider store={localStore}>
78
+ <TemplateInvocation {...pollingProps} isExpanded={false} />
79
+ </Provider>
80
+ );
81
+ expect(apiGetSpy).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it('schedules next poll via setTimeout when auto_refresh is true and not finished', () => {
85
+ const localStore = makeStore();
86
+ render(
87
+ <Provider store={localStore}>
88
+ <TemplateInvocation {...pollingProps} />
89
+ </Provider>
90
+ );
91
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
92
+
93
+ act(() => jest.advanceTimersByTime(5000));
94
+ expect(apiGetSpy).toHaveBeenCalledTimes(2);
95
+ });
96
+
97
+ it('does not schedule next poll when finished is true', () => {
98
+ apiGetSpy.mockImplementation(({ handleSuccess }) => {
99
+ handleSuccess &&
100
+ handleSuccess({ data: { finished: true, auto_refresh: true } });
101
+ return { type: 'MOCK_GET' };
102
+ });
103
+ const localStore = makeStore();
104
+ render(
105
+ <Provider store={localStore}>
106
+ <TemplateInvocation {...pollingProps} />
107
+ </Provider>
108
+ );
109
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
110
+
111
+ act(() => jest.advanceTimersByTime(5000));
112
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
113
+ });
114
+
115
+ it('does not schedule next poll when auto_refresh is false', () => {
116
+ apiGetSpy.mockImplementation(({ handleSuccess }) => {
117
+ handleSuccess &&
118
+ handleSuccess({ data: { finished: false, auto_refresh: false } });
119
+ return { type: 'MOCK_GET' };
120
+ });
121
+ const localStore = makeStore();
122
+ render(
123
+ <Provider store={localStore}>
124
+ <TemplateInvocation {...pollingProps} />
125
+ </Provider>
126
+ );
127
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
128
+
129
+ act(() => jest.advanceTimersByTime(5000));
130
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
131
+ });
132
+
133
+ it('clears the polling timeout and sets cancelled on unmount', () => {
134
+ const localStore = makeStore();
135
+ const { unmount } = render(
136
+ <Provider store={localStore}>
137
+ <TemplateInvocation {...pollingProps} />
138
+ </Provider>
139
+ );
140
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
141
+
142
+ unmount();
143
+ act(() => jest.advanceTimersByTime(5000));
144
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
145
+ });
146
+
147
+ it('cancels in-flight callback when unmounted before handleSuccess runs', () => {
148
+ let capturedHandleSuccess;
149
+ apiGetSpy.mockImplementation(({ handleSuccess }) => {
150
+ capturedHandleSuccess = handleSuccess;
151
+ return { type: 'MOCK_GET' };
152
+ });
153
+
154
+ const localStore = makeStore();
155
+ const { unmount } = render(
156
+ <Provider store={localStore}>
157
+ <TemplateInvocation {...pollingProps} />
158
+ </Provider>
159
+ );
160
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
161
+
162
+ unmount();
163
+
164
+ act(() => {
165
+ capturedHandleSuccess({ data: { finished: false, auto_refresh: true } });
166
+ jest.advanceTimersByTime(5000);
167
+ });
168
+
169
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
170
+ });
171
+
172
+ it('re-fetches when isExpanded changes from false to true', () => {
173
+ const localStore = makeStore();
174
+ const { rerender } = render(
175
+ <Provider store={localStore}>
176
+ <TemplateInvocation {...pollingProps} isExpanded={false} />
177
+ </Provider>
178
+ );
179
+ expect(apiGetSpy).not.toHaveBeenCalled();
180
+
181
+ rerender(
182
+ <Provider store={localStore}>
183
+ <TemplateInvocation {...pollingProps} isExpanded />
184
+ </Provider>
185
+ );
186
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
187
+ });
188
+
189
+ it('does not re-fetch on expand when response is already finished', () => {
190
+ selectors.selectTemplateInvocation.mockImplementation(() => () => ({
191
+ ...mockTemplateInvocationResponse,
192
+ finished: true,
193
+ }));
194
+ apiGetSpy.mockImplementation(({ handleSuccess }) => {
195
+ handleSuccess &&
196
+ handleSuccess({ data: { finished: true, auto_refresh: false } });
197
+ return { type: 'MOCK_GET' };
198
+ });
199
+
200
+ const localStore = makeStore();
201
+ const { rerender } = render(
202
+ <Provider store={localStore}>
203
+ <TemplateInvocation {...pollingProps} isExpanded={false} />
204
+ </Provider>
205
+ );
206
+ expect(apiGetSpy).not.toHaveBeenCalled();
207
+
208
+ rerender(
209
+ <Provider store={localStore}>
210
+ <TemplateInvocation {...pollingProps} isExpanded />
211
+ </Provider>
212
+ );
213
+ // Selector already returns finished=true → guard skips dispatchFetch
214
+ expect(apiGetSpy).not.toHaveBeenCalled();
215
+
216
+ rerender(
217
+ <Provider store={localStore}>
218
+ <TemplateInvocation {...pollingProps} isExpanded={false} />
219
+ </Provider>
220
+ );
221
+ rerender(
222
+ <Provider store={localStore}>
223
+ <TemplateInvocation {...pollingProps} isExpanded />
224
+ </Provider>
225
+ );
226
+ // Second expand: still finished → still no fetch
227
+ expect(apiGetSpy).not.toHaveBeenCalled();
228
+ });
229
+
230
+ it('cancels existing poll and starts fresh when isExpanded changes', () => {
231
+ const localStore = makeStore();
232
+ const { rerender } = render(
233
+ <Provider store={localStore}>
234
+ <TemplateInvocation {...pollingProps} isExpanded />
235
+ </Provider>
236
+ );
237
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
238
+
239
+ rerender(
240
+ <Provider store={localStore}>
241
+ <TemplateInvocation {...pollingProps} isExpanded={false} />
242
+ </Provider>
243
+ );
244
+ act(() => jest.advanceTimersByTime(5000));
245
+ expect(apiGetSpy).toHaveBeenCalledTimes(1);
246
+
247
+ rerender(
248
+ <Provider store={localStore}>
249
+ <TemplateInvocation {...pollingProps} isExpanded />
250
+ </Provider>
251
+ );
252
+ expect(apiGetSpy).toHaveBeenCalledTimes(2);
253
+ });
254
+ });
@@ -42,6 +42,7 @@ export const jobInvocationData = {
42
42
  id: '37ad5ead-51de-4798-bc73-a17687c4d5aa',
43
43
  state: 'stopped',
44
44
  started_at: '2024-01-01 12:34:56 +0100',
45
+ cancellable: true,
45
46
  },
46
47
  template_invocations: [
47
48
  {
@@ -113,6 +114,7 @@ export const jobInvocationDataRecurring = {
113
114
  task: {
114
115
  id: '37ad5ead-51de-4798-bc73-a17687c4d5aa',
115
116
  state: 'scheduled',
117
+ cancellable: true,
116
118
  },
117
119
  mode: 'recurring',
118
120
  recurrence: {
@@ -5,13 +5,12 @@ import {
5
5
  PageSectionVariants,
6
6
  Skeleton,
7
7
  } from '@patternfly/react-core';
8
- import React, { useEffect, useMemo, useState } from 'react';
8
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
9
9
  import { translate as __, documentLocale } from 'foremanReact/common/I18n';
10
10
  import { useDispatch, useSelector } from 'react-redux';
11
11
  import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout';
12
12
  import PropTypes from 'prop-types';
13
13
  import SkeletonLoader from 'foremanReact/components/common/SkeletonLoader';
14
- import { stopInterval } from 'foremanReact/redux/middlewares/IntervalMiddleware';
15
14
  import { STATUS as API_STATUS } from 'foremanReact/constants';
16
15
  import {
17
16
  selectAPIErrorMessage,
@@ -25,12 +24,15 @@ import JobInvocationOverview from './JobInvocationOverview';
25
24
  import JobInvocationSystemStatusChart from './JobInvocationSystemStatusChart';
26
25
  import JobInvocationEmptyState from './JobInvocationEmptyState';
27
26
  import JobInvocationToolbarButtons from './JobInvocationToolbarButtons';
28
- import { getJobInvocation, getTask } from './JobInvocationActions';
27
+ import {
28
+ getJobInvocation,
29
+ stopJobInvocationPolling,
30
+ isJobFinished,
31
+ } from './JobInvocationActions';
29
32
  import './JobInvocationDetail.scss';
30
33
  import {
31
34
  DATE_OPTIONS,
32
35
  JOB_INVOCATION_KEY,
33
- STATUS,
34
36
  STATUS_UPPERCASE,
35
37
  } from './JobInvocationConstants';
36
38
  import { selectItems } from './JobInvocationSelectors';
@@ -50,11 +52,8 @@ const JobInvocationDetailPage = ({
50
52
  start_at: startAt,
51
53
  targeting = {},
52
54
  } = items;
53
- const finished =
54
- statusLabel === STATUS.FAILED ||
55
- statusLabel === STATUS.SUCCEEDED ||
56
- statusLabel === STATUS.CANCELLED;
57
- const autoRefresh = task?.state === STATUS.PENDING || false;
55
+ const finished = isJobFinished(statusLabel);
56
+ const pollTimeoutRef = useRef({ timeoutId: null, cancel: () => {} });
58
57
  const jobInvocationApiStatus = useSelector(state =>
59
58
  selectAPIStatus(state, JOB_INVOCATION_KEY)
60
59
  );
@@ -82,21 +81,11 @@ const JobInvocationDetailPage = ({
82
81
  }
83
82
 
84
83
  useEffect(() => {
85
- dispatch(getJobInvocation(`/api/job_invocations/${id}`));
86
- if (finished && !autoRefresh) {
87
- dispatch(stopInterval(JOB_INVOCATION_KEY));
88
- }
84
+ dispatch(getJobInvocation(`/api/job_invocations/${id}`, pollTimeoutRef));
89
85
  return () => {
90
- dispatch(stopInterval(JOB_INVOCATION_KEY));
86
+ stopJobInvocationPolling(pollTimeoutRef);
91
87
  };
92
- }, [dispatch, id, finished, autoRefresh]);
93
-
94
- const taskId = task?.id;
95
- useEffect(() => {
96
- if (taskId !== undefined) {
97
- dispatch(getTask(`${taskId}`));
98
- }
99
- }, [dispatch, taskId]);
88
+ }, [dispatch, id]);
100
89
 
101
90
  const apiFailed = jobInvocationApiStatus === API_STATUS.ERROR;
102
91
 
@@ -216,10 +205,8 @@ const JobInvocationDetailPage = ({
216
205
  <JobInvocationHostTable
217
206
  id={id}
218
207
  targeting={targeting}
219
- finished={finished}
220
- autoRefresh={autoRefresh}
221
208
  initialFilter={selectedFilter}
222
- statusLabel={statusLabel}
209
+ jobFinished={finished}
223
210
  onFilterUpdate={handleFilterChange}
224
211
  />
225
212
  </SkeletonLoader>
@@ -1,13 +1 @@
1
- import 'core-js/shim';
2
- import 'regenerator-runtime/runtime';
3
-
4
- import { configure } from '@theforeman/test';
5
- // eslint-disable-next-line import/no-extraneous-dependencies
6
- import Adapter from 'enzyme-adapter-react-16';
7
-
8
- configure({ adapter: new Adapter() });
9
-
10
- // Mocking translation function
11
- global.__ = str => str;
12
- global.n__ = str => str;
13
- global.Jed = { sprintf: str => str };
1
+ import 'foremanJSTestSetup';
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: 16.7.0
4
+ version: 17.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foreman Remote Execution team
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-07-13 00:00:00.000000000 Z
10
+ date: 2026-08-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: deface
@@ -202,6 +202,7 @@ files:
202
202
  - app/views/job_invocations/show.js.erb
203
203
  - app/views/job_invocations/show.json.erb
204
204
  - app/views/job_invocations/welcome.html.erb
205
+ - app/views/job_templates/_alerts.html.erb
205
206
  - app/views/job_templates/_custom_tab_headers.html.erb
206
207
  - app/views/job_templates/_custom_tabs.html.erb
207
208
  - app/views/job_templates/_import_job_template_modal.html.erb
@@ -397,10 +398,13 @@ files:
397
398
  - webpack/JobInvocationDetail/TemplateInvocationComponents/index.scss
398
399
  - webpack/JobInvocationDetail/TemplateInvocationPage.js
399
400
  - webpack/JobInvocationDetail/__tests__/JobInvocationDetailEmptyState.test.js
401
+ - webpack/JobInvocationDetail/__tests__/JobInvocationHostTablePolling.test.js
402
+ - webpack/JobInvocationDetail/__tests__/JobInvocationPolling.test.js
400
403
  - webpack/JobInvocationDetail/__tests__/MainInformation.test.js
401
404
  - webpack/JobInvocationDetail/__tests__/OutputCodeBlock.test.js
402
405
  - webpack/JobInvocationDetail/__tests__/TableToolbarActions.test.js
403
406
  - webpack/JobInvocationDetail/__tests__/TemplateInvocation.test.js
407
+ - webpack/JobInvocationDetail/__tests__/TemplateInvocationPolling.test.js
404
408
  - webpack/JobInvocationDetail/__tests__/fixtures.js
405
409
  - webpack/JobInvocationDetail/__tests__/foremanTestHelpers.js
406
410
  - webpack/JobInvocationDetail/index.js
@@ -564,7 +568,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
564
568
  - !ruby/object:Gem::Version
565
569
  version: '0'
566
570
  requirements: []
567
- rubygems_version: 4.0.10
571
+ rubygems_version: 4.0.16
568
572
  specification_version: 4
569
573
  summary: A plugin bringing remote execution to the Foreman, completing the config
570
574
  management functionality with remote management functionality.