foreman_remote_execution 18.0.1 → 18.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/foreman_remote_execution/locale/fr/foreman_remote_execution.js +28 -28
  3. data/app/assets/javascripts/foreman_remote_execution/locale/ja/foreman_remote_execution.js +48 -48
  4. data/app/assets/javascripts/foreman_remote_execution/locale/ko/foreman_remote_execution.js +29 -29
  5. data/app/assets/javascripts/foreman_remote_execution/locale/zh_CN/foreman_remote_execution.js +22 -22
  6. data/app/assets/javascripts/foreman_remote_execution/template_invocation.js +0 -170
  7. data/app/assets/stylesheets/foreman_remote_execution/job_invocations.scss +0 -6
  8. data/app/controllers/job_invocations_controller.rb +1 -98
  9. data/app/helpers/job_invocations_helper.rb +0 -49
  10. data/app/helpers/remote_execution_helper.rb +23 -123
  11. data/config/routes.rb +0 -6
  12. data/lib/foreman_remote_execution/plugin.rb +2 -2
  13. data/lib/foreman_remote_execution/version.rb +1 -1
  14. data/locale/foreman_remote_execution.pot +2 -2
  15. data/locale/fr/LC_MESSAGES/foreman_remote_execution.mo +0 -0
  16. data/locale/fr/foreman_remote_execution.po +28 -28
  17. data/locale/ja/LC_MESSAGES/foreman_remote_execution.mo +0 -0
  18. data/locale/ja/foreman_remote_execution.po +48 -48
  19. data/locale/ko/LC_MESSAGES/foreman_remote_execution.mo +0 -0
  20. data/locale/ko/foreman_remote_execution.po +29 -29
  21. data/locale/zh_CN/LC_MESSAGES/foreman_remote_execution.mo +0 -0
  22. data/locale/zh_CN/foreman_remote_execution.po +22 -22
  23. data/package.json +1 -14
  24. data/test/functional/job_invocations_controller_test.rb +0 -77
  25. data/webpack/JobInvocationDetail/JobInvocationConstants.js +5 -0
  26. data/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js +0 -10
  27. data/webpack/JobInvocationDetail/TemplateInvocation.js +12 -69
  28. data/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputCodeBlock.js +94 -72
  29. data/webpack/JobInvocationDetail/TemplateInvocationHelpers.js +33 -0
  30. data/webpack/JobInvocationDetail/__tests__/JobInvocationDetailEmptyState.test.js +1 -1
  31. data/webpack/JobInvocationDetail/__tests__/MainInformation.test.js +0 -6
  32. data/webpack/JobInvocationDetail/__tests__/OutputCodeBlock.test.js +1 -1
  33. data/webpack/JobInvocationDetail/__tests__/TableToolbarActions.test.js +1 -1
  34. data/webpack/JobInvocationDetail/__tests__/TemplateInvocation.test.js +5 -1
  35. data/webpack/JobInvocationDetail/__tests__/TemplateInvocationHelpers.test.js +94 -0
  36. data/webpack/JobInvocationDetail/__tests__/TemplateInvocationPolling.test.js +338 -139
  37. data/webpack/JobInvocationDetail/useTemplateInvocationOutputPolling.js +185 -0
  38. data/webpack/JobWizard/JobWizardPageRerun.js +0 -10
  39. data/webpack/JobWizard/__tests__/autofill.test.js +23 -0
  40. data/webpack/JobWizard/__tests__/integration.test.js +1 -1
  41. data/webpack/JobWizard/autofill.js +18 -4
  42. data/webpack/JobWizard/index.js +1 -18
  43. data/webpack/JobWizard/steps/HostsAndInputs/__tests__/HostsAndInputs.test.js +49 -1
  44. data/webpack/global_index.js +0 -2
  45. data/webpack/index.js +0 -15
  46. data/webpack/react_app/components/RegistrationExtension/__tests__/RexInterface.test.js +1 -1
  47. metadata +6 -53
  48. data/app/assets/stylesheets/foreman_remote_execution/modal_window.scss +0 -4
  49. data/app/helpers/job_invocations_chart_helper.rb +0 -80
  50. data/app/views/job_invocations/_card_results.html.erb +0 -12
  51. data/app/views/job_invocations/_card_schedule.html.erb +0 -33
  52. data/app/views/job_invocations/_card_target_hosts.html.erb +0 -57
  53. data/app/views/job_invocations/_card_user_input.html.erb +0 -16
  54. data/app/views/job_invocations/_description_fields.html.erb +0 -4
  55. data/app/views/job_invocations/_form.html.erb +0 -144
  56. data/app/views/job_invocations/_preview_hosts_modal.html.erb +0 -15
  57. data/app/views/job_invocations/_rerun_taxonomies.html.erb +0 -22
  58. data/app/views/job_invocations/_tab_hosts.html.erb +0 -11
  59. data/app/views/job_invocations/_tab_overview.html.erb +0 -41
  60. data/app/views/job_invocations/_tab_preview_templates.html.erb +0 -20
  61. data/app/views/job_invocations/_user_input.html.erb +0 -21
  62. data/app/views/job_invocations/new.html.erb +0 -9
  63. data/app/views/job_invocations/refresh.js.erb +0 -4
  64. data/app/views/job_invocations/show.html.erb +0 -59
  65. data/app/views/job_invocations/show.js.erb +0 -12
  66. data/app/views/job_invocations/show.json.erb +0 -5
  67. data/test/support/remote_execution_helper.rb +0 -5
  68. data/webpack/react_app/components/TargetingHosts/TargetingHosts.js +0 -62
  69. data/webpack/react_app/components/TargetingHosts/TargetingHostsConsts.js +0 -2
  70. data/webpack/react_app/components/TargetingHosts/TargetingHostsHelpers.js +0 -10
  71. data/webpack/react_app/components/TargetingHosts/TargetingHostsLabelsRow.js +0 -45
  72. data/webpack/react_app/components/TargetingHosts/TargetingHostsLabelsRow.scss +0 -30
  73. data/webpack/react_app/components/TargetingHosts/TargetingHostsPage.js +0 -78
  74. data/webpack/react_app/components/TargetingHosts/TargetingHostsPage.scss +0 -3
  75. data/webpack/react_app/components/TargetingHosts/TargetingHostsSelectors.js +0 -25
  76. data/webpack/react_app/components/TargetingHosts/__tests__/HostItem.test.js +0 -76
  77. data/webpack/react_app/components/TargetingHosts/__tests__/HostStatus.test.js +0 -6
  78. data/webpack/react_app/components/TargetingHosts/__tests__/TargetingHosts.test.js +0 -6
  79. data/webpack/react_app/components/TargetingHosts/__tests__/TargetingHostsPage.test.js +0 -9
  80. data/webpack/react_app/components/TargetingHosts/__tests__/TargetingHostsSelectors.test.js +0 -31
  81. data/webpack/react_app/components/TargetingHosts/__tests__/__snapshots__/HostStatus.test.js.snap +0 -12
  82. data/webpack/react_app/components/TargetingHosts/__tests__/__snapshots__/TargetingHosts.test.js.snap +0 -96
  83. data/webpack/react_app/components/TargetingHosts/__tests__/__snapshots__/TargetingHostsPage.test.js.snap +0 -75
  84. data/webpack/react_app/components/TargetingHosts/__tests__/__snapshots__/TargetingHostsSelectors.test.js.snap +0 -16
  85. data/webpack/react_app/components/TargetingHosts/__tests__/fixtures.js +0 -56
  86. data/webpack/react_app/components/TargetingHosts/components/HostItem.js +0 -39
  87. data/webpack/react_app/components/TargetingHosts/components/HostStatus.js +0 -54
  88. data/webpack/react_app/components/TargetingHosts/index.js +0 -150
  89. data/webpack/react_app/components/jobInvocations/AggregateStatus/index.js +0 -60
  90. data/webpack/react_app/components/jobInvocations/AggregateStatus/index.test.js +0 -76
  91. data/webpack/react_app/components/jobInvocations/index.js +0 -94
  92. data/webpack/react_app/extend/reducers.js +0 -5
  93. data/webpack/react_app/redux/actions/jobInvocations/index.js +0 -87
  94. data/webpack/react_app/redux/consts.js +0 -5
  95. data/webpack/react_app/redux/reducers/index.js +0 -6
  96. data/webpack/react_app/redux/reducers/jobInvocations/index.fixtures.js +0 -46
  97. data/webpack/react_app/redux/reducers/jobInvocations/index.js +0 -34
  98. data/webpack/react_app/redux/reducers/jobInvocations/index.test.js +0 -43
