foreman_remote_execution 14.1.4 → 15.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/api/v2/job_invocations_controller.rb +8 -0
  3. data/app/controllers/template_invocations_controller.rb +57 -0
  4. data/app/controllers/ui_job_wizard_controller.rb +6 -3
  5. data/app/helpers/remote_execution_helper.rb +5 -6
  6. data/app/views/api/v2/job_invocations/base.json.rabl +1 -1
  7. data/app/views/api/v2/job_invocations/hosts.json.rabl +1 -1
  8. data/app/views/api/v2/job_invocations/main.json.rabl +1 -1
  9. data/app/views/api/v2/job_invocations/show.json.rabl +18 -0
  10. data/app/views/templates/script/convert2rhel_analyze.erb +4 -4
  11. data/config/routes.rb +2 -0
  12. data/lib/foreman_remote_execution/engine.rb +3 -3
  13. data/lib/foreman_remote_execution/version.rb +1 -1
  14. data/webpack/JobInvocationDetail/JobAdditionInfo.js +214 -0
  15. data/webpack/JobInvocationDetail/JobInvocationConstants.js +40 -2
  16. data/webpack/JobInvocationDetail/JobInvocationDetail.scss +70 -0
  17. data/webpack/JobInvocationDetail/JobInvocationHostTable.js +177 -80
  18. data/webpack/JobInvocationDetail/JobInvocationHostTableToolbar.js +63 -0
  19. data/webpack/JobInvocationDetail/JobInvocationSelectors.js +8 -1
  20. data/webpack/JobInvocationDetail/JobInvocationSystemStatusChart.js +61 -10
  21. data/webpack/JobInvocationDetail/OpenAlInvocations.js +111 -0
  22. data/webpack/JobInvocationDetail/TemplateInvocation.js +202 -0
  23. data/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputCodeBlock.js +124 -0
  24. data/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputToggleGroup.js +156 -0
  25. data/webpack/JobInvocationDetail/TemplateInvocationComponents/PreviewTemplate.js +50 -0
  26. data/webpack/JobInvocationDetail/TemplateInvocationComponents/TemplateActionButtons.js +224 -0
  27. data/webpack/JobInvocationDetail/TemplateInvocationPage.js +53 -0
  28. data/webpack/JobInvocationDetail/__tests__/MainInformation.test.js +1 -1
  29. data/webpack/JobInvocationDetail/__tests__/OpenAlInvocations.test.js +110 -0
  30. data/webpack/JobInvocationDetail/__tests__/OutputCodeBlock.test.js +69 -0
  31. data/webpack/JobInvocationDetail/__tests__/TemplateInvocation.test.js +131 -0
  32. data/webpack/JobInvocationDetail/__tests__/fixtures.js +130 -0
  33. data/webpack/JobInvocationDetail/index.js +18 -3
  34. data/webpack/JobWizard/JobWizard.js +38 -16
  35. data/webpack/JobWizard/{StartsBeforeErrorAlert.js → StartsErrorAlert.js} +16 -1
  36. data/webpack/JobWizard/steps/AdvancedFields/__tests__/AdvancedFields.test.js +1 -1
  37. data/webpack/JobWizard/steps/Schedule/ScheduleFuture.js +1 -1
  38. data/webpack/JobWizard/steps/Schedule/ScheduleRecurring.js +5 -3
  39. data/webpack/JobWizard/steps/Schedule/ScheduleType.js +1 -1
  40. data/webpack/JobWizard/steps/Schedule/__tests__/Schedule.test.js +3 -3
  41. data/webpack/JobWizard/steps/form/DateTimePicker.js +13 -0
  42. data/webpack/JobWizard/steps/form/Formatter.js +1 -0
  43. data/webpack/JobWizard/steps/form/ResourceSelect.js +34 -9
  44. data/webpack/Routes/routes.js +6 -0
  45. data/webpack/__mocks__/foremanReact/Root/Context/ForemanContext/index.js +1 -0
  46. data/webpack/react_app/components/RegistrationExtension/RexPull.js +27 -2
  47. data/webpack/react_app/components/TargetingHosts/components/HostStatus.js +1 -1
  48. metadata +15 -3
