foreman_remote_execution 14.1.4 → 15.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 (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
+ });