@@ -1,25 +1,40 @@
1
+ /* eslint-disable max-lines */
1
2
  import React from 'react';
2
3
  import { createStore, applyMiddleware } from 'redux';
3
4
  import thunk from 'redux-thunk';
4
5
  import { Provider } from 'react-redux';
5
- import { render, act } from '@testing-library/react';
6
- import '@testing-library/jest-dom/extend-expect';
6
+ import { render, act, cleanup, screen } from '@testing-library/react';
7
+ import '@testing-library/jest-dom';
8
+ // eslint-disable-next-line import/no-extraneous-dependencies
9
+ import API from 'foremanReact/API';
10
+ import { addToast } from 'foremanReact/components/ToastsList';
7
11
  import * as api from 'foremanReact/redux/API';
8
12
  import * as selectors from '../JobInvocationSelectors';
9
13
  import { TemplateInvocation } from '../TemplateInvocation';
10
- import { mockTemplateInvocationResponse } from './fixtures';
14
+ import {
15
+ OUTPUT_MAX_REFRESH_INTERVAL_MS,
16
+ OUTPUT_REFRESH_INTERVAL_MS,
17
+ } from '../JobInvocationConstants';
18
+ import {
19
+ jobInvocationOutput,
20
+ mockTemplateInvocationResponse,
21
+ } from './fixtures';
11
22
 
