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,5 +1,6 @@
1
+ /* eslint-disable max-lines */
1
2
  import PropTypes from 'prop-types';
2
- import React, { useEffect, useState } from 'react';
3
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
3
4
  import { useDispatch, useSelector } from 'react-redux';
4
5
  import { Button, Split, SplitItem } from '@patternfly/react-core';
5
6
  import { UndoIcon } from '@patternfly/react-icons';
@@ -12,6 +13,7 @@ import {
12
13
  } from '@patternfly/react-core/deprecated';
13
14
  import { translate as __ } from 'foremanReact/common/I18n';
14
15
  import { foremanUrl } from 'foremanReact/common/helpers';
16
+ import { usePermissions } from 'foremanReact/common/hooks/Permissions/permissionHooks';
15
17
  import { get } from 'foremanReact/redux/API';
16
18
  import {
17
19
  cancelJob,
@@ -23,10 +25,7 @@ import {
23
25
  GET_REPORT_TEMPLATES,
24
26
  GET_REPORT_TEMPLATE_INPUTS,
25
27
  } from './JobInvocationConstants';
26
- import {
27
- selectTaskCancelable,
28
- selectHasPermission,
29
- } from './JobInvocationSelectors';
28
+ import { selectTaskCancelable } from './JobInvocationSelectors';
30
29
 
31
30
  const JobInvocationToolbarButtons = ({ jobId, data }) => {
32
31
  const { succeeded, failed, task, recurrence, permissions } = data;
@@ -38,165 +37,240 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => {
38
37
  ? permissions.edit_recurring_logics
39
38
  : false;
40
39
  const isTaskCancelable = useSelector(selectTaskCancelable);
41
- const useHasPermission = permissionRequired =>
42
- useSelector(selectHasPermission(permissionRequired));
40
+ const canCreateJobInvocations = usePermissions(['create_job_invocations']);
41
+ const canCancelJobInvocations = usePermissions(['cancel_job_invocations']);
42
+ const canGenerateReportTemplates = usePermissions([
43
+ 'generate_report_templates',
44
+ ]);
43
45
  const [isActionOpen, setIsActionOpen] = useState(false);
44
46
  const [reportTemplateJobId, setReportTemplateJobId] = useState(undefined);
45
47
  const [templateInputId, setTemplateInputId] = useState(undefined);
46
- const queryParams = new URLSearchParams({
47
- [`report_template_report[input_values][${templateInputId}][value]`]: jobId,
48
- });
49
48
  const dispatch = useDispatch();
49
+ const reportHref = useMemo(() => {
50
+ if (reportTemplateJobId === undefined || templateInputId === undefined) {
51
+ return undefined;
52
+ }
53
+ const queryParams = new URLSearchParams({
54
+ [`report_template_report[input_values][${templateInputId}][value]`]: jobId,
55
+ });
56
+ return foremanUrl(
57
+ `/templates/report_templates/${reportTemplateJobId}/generate?${queryParams.toString()}`
58
+ );
59
+ }, [jobId, reportTemplateJobId, templateInputId]);
50
60
 
51
- const onActionFocus = () => {
61
+ const isCreateReportDisabled =
62
+ !canGenerateReportTemplates ||
63
+ task?.state === STATUS.RUNNING ||
64
+ task?.state === STATUS.PENDING ||
65
+ reportHref === undefined;
66
+
67
+ const onActionFocus = useCallback(() => {
52
68
  const element = document.getElementById(
53
69
  `toggle-split-button-action-primary-${jobId}`
54
70
  );
55
- element.focus();
56
- };
57
- const onActionSelect = () => {
71
+ if (element) {
72
+ element.focus();
73
+ }
74
+ }, [jobId]);
75
+ const onActionSelect = useCallback(() => {
58
76
  setIsActionOpen(false);
59
77
  onActionFocus();
60
- };
78
+ }, [onActionFocus]);
79
+ const onActionToggle = useCallback((_event, val) => setIsActionOpen(val), []);
61
80
 
62
81
  useEffect(() => {
82
+ let isMounted = true;
63
83
  dispatch(
64
84
  get({
65
85
  key: GET_REPORT_TEMPLATES,
66
86
  url: '/api/report_templates',
67
87
  handleSuccess: ({ data: { results } }) => {
68
- setReportTemplateJobId(
69
- results.find(result => result.name === 'Job - Invocation Report')
70
- ?.id
71
- );
88
+ if (isMounted) {
89
+ setReportTemplateJobId(
90
+ results.find(result => result.name === 'Job - Invocation Report')
91
+ ?.id
92
+ );
93
+ }
72
94
  },
73
95
  handleError: () => {
74
- setReportTemplateJobId(undefined);
96
+ if (isMounted) {
97
+ setReportTemplateJobId(undefined);
98
+ }
75
99
  },
76
100
  })
77
101
  );
102
+ return () => {
103
+ isMounted = false;
104
+ };
78
105
  }, [dispatch]);
79
106
  useEffect(() => {
107
+ let isMounted = true;
80
108
  if (reportTemplateJobId !== undefined) {
81
109
  dispatch(
82
110
  get({
83
111
  key: GET_REPORT_TEMPLATE_INPUTS,
84
112
  url: `/api/templates/${reportTemplateJobId}/template_inputs`,
85
113
  handleSuccess: ({ data: { results } }) => {
86
- setTemplateInputId(
87
- results.find(result => result.name === 'job_id')?.id
88
- );
114
+ if (isMounted) {
115
+ setTemplateInputId(
116
+ results.find(result => result.name === 'job_id')?.id
117
+ );
118
+ }
89
119
  },
90
120
  handleError: () => {
91
- setTemplateInputId(undefined);
121
+ if (isMounted) {
122
+ setTemplateInputId(undefined);
123
+ }
92
124
  },
93
125
  })
94
126
  );
95
127
  }
128
+ return () => {
129
+ isMounted = false;
130
+ };
96
131
  }, [dispatch, reportTemplateJobId]);
97
132
 
98
- const recurrenceDropdownItems = recurrence
99
- ? [
100
- <DropdownSeparator ouiaId="dropdown-separator-1" key="separator-1" />,
101
- <DropdownItem
102
- ouiaId="change-enabled-recurring-dropdown-item"
103
- onClick={() =>
104
- dispatch(
105
- enableRecurringLogic(recurrence?.id, recurringEnabled, jobId)
106
- )
107
- }
108
- key="change-enabled-recurring"
109
- component="button"
110
- isDisabled={
111
- recurrence?.id === undefined ||
112
- recurrence?.state === 'cancelled' ||
113
- !canEditRecurringLogic
114
- }
115
- >
116
- {recurringEnabled ? __('Disable recurring') : __('Enable recurring')}
117
- </DropdownItem>,
118
- <DropdownItem
119
- ouiaId="cancel-recurring-dropdown-item"
120
- onClick={() => dispatch(cancelRecurringLogic(recurrence?.id, jobId))}
121
- key="cancel-recurring"
122
- component="button"
123
- isDisabled={
124
- recurrence?.id === undefined ||
125
- recurrence?.state === 'cancelled' ||
126
- !canEditRecurringLogic
127
- }
128
- >
129
- {__('Cancel recurring')}
130
- </DropdownItem>,
131
- ]
132
- : [];
133
+ const recurrenceDropdownItems = useMemo(
134
+ () =>
135
+ recurrence
136
+ ? [
137
+ <DropdownSeparator
138
+ ouiaId="dropdown-separator-1"
139
+ key="separator-1"
140
+ />,
141
+ <DropdownItem
142
+ ouiaId="change-enabled-recurring-dropdown-item"
143
+ onClick={() =>
144
+ dispatch(enableRecurringLogic(recurrence?.id, recurringEnabled))
145
+ }
146
+ key="change-enabled-recurring"
147
+ component="button"
148
+ isDisabled={
149
+ recurrence?.id === undefined ||
150
+ recurrence?.state === 'cancelled' ||
151
+ !canEditRecurringLogic
152
+ }
153
+ >
154
+ {recurringEnabled
155
+ ? __('Disable recurring')
156
+ : __('Enable recurring')}
157
+ </DropdownItem>,
158
+ <DropdownItem
159
+ ouiaId="cancel-recurring-dropdown-item"
160
+ onClick={() => dispatch(cancelRecurringLogic(recurrence?.id))}
161
+ key="cancel-recurring"
162
+ component="button"
163
+ isDisabled={
164
+ recurrence?.id === undefined ||
165
+ recurrence?.state === 'cancelled' ||
166
+ !canEditRecurringLogic
167
+ }
168
+ >
169
+ {__('Cancel recurring')}
170
+ </DropdownItem>,
171
+ ]
172
+ : [],
173
+ [recurrence, recurringEnabled, canEditRecurringLogic, dispatch]
174
+ );
133
175
 
134
- const dropdownItems = [
135
- <DropdownItem
136
- ouiaId="rerun-succeeded-dropdown-item"
137
- href={foremanUrl(`/job_invocations/${jobId}/rerun?succeeded_only=1`)}
138
- key="rerun-succeeded"
139
- isDisabled={
140
- !useHasPermission('create_job_invocations') || !(succeeded > 0)
141
- }
142
- description="Rerun job on successful hosts"
143
- >
144
- {__('Rerun successful')}
145
- </DropdownItem>,
146
- <DropdownItem
147
- ouiaId="rerun-failed-dropdown-item"
148
- href={foremanUrl(`/job_invocations/${jobId}/rerun?failed_only=1`)}
149
- key="rerun-failed"
150
- isDisabled={!useHasPermission('create_job_invocations') || !(failed > 0)}
151
- description="Rerun job on failed hosts"
152
- >
153
- {__('Rerun failed')}
154
- </DropdownItem>,
155
- <DropdownItem
156
- ouiaId="view-task-dropdown-item"
157
- href={foremanUrl(`/foreman_tasks/tasks/${task?.id}`)}
158
- key="view-task"
159
- isDisabled={!canViewForemanTasks || task === undefined}
160
- description="See details of latest task"
161
- >
162
- {__('View task')}
163
- </DropdownItem>,
164
- <DropdownSeparator ouiaId="dropdown-separator-0" key="separator-0" />,
165
- <DropdownItem
166
- ouiaId="cancel-dropdown-item"
167
- onClick={() => dispatch(cancelJob(jobId, false))}
168
- key="cancel"
169
- component="button"
170
- isDisabled={
171
- !useHasPermission('cancel_job_invocations') || !isTaskCancelable
172
- }
173
- description="Cancel job gracefully"
174
- >
175
- {__('Cancel')}
176
- </DropdownItem>,
177
- <DropdownItem
178
- ouiaId="abort-dropdown-item"
179
- onClick={() => dispatch(cancelJob(jobId, true))}
180
- key="abort"
181
- component="button"
182
- isDisabled={
183
- !useHasPermission('cancel_job_invocations') || !isTaskCancelable
184
- }
185
- description="Cancel job immediately"
186
- >
187
- {__('Abort')}
188
- </DropdownItem>,
189
- ...recurrenceDropdownItems,
190
- <DropdownSeparator ouiaId="dropdown-separator-2" key="separator-2" />,
191
- <DropdownItem
192
- ouiaId="legacy-ui-dropdown-item"
193
- icon={<UndoIcon />}
194
- href={`/legacy/job_invocations/${jobId}`}
195
- key="legacy-ui"
196
- >
197
- {__('Legacy UI')}
198
- </DropdownItem>,
199
- ];
176
+ const dropdownItems = useMemo(
177
+ () => [
178
+ <DropdownItem
179
+ ouiaId="rerun-succeeded-dropdown-item"
180
+ href={foremanUrl(`/job_invocations/${jobId}/rerun?succeeded_only=1`)}
181
+ key="rerun-succeeded"
182
+ isDisabled={!canCreateJobInvocations || !(succeeded > 0)}
183
+ description="Rerun job on successful hosts"
184
+ >
185
+ {__('Rerun successful')}
186
+ </DropdownItem>,
187
+ <DropdownItem
188
+ ouiaId="rerun-failed-dropdown-item"
189
+ href={foremanUrl(`/job_invocations/${jobId}/rerun?failed_only=1`)}
190
+ key="rerun-failed"
191
+ isDisabled={!canCreateJobInvocations || !(failed > 0)}
192
+ description="Rerun job on failed hosts"
193
+ >
194
+ {__('Rerun failed')}
195
+ </DropdownItem>,
196
+ <DropdownItem
197
+ ouiaId="view-task-dropdown-item"
198
+ href={foremanUrl(`/foreman_tasks/tasks/${task?.id}`)}
199
+ key="view-task"
200
+ isDisabled={!canViewForemanTasks || task === undefined}
201
+ description="See details of latest task"
202
+ >
203
+ {__('View task')}
204
+ </DropdownItem>,
205
+ <DropdownSeparator ouiaId="dropdown-separator-0" key="separator-0" />,
206
+ <DropdownItem
207
+ ouiaId="cancel-dropdown-item"
208
+ onClick={() => dispatch(cancelJob(jobId, false))}
209
+ key="cancel"
210
+ component="button"
211
+ isDisabled={!canCancelJobInvocations || !isTaskCancelable}
212
+ description="Cancel job gracefully"
213
+ >
214
+ {__('Cancel')}
215
+ </DropdownItem>,
216
+ <DropdownItem
217
+ ouiaId="abort-dropdown-item"
218
+ onClick={() => dispatch(cancelJob(jobId, true))}
219
+ key="abort"
220
+ component="button"
221
+ isDisabled={!canCancelJobInvocations || !isTaskCancelable}
222
+ description="Cancel job immediately"
223
+ >
224
+ {__('Abort')}
225
+ </DropdownItem>,
226
+ ...recurrenceDropdownItems,
227
+ <DropdownSeparator ouiaId="dropdown-separator-2" key="separator-2" />,
228
+ <DropdownItem
229
+ ouiaId="legacy-ui-dropdown-item"
230
+ icon={<UndoIcon />}
231
+ href={`/legacy/job_invocations/${jobId}`}
232
+ key="legacy-ui"
233
+ >
234
+ {__('Legacy UI')}
235
+ </DropdownItem>,
236
+ ],
237
+ [
238
+ canCancelJobInvocations,
239
+ canCreateJobInvocations,
240
+ canViewForemanTasks,
241
+ dispatch,
242
+ failed,
243
+ isTaskCancelable,
244
+ jobId,
245
+ recurrenceDropdownItems,
246
+ succeeded,
247
+ task,
248
+ ]
249
+ );
250
+
251
+ const dropdownToggle = useMemo(
252
+ () => (
253
+ <DropdownToggle
254
+ ouiaId="toggle-button-action-primary"
255
+ id={`toggle-split-button-action-primary-${jobId}`}
256
+ splitButtonItems={[
257
+ <Button
258
+ component="a"
259
+ ouiaId="button-rerun-all"
260
+ key="rerun"
261
+ href={foremanUrl(`/job_invocations/${jobId}/rerun`)}
262
+ variant="control"
263
+ isDisabled={!canCreateJobInvocations}
264
+ >
265
+ {__(`Rerun all`)}
266
+ </Button>,
267
+ ]}
268
+ splitButtonVariant="action"
269
+ onToggle={onActionToggle}
270
+ />
271
+ ),
272
+ [canCreateJobInvocations, jobId, onActionToggle]
273
+ );
200
274
 
201
275
  return (
202
276
  <>
@@ -206,15 +280,9 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => {
206
280
  component="a"
207
281
  ouiaId="button-create-report"
208
282
  className="button-create-report"
209
- href={foremanUrl(
210
- `/templates/report_templates/${reportTemplateJobId}/generate?${queryParams.toString()}`
211
- )}
283
+ href={reportHref}
212
284
  variant="secondary"
213
- isDisabled={
214
- !useHasPermission('generate_report_templates') ||
215
- task?.state === STATUS.PENDING ||
216
- templateInputId === undefined
217
- }
285
+ isDisabled={isCreateReportDisabled}
218
286
  >
219
287
  {__(`Create report`)}
220
288
  </Button>
@@ -224,26 +292,7 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => {
224
292
  ouiaId="job-invocation-global-actions-dropdown"
225
293
  onSelect={onActionSelect}
226
294
  position={DropdownPosition.right}
227
- toggle={
228
- <DropdownToggle
229
- ouiaId="toggle-button-action-primary"
230
- id={`toggle-split-button-action-primary-${jobId}`}
231
- splitButtonItems={[
232
- <Button
233
- component="a"
234
- ouiaId="button-rerun-all"
235
- key="rerun"
236
- href={foremanUrl(`/job_invocations/${jobId}/rerun`)}
237
- variant="control"
238
- isDisabled={!useHasPermission('create_job_invocations')}
239
- >
240
- {__(`Rerun all`)}
241
- </Button>,
242
- ]}
243
- splitButtonVariant="action"
244
- onToggle={(_event, val) => setIsActionOpen(val)}
245
- />
246
- }
295
+ toggle={dropdownToggle}
247
296
  isOpen={isActionOpen}
248
297
  dropdownItems={dropdownItems}
249
298
  />
@@ -11,6 +11,7 @@ import {
11
11
  showTemplateInvocationUrl,
12
12
  templateInvocationPageUrl,
13
13
  GET_TEMPLATE_INVOCATION,
14
+ AUTO_REFRESH_INTERVAL_MS,
14
15
  } from './JobInvocationConstants';
15
16
  import {
16
17
  selectTemplateInvocationStatus,
@@ -67,7 +68,7 @@ export const TemplateInvocation = ({
67
68
  showCommand,
68
69
  setShowCommand,
69
70
  }) => {
70
- const intervalRef = useRef(null);
71
+ const timeoutRef = useRef(null);
71
72
  const templateURL = showTemplateInvocationUrl(hostID, jobID);
72
73
  const hostDetailsPageUrl = useForemanHostDetailsPageUrl();
73
74
 
@@ -81,43 +82,48 @@ export const TemplateInvocation = ({
81
82
  }, [response]);
82
83
 
83
84
  useEffect(() => {
84
- const dispatchFetch = () => {
85
+ let cancelled = false;
86
+
87
+ const schedulePoll = () => {
88
+ if (cancelled) return;
85
89
  dispatch(
86
90
  APIActions.get({
87
91
  url: templateURL,
88
92
  key: `${GET_TEMPLATE_INVOCATION}_${hostID}`,
93
+ handleSuccess: ({ data }) => {
94
+ if (cancelled) return;
95
+ const isFinished = data?.finished ?? true;
96
+ // eslint-disable-next-line camelcase
97
+ const autoRefresh = data?.auto_refresh || false;
98
+ if (!isFinished && autoRefresh) {
99
+ timeoutRef.current = setTimeout(
100
+ schedulePoll,
101
+ AUTO_REFRESH_INTERVAL_MS
102
+ );
103
+ } else {
104
+ timeoutRef.current = null;
105
+ }
106
+ },
107
+ handleError: () => {
108
+ if (cancelled) return;
109
+ timeoutRef.current = null;
110
+ },
89
111
  })
90
112
  );
91
113
  };
92
114
 
93
- if (intervalRef.current) {
94
- clearInterval(intervalRef.current);
95
- intervalRef.current = null;
96
- }
115
+ clearTimeout(timeoutRef.current);
116
+ timeoutRef.current = null;
97
117
 
98
118
  if (isExpanded) {
99
- if (isEmpty(responseRef.current)) {
100
- dispatchFetch();
101
- }
102
-
103
- intervalRef.current = setInterval(() => {
104
- const latestResponse = responseRef.current;
105
- const finished = latestResponse?.finished ?? true;
106
- // eslint-disable-next-line camelcase
107
- const autoRefresh = latestResponse?.auto_refresh || false;
108
-
109
- if (!finished && autoRefresh) {
110
- dispatchFetch();
111
- } else if (intervalRef.current) {
112
- clearInterval(intervalRef.current);
113
- }
114
- }, 5000);
119
+ if (responseRef.current?.finished) return undefined;
120
+ schedulePoll();
115
121
  }
116
122
 
117
123
  return () => {
118
- if (intervalRef.current) {
119
- clearInterval(intervalRef.current);
120
- }
124
+ cancelled = true;
125
+ clearTimeout(timeoutRef.current);
126
+ timeoutRef.current = null;
121
127
  };
122
128
  }, [isExpanded, dispatch, templateURL, hostID]);
123
129
 
@@ -0,0 +1,53 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import '@testing-library/jest-dom/extend-expect';
4
+ import { createMemoryHistory } from 'history';
5
+ import { Router } from 'react-router-dom';
6
+ import JobInvocationEmptyState from '../JobInvocationEmptyState';
7
+ import { createForemanContextWrapper } from './foremanTestHelpers';
8
+
9
+ describe('JobInvocationEmptyState', () => {
10
+ it('renders the failed load empty state for a job invocation', () => {
11
+ const jobInvocationId = '99';
12
+ const errorMessage = 'Record not found';
13
+ const history = createMemoryHistory();
14
+ const ForemanContextWrapper = createForemanContextWrapper();
15
+
16
+ render(
17
+ <Router history={history}>
18
+ <ForemanContextWrapper>
19
+ <JobInvocationEmptyState
20
+ jobInvocationId={jobInvocationId}
21
+ httpStatus={404}
22
+ errorMessage={errorMessage}
23
+ />
24
+ </ForemanContextWrapper>
25
+ </Router>
26
+ );
27
+
28
+ expect(
29
+ screen.getByRole('heading', {
30
+ name: 'Unable to load job invocation',
31
+ level: 5,
32
+ })
33
+ ).toBeInTheDocument();
34
+ expect(
35
+ screen.getByText(
36
+ (_, element) =>
37
+ element?.tagName === 'P' &&
38
+ element.textContent?.includes(
39
+ `The job invocation with id ${jobInvocationId} could not be found. It may have been deleted or may not be available in your current organization or location scope.`
40
+ )
41
+ )
42
+ ).toBeInTheDocument();
43
+ expect(
44
+ screen.getByText(`Server returned: ${errorMessage}`)
45
+ ).toBeInTheDocument();
46
+ expect(
47
+ screen.getByRole('button', { name: 'Go to job invocations' })
48
+ ).toBeInTheDocument();
49
+ expect(
50
+ screen.getByRole('button', { name: 'Create a new job invocation' })
51
+ ).toBeInTheDocument();
52
+ });
53
+ });