@@ -0,0 +1,224 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { useDispatch, useSelector } from 'react-redux';
4
+ import { Flex, FlexItem, Button } from '@patternfly/react-core';
5
+ import { ActionsColumn } from '@patternfly/react-table';
6
+ import { APIActions } from 'foremanReact/redux/API';
7
+ import { addToast } from 'foremanReact/components/ToastsList';
8
+ import { translate as __ } from 'foremanReact/common/I18n';
9
+ import { selectTemplateInvocation } from '../JobInvocationSelectors';
10
+
11
+ const actions = ({
12
+ taskID,
13
+ jobID,
14
+ hostID,
15
+ taskCancellable,
16
+ permissions,
17
+ dispatch,
18
+ }) => ({
19
+ rerun: {
20
+ name: 'template-invocation-rerun-job',
21
+ href: `/job_invocations/${jobID}/rerun?host_ids[]=${hostID}`,
22
+ component: 'a',
23
+ text: __('Rerun'),
24
+ permission: permissions.execute_jobs,
25
+ },
26
+ details: {
27
+ name: 'template-invocation-task-details',
28
+ href: `/foreman_tasks/tasks/${taskID}`,
29
+ component: 'a',
30
+ text: __('Task Details'),
31
+ permission: permissions.view_foreman_tasks,
32
+ },
33
+ cancel: {
34
+ name: 'template-invocation-cancel-job',
35
+ text: __('Cancel Task'),
36
+ permission: permissions.cancel_job_invocations,
37
+ onClick: () => {
38
+ dispatch(
39
+ addToast({
40
+ key: `cancel-job-info`,
41
+ type: 'info',
42
+ message: __('Trying to cancel the task for the host'),
43
+ })
44
+ );
45
+ dispatch(
46
+ APIActions.post({
47
+ url: `/foreman_tasks/tasks/${taskID}/cancel`,
48
+ key: 'CANCEL_TASK',
49
+ errorToast: ({ response }) => response.data.message,
50
+ successToast: () => __('Task for the host cancelled succesfully'),
51
+ })
52
+ );
53
+ },
54
+ isDisabled: !taskCancellable,
55
+ },
56
+ abort: {
57
+ name: 'template-invocation-abort-job',
58
+ text: __('Abort task'),
59
+ permission: permissions.cancel_job_invocations,
60
+ onClick: () => {
61
+ dispatch(
62
+ addToast({
63
+ key: `abort-job-info`,
64
+ type: 'info',
65
+ message: __('Trying to abort the task for the host'),
66
+ })
67
+ );
68
+ dispatch(
69
+ APIActions.post({
70
+ url: `/foreman_tasks/tasks/${taskID}/abort`,
71
+ key: 'ABORT_TASK',
72
+ errorToast: ({ response }) => response.data.message,
73
+ successToast: () => __('task aborted succesfully'),
74
+ })
75
+ );
76
+ },
77
+ isDisabled: !taskCancellable,
78
+ },
79
+ });
80
+
81
+ export const RowActions = ({ hostID, jobID }) => {
82
+ const dispatch = useDispatch();
83
+ const response = useSelector(selectTemplateInvocation);
84
+ if (!response?.permissions) return null;
85
+ const {
86
+ task_id: taskID,
87
+ task_cancellable: taskCancellable,
88
+ permissions,
89
+ } = response;
90
+
91
+ const getActions = actions({
92
+ taskID,
93
+ jobID,
94
+ hostID,
95
+ taskCancellable,
96
+ permissions,
97
+ dispatch,
98
+ });
99
+ const rowActions = Object.values(getActions)
100
+ .map(({ text, onClick, href, permission, isDisabled }) =>
101
+ permission
102
+ ? {
103
+ title: href ? (
104
+ <a href={href} target="_blank" rel="noreferrer">
105
+ {text}
106
+ </a>
107
+ ) : (
108
+ text
109
+ ),
110
+ onClick,
111
+ isDisabled,
112
+ }
113
+ : null
114
+ )
115
+ .filter(Boolean);
116
+
117
+ return <ActionsColumn items={rowActions} />;
118
+ };
119
+
120
+ export const TemplateActionButtons = ({
121
+ taskID,
122
+ jobID,
123
+ hostID,
124
+ taskCancellable,
125
+ permissions,
126
+ }) => {
127
+ const dispatch = useDispatch();
128
+ const { rerun, details, cancel, abort } = actions({
129
+ taskID,
130
+ jobID,
131
+ hostID,
132
+ taskCancellable,
133
+ permissions,
134
+ dispatch,
135
+ });
136
+ return (
137
+ <Flex align={{ default: 'alignRight' }}>
138
+ {rerun.permission && (
139
+ <FlexItem spacer={{ default: 'spacerXs' }}>
140
+ <Button
141
+ isSmall
142
+ variant="secondary"
143
+ isInline
144
+ ouiaId={rerun.name}
145
+ href={rerun.href}
146
+ component="a"
147
+ target="_blank"
148
+ >
149
+ {rerun.text}
150
+ </Button>
151
+ </FlexItem>
152
+ )}
153
+ {details.permission && (
154
+ <FlexItem spacer={{ default: 'spacerXs' }}>
155
+ <Button
156
+ isSmall
157
+ variant="secondary"
158
+ isInline
159
+ ouiaId={details.name}
160
+ href={details.href}
161
+ component="a"
162
+ target="_blank"
163
+ >
164
+ {details.text}
165
+ </Button>
166
+ </FlexItem>
167
+ )}
168
+ {cancel.permission && (
169
+ <FlexItem spacer={{ default: 'spacerXs' }}>
170
+ <Button
171
+ isSmall
172
+ variant="danger"
173
+ isInline
174
+ ouiaId={cancel.name}
175
+ onClick={cancel.onClick}
176
+ isDisabled={cancel.isDisabled}
177
+ >
178
+ {cancel.text}
179
+ </Button>
180
+ </FlexItem>
181
+ )}
182
+ {abort.permission && (
183
+ <FlexItem spacer={{ default: 'spacerXs' }}>
184
+ <Button
185
+ isSmall
186
+ variant="danger"
187
+ isInline
188
+ ouiaId={abort.name}
189
+ onClick={abort.onClick}
190
+ isDisabled={abort.isDisabled}
191
+ >
192
+ {abort.text}
193
+ </Button>
194
+ </FlexItem>
195
+ )}
196
+ </Flex>
197
+ );
198
+ };
199
+ TemplateActionButtons.propTypes = {
200
+ taskID: PropTypes.string,
201
+ jobID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
202
+ hostID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
203
+ taskCancellable: PropTypes.bool,
204
+ permissions: PropTypes.shape({
205
+ view_foreman_tasks: PropTypes.bool,
206
+ cancel_job_invocations: PropTypes.bool,
207
+ execute_jobs: PropTypes.bool,
208
+ }),
209
+ };
210
+
211
+ TemplateActionButtons.defaultProps = {
212
+ taskID: null,
213
+ taskCancellable: false,
214
+ permissions: {
215
+ view_foreman_tasks: false,
216
+ cancel_job_invocations: false,
217
+ execute_jobs: false,
218
+ },
219
+ };
220
+
221
+ RowActions.propTypes = {
222
+ hostID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
223
+ jobID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
224
+ };
@@ -0,0 +1,53 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { useSelector } from 'react-redux';
4
+ import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout';
5
+ import { translate as __, sprintf } from 'foremanReact/common/I18n';
6
+ import { TemplateInvocation } from './TemplateInvocation';
7
+ import { selectTemplateInvocation } from './JobInvocationSelectors';
8
+ import { jobInvocationDetailsUrl } from './JobInvocationConstants';
9
+
10
+ const TemplateInvocationPage = ({
11
+ match: {
12
+ params: { hostID, jobID },
13
+ },
14
+ }) => {
15
+ const {
16
+ job_invocation_description: jobDescription,
17
+ host_name: hostName,
18
+ } = useSelector(selectTemplateInvocation);
19
+ const description = sprintf(__('Template Invocation for %s'), hostName);
20
+ const breadcrumbOptions = {
21
+ breadcrumbItems: [
22
+ { caption: __('Jobs'), url: `/job_invocations` },
23
+ { caption: jobDescription, url: jobInvocationDetailsUrl(jobID) },
24
+ { caption: hostName },
25
+ ],
26
+ isPf4: true,
27
+ };
28
+ return (
29
+ <PageLayout
30
+ header={description}
31
+ breadcrumbOptions={breadcrumbOptions}
32
+ searchable={false}
33
+ >
34
+ <TemplateInvocation
35
+ hostID={hostID}
36
+ jobID={jobID}
37
+ isInTableView={false}
38
+ hostName={hostName}
39
+ />
40
+ </PageLayout>
41
+ );
42
+ };
43
+
44
+ TemplateInvocationPage.propTypes = {
45
+ match: PropTypes.shape({
46
+ params: PropTypes.shape({
47
+ jobID: PropTypes.string.isRequired,
48
+ hostID: PropTypes.string.isRequired,
49
+ }).isRequired,
50
+ }).isRequired,
51
+ };
52
+
53
+ export default TemplateInvocationPage;
@@ -105,7 +105,7 @@ describe('JobInvocationDetailPage', () => {
105
105
  expect(screen.getByText('Succeeded: 2')).toBeInTheDocument();
106
106
  expect(screen.getByText('Failed: 4')).toBeInTheDocument();
107
107
  expect(screen.getByText('In Progress: 0')).toBeInTheDocument();
108
- expect(screen.getByText('Canceled: 0')).toBeInTheDocument();
108
+ expect(screen.getByText('Cancelled: 0')).toBeInTheDocument();
109
109
 
110
110
  const informationToCheck = {
111
111
  'Effective user:': jobInvocationData.effective_user,
@@ -0,0 +1,110 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import '@testing-library/jest-dom/extend-expect';
4
+ import { OpenAlInvocations, PopupAlert } from '../OpenAlInvocations';
5
+ import { templateInvocationPageUrl } from '../JobInvocationConstants';
6
+
7
+ // Mock the templateInvocationPageUrl function
8
+ jest.mock('../JobInvocationConstants', () => ({
9
+ ...jest.requireActual('../JobInvocationConstants'),
10
+ templateInvocationPageUrl: jest.fn((resultId, id) => `url/${resultId}/${id}`),
11
+ }));
12
+
13
+ describe('OpenAlInvocations', () => {
14
+ const mockResults = [{ id: 1 }, { id: 2 }, { id: 3 }];
15
+ const mockSetShowAlert = jest.fn();
16
+ let windowSpy;
17
+ const windowOpen = window.open;
18
+
19
+ beforeAll(() => {
20
+ window.open = () => {};
21
+ });
22
+ afterAll(() => {
23
+ windowSpy.mockRestore();
24
+ jest.clearAllMocks();
25
+ window.open = windowOpen;
26
+ });
27
+
28
+ test('renders without crashing', () => {
29
+ render(
30
+ <OpenAlInvocations
31
+ results={mockResults}
32
+ id="test-id"
33
+ setShowAlert={mockSetShowAlert}
34
+ />
35
+ );
36
+ });
37
+
38
+ test('opens links when results length is less than or equal to 3', () => {
39
+ render(
40
+ <OpenAlInvocations
41
+ results={mockResults}
42
+ id="test-id"
43
+ setShowAlert={mockSetShowAlert}
44
+ />
45
+ );
46
+
47
+ const button = screen.getByRole('button', { name: /open all/i });
48
+ fireEvent.click(button);
49
+
50
+ expect(templateInvocationPageUrl).toHaveBeenCalledTimes(mockResults.length);
51
+ mockResults.forEach(result => {
52
+ expect(templateInvocationPageUrl).toHaveBeenCalledWith(
53
+ result.id,
54
+ 'test-id'
55
+ );
56
+ });
57
+ });
58
+
59
+ test('shows modal when results length is greater than 3', () => {
60
+ const largeResults = [...mockResults, { id: 4 }];
61
+ render(
62
+ <OpenAlInvocations
63
+ results={largeResults}
64
+ id="test-id"
65
+ setShowAlert={mockSetShowAlert}
66
+ />
67
+ );
68
+
69
+ const button = screen.getByRole('button', { name: /open all/i });
70
+ fireEvent.click(button);
71
+
72
+ expect(
73
+ screen.getAllByText(/open all invocations in new tabs/i)
74
+ ).toHaveLength(2);
75
+ });
76
+
77
+ test('shows alert when popups are blocked', () => {
78
+ window.open = jest.fn().mockReturnValueOnce(null);
79
+
80
+ render(
81
+ <OpenAlInvocations
82
+ results={mockResults}
83
+ id="test-id"
84
+ setShowAlert={mockSetShowAlert}
85
+ />
86
+ );
87
+
88
+ const button = screen.getByRole('button', { name: /open all/i });
89
+ fireEvent.click(button);
90
+
91
+ expect(mockSetShowAlert).toHaveBeenCalledWith(true);
92
+ });
93
+ });
94
+
95
+ describe('PopupAlert', () => {
96
+ const mockSetShowAlert = jest.fn();
97
+
98
+ test('renders without crashing', () => {
99
+ render(<PopupAlert setShowAlert={mockSetShowAlert} />);
100
+ });
101
+
102
+ test('closes alert when close button is clicked', () => {
103
+ render(<PopupAlert setShowAlert={mockSetShowAlert} />);
104
+
105
+ const closeButton = screen.getByRole('button', { name: /close/i });
106
+ fireEvent.click(closeButton);
107
+
108
+ expect(mockSetShowAlert).toHaveBeenCalledWith(false);
109
+ });
110
+ });
@@ -0,0 +1,69 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import '@testing-library/jest-dom/extend-expect';
4
+ import { OutputCodeBlock } from '../TemplateInvocationComponents/OutputCodeBlock';
5
+ import { jobInvocationOutput } from './fixtures';
6
+
7
+ const mockShowOutputType = {
8
+ stdout: true,
9
+ stderr: true,
10
+ debug: true,
11
+ };
12
+
13
+ describe('OutputCodeBlock', () => {
14
+ beforeAll(() => {
15
+ Element.prototype.scrollTo = () => {};
16
+ });
17
+ afterAll(() => {
18
+ delete Element.prototype.scrollTo;
19
+ });
20
+ test('displays the correct output', () => {
21
+ render(
22
+ <OutputCodeBlock
23
+ code={jobInvocationOutput}
24
+ showOutputType={mockShowOutputType}
25
+ scrollElement="body"
26
+ />
27
+ );
28
+
29
+ expect(screen.getByText('3:')).toBeInTheDocument();
30
+ expect(screen.getByText('This is red text')).toHaveStyle('color: red');
31
+ expect(screen.getByText('This is green text')).toHaveStyle(
32
+ 'color: lightgreen'
33
+ );
34
+ });
35
+
36
+ test('displays no output message when filtered', () => {
37
+ render(
38
+ <OutputCodeBlock
39
+ code={jobInvocationOutput}
40
+ showOutputType={{ stdout: false, stderr: false, debug: false }}
41
+ scrollElement="body"
42
+ />
43
+ );
44
+
45
+ expect(
46
+ screen.getByText('No output for the selected filters')
47
+ ).toBeInTheDocument();
48
+ });
49
+
50
+ test('scrolls to top and bottom', async () => {
51
+ render(
52
+ <div className="template-invocation">
53
+ <div className="invocation-output" style={{ overflow: 'auto' }}>
54
+ <OutputCodeBlock
55
+ code={jobInvocationOutput}
56
+ showOutputType={mockShowOutputType}
57
+ scrollElement=".invocation-output"
58
+ />
59
+ </div>
60
+ </div>
61
+ );
62
+
63
+ const scrollToTopButton = screen.getByText('Scroll to top');
64
+ const scrollToBottomButton = screen.getByText('Scroll to bottom');
65
+
66
+ fireEvent.click(scrollToTopButton);
67
+ fireEvent.click(scrollToBottomButton);
68
+ });
69
+ });
@@ -0,0 +1,131 @@
1
+ import React from 'react';
2
+ import configureMockStore from 'redux-mock-store';
3
+ import { Provider } from 'react-redux';
4
+ import { render, screen, act, fireEvent } from '@testing-library/react';
5
+ import '@testing-library/jest-dom/extend-expect';
6
+ import * as APIHooks from 'foremanReact/common/hooks/API/APIHooks';
7
+ import * as api from 'foremanReact/redux/API';
8
+ import { TemplateInvocation } from '../TemplateInvocation';
9
+ import { mockTemplateInvocationResponse } from './fixtures';
10
+
11
+ jest.spyOn(api, 'get');
12
+ jest.mock('foremanReact/common/hooks/API/APIHooks');
13
+ APIHooks.useAPI.mockImplementation(() => ({
14
+ response: mockTemplateInvocationResponse,
15
+ status: 'RESOLVED',
16
+ }));
17
+
18
+ const mockStore = configureMockStore([]);
19
+ const store = mockStore({
20
+ HOSTS_API: {
21
+ response: {
22
+ subtotal: 3,
23
+ },
24
+ },
25
+ });
26
+ describe('TemplateInvocation', () => {
27
+ test('render', async () => {
28
+ render(
29
+ <Provider store={store}>
30
+ <TemplateInvocation
31
+ hostID="1"
32
+ jobID="1"
33
+ isInTableView={false}
34
+ hostName="example-host"
35
+ />
36
+ </Provider>
37
+ );
38
+
39
+ expect(screen.getByText('Target:')).toBeInTheDocument();
40
+ expect(screen.getByText('example-host')).toBeInTheDocument();
41
+
42
+ expect(screen.getByText('This is red text')).toBeInTheDocument();
43
+ expect(screen.getByText('This is default text')).toBeInTheDocument();
44
+ });
45
+ test('filtering toggles', () => {
46
+ render(
47
+ <Provider store={store}>
48
+ <TemplateInvocation
49
+ hostID="1"
50
+ jobID="1"
51
+ isInTableView={false}
52
+ hostName="example-host"
53
+ />
54
+ </Provider>
55
+ );
56
+
57
+ act(() => {
58
+ fireEvent.click(screen.getByText('STDOUT'));
59
+ fireEvent.click(screen.getByText('DEBUG'));
60
+ fireEvent.click(screen.getByText('STDERR'));
61
+ });
62
+ expect(
63
+ screen.queryAllByText('No output for the selected filters')
64
+ ).toHaveLength(1);
65
+ expect(screen.queryAllByText('Exit status: 1')).toHaveLength(0);
66
+ expect(
67
+ screen.queryAllByText('StandardError: Job execution failed')
68
+ ).toHaveLength(0);
69
+
70
+ act(() => {
71
+ fireEvent.click(screen.getByText('STDOUT'));
72
+ });
73
+ expect(
74
+ screen.queryAllByText('No output for the selected filters')
75
+ ).toHaveLength(0);
76
+ expect(screen.queryAllByText('Exit status: 1')).toHaveLength(1);
77
+ expect(
78
+ screen.queryAllByText('StandardError: Job execution failed')
79
+ ).toHaveLength(0);
80
+
81
+ act(() => {
82
+ fireEvent.click(screen.getByText('DEBUG'));
83
+ });
84
+ expect(
85
+ screen.queryAllByText('No output for the selected filters')
86
+ ).toHaveLength(0);
87
+ expect(screen.queryAllByText('Exit status: 1')).toHaveLength(1);
88
+ expect(
89
+ screen.queryAllByText('StandardError: Job execution failed')
90
+ ).toHaveLength(1);
91
+ });
92
+ test('displays an error alert when there is an error', async () => {
93
+ APIHooks.useAPI.mockImplementation(() => ({
94
+ response: { response: { data: { error: 'Error message' } } },
95
+ status: 'ERROR',
96
+ }));
97
+
98
+ render(
99
+ <TemplateInvocation
100
+ hostID="1"
101
+ jobID="1"
102
+ isInTableView={false}
103
+ hostName="example-host"
104
+ />
105
+ );
106
+
107
+ expect(
108
+ screen.getByText(
109
+ 'An error occurred while fetching the template invocation details.'
110
+ )
111
+ ).toBeInTheDocument();
112
+ expect(screen.getByText('Error message')).toBeInTheDocument();
113
+ });
114
+
115
+ test('displays a skeleton while loading', async () => {
116
+ APIHooks.useAPI.mockImplementation(() => ({
117
+ response: {},
118
+ status: 'PENDING',
119
+ }));
120
+ render(
121
+ <TemplateInvocation
122
+ hostID="1"
123
+ jobID="1"
124
+ isInTableView={false}
125
+ hostName="example-host"
126
+ />
127
+ );
128
+
129
+ expect(document.querySelectorAll('.pf-c-skeleton')).toHaveLength(1);
130
+ });
131
+ });