12
- jest.spyOn(api, 'get');
23
+ jest.mock('foremanReact/API');
13
24
  jest.mock('../JobInvocationSelectors');
14
25
 
15
26
  jest.mock('foremanReact/components/ToastsList', () => ({
16
27
  addToast: jest.fn(payload => ({ type: 'ADD_TOAST', payload })),
17
28
  }));
18
29
 
30
+ const apiActionsGetSpy = jest.spyOn(api.APIActions, 'get');
31
+
19
32
  describe('TemplateInvocation polling', () => {
20
33
  const noop = () => {};
21
34
  const reducer = (state = {}) => state;
22
35
  const makeStore = () => createStore(reducer, applyMiddleware(thunk));
36
+ const outputURL = '/api/job_invocations/1/hosts/1';
37
+ const detailsURL = '/show_template_invocation_by_host/1/job_invocation/1';
23
38
 
24
39
  const pollingProps = {
25
40
  hostID: '1',
@@ -35,8 +50,28 @@ describe('TemplateInvocation polling', () => {
35
50
  showCommand: false,
36
51
  setShowCommand: noop,
37
52
  };
53
+ const renderInvocation = (props = {}) =>
54
+ render(
55
+ <Provider store={makeStore()}>
56
+ <TemplateInvocation {...pollingProps} {...props} />
57
+ </Provider>
58
+ );
59
+
60
+ const respond = (request, overrides = {}) => {
61
+ const data = {
62
+ ...mockTemplateInvocationResponse,
63
+ finished: false,
64
+ auto_refresh: true,
65
+ ...overrides.details,
66
+ };
67
+ if (request.handleSuccess) request.handleSuccess({ data });
68
+ return { type: 'MOCK_GET' };
69
+ };
70
+
71
+ const outputResponse = (overrides = {}) =>
72
+ Promise.resolve({ data: { output: [], refresh: true, ...overrides } });
38
73
 
39
- let apiGetSpy;
74
+ const flushPromises = () => act(async () => {});
40
75
 
41
76
  beforeEach(() => {
42
77
  jest.useFakeTimers({ legacyFakeTimers: true });
@@ -46,209 +81,373 @@ describe('TemplateInvocation polling', () => {
46
81
  selectors.selectTemplateInvocation.mockImplementation(() => () =>
47
82
  mockTemplateInvocationResponse
48
83
  );
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
- });
84
+ API.get.mockImplementation(() => outputResponse());
85
+ apiActionsGetSpy.mockImplementation(request => respond(request));
56
86
  });
57
87
 
58
88
  afterEach(() => {
89
+ cleanup();
59
90
  jest.clearAllTimers();
60
91
  jest.useRealTimers();
61
- apiGetSpy.mockRestore();
92
+ API.get.mockReset();
93
+ apiActionsGetSpy.mockReset();
94
+ addToast.mockClear();
62
95
  });
63
96
 
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>
97
+ it('requests only output added since the latest displayed chunk', () => {
98
+ renderInvocation();
99
+
100
+ expect(API.get).toHaveBeenCalledWith(
101
+ outputURL,
102
+ {},
103
+ {
104
+ since: jobInvocationOutput[jobInvocationOutput.length - 1].timestamp,
105
+ }
70
106
  );
71
- expect(apiGetSpy).toHaveBeenCalledTimes(1);
72
107
  });
73
108
 
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>
109
+ it('uses newly appended output as the cursor for the next poll', async () => {
110
+ const latestTimestamp = 1733931150.2044532;
111
+ API.get.mockImplementation(() =>
112
+ outputResponse({
113
+ output: [
114
+ {
115
+ output_type: 'stdout',
116
+ output: 'New live output\n',
117
+ timestamp: latestTimestamp,
118
+ },
119
+ ],
120
+ })
80
121
  );
81
- expect(apiGetSpy).not.toHaveBeenCalled();
122
+
123
+ renderInvocation();
124
+ await flushPromises();
125
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS));
126
+
127
+ expect(API.get.mock.calls[1]).toEqual([
128
+ outputURL,
129
+ {},
130
+ { since: latestTimestamp },
131
+ ]);
82
132
  });
83
133
 
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);
134
+ it('polls output once per output refresh interval', async () => {
135
+ renderInvocation();
136
+ expect(API.get).toHaveBeenCalledTimes(1);
137
+
138
+ await flushPromises();
139
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS));
92
140
 
93
- act(() => jest.advanceTimersByTime(5000));
94
- expect(apiGetSpy).toHaveBeenCalledTimes(2);
141
+ expect(API.get).toHaveBeenCalledTimes(2);
142
+ expect(API.get.mock.calls[1][0]).toBe(outputURL);
95
143
  });
96
144
 
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' };
145
+ it('gradually backs off while the job produces no output', async () => {
146
+ renderInvocation();
147
+ await flushPromises();
148
+
149
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS));
150
+ expect(API.get).toHaveBeenCalledTimes(2);
151
+ await flushPromises();
152
+
153
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS));
154
+ expect(API.get).toHaveBeenCalledTimes(2);
155
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS));
156
+ expect(API.get).toHaveBeenCalledTimes(3);
157
+ await flushPromises();
158
+
159
+ act(() =>
160
+ jest.advanceTimersByTime(
161
+ OUTPUT_MAX_REFRESH_INTERVAL_MS - OUTPUT_REFRESH_INTERVAL_MS
162
+ )
163
+ );
164
+ expect(API.get).toHaveBeenCalledTimes(3);
165
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS));
166
+ expect(API.get).toHaveBeenCalledTimes(4);
167
+ });
168
+
169
+ it('returns to fast polling when new output arrives', async () => {
170
+ let outputRequests = 0;
171
+ API.get.mockImplementation(() => {
172
+ outputRequests += 1;
173
+ return outputResponse({
174
+ output:
175
+ outputRequests === 3
176
+ ? [
177
+ {
178
+ output_type: 'stdout',
179
+ output: 'New live output\n',
180
+ timestamp: 1733931150.2044532,
181
+ },
182
+ ]
183
+ : [],
184
+ });
102
185
  });
103
- const localStore = makeStore();
104
- render(
105
- <Provider store={localStore}>
106
- <TemplateInvocation {...pollingProps} />
107
- </Provider>
186
+
187
+ renderInvocation();
188
+ await flushPromises();
189
+
190
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS));
191
+ await flushPromises();
192
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS * 2));
193
+ expect(API.get).toHaveBeenCalledTimes(3);
194
+ await flushPromises();
195
+
196
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS));
197
+ expect(API.get).toHaveBeenCalledTimes(4);
198
+ });
199
+
200
+ it('appends new output without replacing the displayed output', async () => {
201
+ API.get.mockImplementation(() =>
202
+ outputResponse({
203
+ output: [
204
+ {
205
+ output_type: 'stdout',
206
+ output: 'New live output\n',
207
+ timestamp: 1733931150.2044532,
208
+ },
209
+ ],
210
+ })
108
211
  );
109
- expect(apiGetSpy).toHaveBeenCalledTimes(1);
110
212
 
111
- act(() => jest.advanceTimersByTime(5000));
112
- expect(apiGetSpy).toHaveBeenCalledTimes(1);
213
+ renderInvocation();
214
+ await flushPromises();
215
+
216
+ expect(screen.getByText('This is red text')).toBeInTheDocument();
217
+ expect(screen.getByText('New live output')).toBeInTheDocument();
113
218
  });
114
219
 
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
- });
220
+ it('does not replace live output with a later Redux update', async () => {
221
+ let response = mockTemplateInvocationResponse;
121
222
  const localStore = makeStore();
223
+ selectors.selectTemplateInvocation.mockImplementation(() => () => response);
224
+ API.get.mockImplementation(() =>
225
+ outputResponse({
226
+ output: [
227
+ {
228
+ output_type: 'stdout',
229
+ output: 'New live output\n',
230
+ timestamp: 1733931150.2044532,
231
+ },
232
+ ],
233
+ })
234
+ );
235
+
122
236
  render(
123
237
  <Provider store={localStore}>
124
238
  <TemplateInvocation {...pollingProps} />
125
239
  </Provider>
126
240
  );
127
- expect(apiGetSpy).toHaveBeenCalledTimes(1);
241
+ await flushPromises();
242
+
243
+ response = {
244
+ ...mockTemplateInvocationResponse,
245
+ output: [
246
+ {
247
+ output_type: 'stdout',
248
+ output: 'Stale Redux output\n',
249
+ timestamp: 1,
250
+ },
251
+ ],
252
+ };
253
+ act(() => {
254
+ localStore.dispatch({ type: 'UPDATE' });
255
+ });
128
256
 
129
- act(() => jest.advanceTimersByTime(5000));
130
- expect(apiGetSpy).toHaveBeenCalledTimes(1);
257
+ expect(screen.getByText('New live output')).toBeInTheDocument();
258
+ expect(screen.queryByText('Stale Redux output')).not.toBeInTheDocument();
131
259
  });
132
260
 
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>
261
+ it('refreshes the complete details once after host output finishes', async () => {
262
+ API.get.mockImplementation(() => outputResponse({ refresh: false }));
263
+ apiActionsGetSpy.mockImplementation(request =>
264
+ respond(request, { details: { finished: false, auto_refresh: true } })
139
265
  );
140
- expect(apiGetSpy).toHaveBeenCalledTimes(1);
141
266
 
142
- unmount();
143
- act(() => jest.advanceTimersByTime(5000));
144
- expect(apiGetSpy).toHaveBeenCalledTimes(1);
267
+ renderInvocation();
268
+ await flushPromises();
269
+
270
+ expect(API.get).toHaveBeenCalledTimes(1);
271
+ expect(API.get.mock.calls[0][0]).toBe(outputURL);
272
+ expect(apiActionsGetSpy).toHaveBeenCalledTimes(1);
273
+ expect(apiActionsGetSpy.mock.calls[0][0].url).toBe(detailsURL);
274
+
275
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS * 2));
276
+
277
+ expect(API.get).toHaveBeenCalledTimes(1);
278
+ expect(apiActionsGetSpy).toHaveBeenCalledTimes(1);
145
279
  });
146
280
 
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
- });
281
+ it('loads complete details first when no cached response exists', () => {
282
+ selectors.selectTemplateInvocation.mockImplementation(() => () =>
283
+ undefined
284
+ );
153
285
 
154
- const localStore = makeStore();
155
- const { unmount } = render(
156
- <Provider store={localStore}>
157
- <TemplateInvocation {...pollingProps} />
158
- </Provider>
286
+ renderInvocation();
287
+
288
+ expect(apiActionsGetSpy).toHaveBeenCalledTimes(1);
289
+ expect(apiActionsGetSpy.mock.calls[0][0].url).toBe(detailsURL);
290
+
291
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS));
292
+
293
+ expect(API.get).toHaveBeenCalledTimes(1);
294
+ expect(API.get.mock.calls[0][0]).toBe(outputURL);
295
+ });
296
+
297
+ it('does not poll output when complete details disable auto refresh', () => {
298
+ selectors.selectTemplateInvocation.mockImplementation(() => () =>
299
+ undefined
300
+ );
301
+ apiActionsGetSpy.mockImplementation(request =>
302
+ respond(request, { details: { auto_refresh: false } })
159
303
  );
160
- expect(apiGetSpy).toHaveBeenCalledTimes(1);
161
304
 
162
- unmount();
305
+ renderInvocation();
163
306
 
164
- act(() => {
165
- capturedHandleSuccess({ data: { finished: false, auto_refresh: true } });
166
- jest.advanceTimersByTime(5000);
307
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS * 2));
308
+
309
+ expect(apiActionsGetSpy).toHaveBeenCalledTimes(1);
310
+ expect(apiActionsGetSpy.mock.calls[0][0].url).toBe(detailsURL);
311
+ expect(API.get).not.toHaveBeenCalled();
312
+ });
313
+
314
+ it('retries failed output requests with backoff and one toast', async () => {
315
+ API.get.mockRejectedValue({
316
+ response: { data: { error: { message: 'Output request failed' } } },
167
317
  });
168
318
 
169
- expect(apiGetSpy).toHaveBeenCalledTimes(1);
319
+ renderInvocation();
320
+ await flushPromises();
321
+
322
+ expect(API.get).toHaveBeenCalledTimes(1);
323
+ expect(addToast).toHaveBeenCalledWith(
324
+ expect.objectContaining({
325
+ type: 'danger',
326
+ message: 'Output request failed',
327
+ })
328
+ );
329
+
330
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS));
331
+ expect(API.get).toHaveBeenCalledTimes(2);
332
+ await flushPromises();
333
+
334
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS));
335
+ expect(API.get).toHaveBeenCalledTimes(2);
336
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS));
337
+
338
+ expect(API.get).toHaveBeenCalledTimes(3);
339
+ expect(addToast).toHaveBeenCalledTimes(1);
170
340
  });
171
341
 
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>
342
+ it('does not start output polling after a details request fails', () => {
343
+ selectors.selectTemplateInvocation.mockImplementation(() => () =>
344
+ undefined
178
345
  );
179
- expect(apiGetSpy).not.toHaveBeenCalled();
346
+ apiActionsGetSpy.mockImplementation(request => {
347
+ request.handleError();
348
+ return { type: 'MOCK_GET' };
349
+ });
180
350
 
181
- rerender(
182
- <Provider store={localStore}>
183
- <TemplateInvocation {...pollingProps} isExpanded />
184
- </Provider>
351
+ renderInvocation();
352
+ act(() => jest.advanceTimersByTime(OUTPUT_MAX_REFRESH_INTERVAL_MS));
353
+
354
+ expect(apiActionsGetSpy).toHaveBeenCalledTimes(1);
355
+ expect(apiActionsGetSpy.mock.calls[0][0].url).toBe(detailsURL);
356
+ expect(API.get).not.toHaveBeenCalled();
357
+ });
358
+
359
+ it('does not fetch while collapsed', () => {
360
+ renderInvocation({ isExpanded: false });
361
+
362
+ expect(API.get).not.toHaveBeenCalled();
363
+ expect(apiActionsGetSpy).not.toHaveBeenCalled();
364
+ });
365
+
366
+ it('pauses output requests while the document is hidden', () => {
367
+ const visibilityState = Object.getOwnPropertyDescriptor(
368
+ document,
369
+ 'visibilityState'
185
370
  );
186
- expect(apiGetSpy).toHaveBeenCalledTimes(1);
371
+ Object.defineProperty(document, 'visibilityState', {
372
+ configurable: true,
373
+ value: 'hidden',
374
+ });
375
+
376
+ try {
377
+ renderInvocation();
378
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS));
379
+ expect(API.get).not.toHaveBeenCalled();
380
+
381
+ Object.defineProperty(document, 'visibilityState', {
382
+ configurable: true,
383
+ value: 'visible',
384
+ });
385
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS));
386
+ expect(API.get).toHaveBeenCalledTimes(1);
387
+ } finally {
388
+ if (visibilityState) {
389
+ Object.defineProperty(document, 'visibilityState', visibilityState);
390
+ } else {
391
+ delete document.visibilityState;
392
+ }
393
+ }
187
394
  });
188
395
 
189
- it('does not re-fetch on expand when response is already finished', () => {
396
+ it('does not fetch when the cached response is already finished', () => {
190
397
  selectors.selectTemplateInvocation.mockImplementation(() => () => ({
191
398
  ...mockTemplateInvocationResponse,
192
399
  finished: true,
193
400
  }));
194
- apiGetSpy.mockImplementation(({ handleSuccess }) => {
195
- handleSuccess &&
196
- handleSuccess({ data: { finished: true, auto_refresh: false } });
197
- return { type: 'MOCK_GET' };
198
- });
199
401
 
402
+ renderInvocation();
403
+
404
+ expect(API.get).not.toHaveBeenCalled();
405
+ expect(apiActionsGetSpy).not.toHaveBeenCalled();
406
+ });
407
+
408
+ it('cancels a scheduled poll when collapsed', () => {
200
409
  const localStore = makeStore();
201
410
  const { rerender } = render(
202
411
  <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 />
412
+ <TemplateInvocation {...pollingProps} />
211
413
  </Provider>
212
414
  );
213
- // Selector already returns finished=true → guard skips dispatchFetch
214
- expect(apiGetSpy).not.toHaveBeenCalled();
415
+ expect(API.get).toHaveBeenCalledTimes(1);
215
416
 
216
417
  rerender(
217
418
  <Provider store={localStore}>
218
419
  <TemplateInvocation {...pollingProps} isExpanded={false} />
219
420
  </Provider>
220
421
  );
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();
422
+ act(() => jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS));
423
+
424
+ expect(API.get).toHaveBeenCalledTimes(1);
228
425
  });
229
426
 
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>
427
+ it('ignores an in-flight response after unmount', async () => {
428
+ let resolveRequest;
429
+ API.get.mockImplementation(
430
+ () =>
431
+ new Promise(resolve => {
432
+ resolveRequest = resolve;
433
+ })
236
434
  );
237
- expect(apiGetSpy).toHaveBeenCalledTimes(1);
238
435
 
239
- rerender(
240
- <Provider store={localStore}>
241
- <TemplateInvocation {...pollingProps} isExpanded={false} />
436
+ const { unmount } = render(
437
+ <Provider store={makeStore()}>
438
+ <TemplateInvocation {...pollingProps} />
242
439
  </Provider>
243
440
  );
244
- act(() => jest.advanceTimersByTime(5000));
245
- expect(apiGetSpy).toHaveBeenCalledTimes(1);
441
+ expect(API.get).toHaveBeenCalledTimes(1);
246
442
 
247
- rerender(
248
- <Provider store={localStore}>
249
- <TemplateInvocation {...pollingProps} isExpanded />
250
- </Provider>
251
- );
252
- expect(apiGetSpy).toHaveBeenCalledTimes(2);
443
+ unmount();
444
+ await act(async () => {
445
+ resolveRequest({ data: { output: [], refresh: true } });
446
+ });
447
+ act(() => {
448
+ jest.advanceTimersByTime(OUTPUT_REFRESH_INTERVAL_MS);
449
+ });
450
+
451
+ expect(API.get).toHaveBeenCalledTimes(1);
253
452
  });
254
453
  });