foreman-tasks 12.2.3 → 13.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/foreman_tasks/tasks_controller.rb +0 -5
  3. data/config/routes.rb +3 -2
  4. data/lib/foreman_tasks/engine.rb +2 -2
  5. data/lib/foreman_tasks/version.rb +1 -1
  6. data/test/controllers/tasks_controller_test.rb +0 -9
  7. data/test/foreman_tasks_test_helper.rb +2 -2
  8. data/test/integration/tasks_test.rb +17 -0
  9. data/test/test_plugin_helper.rb +8 -0
  10. data/webpack/ForemanTasks/Components/TaskActions/TaskAction.test.js +212 -42
  11. data/webpack/ForemanTasks/Components/TaskDetails/Components/Errors.js +224 -55
  12. data/webpack/ForemanTasks/Components/TaskDetails/Components/Errors.scss +41 -0
  13. data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskInfo.js +6 -51
  14. data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskSkeleton.js +1 -6
  15. data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Errors.test.js +159 -11
  16. data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Task.test.js +0 -2
  17. data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskButtons.test.js +0 -2
  18. data/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.js +29 -15
  19. data/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.scss +1 -5
  20. data/webpack/ForemanTasks/Components/TaskDetails/TaskDetailsConstants.js +2 -1
  21. data/webpack/ForemanTasks/Components/TaskDetails/TaskDetailsSelectors.js +20 -5
  22. data/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.fixtures.js +1 -1
  23. data/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.test.js +97 -10
  24. data/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetailsSelectors.test.js +81 -0
  25. data/webpack/ForemanTasks/Components/TaskDetails/index.js +6 -4
  26. data/webpack/ForemanTasks/Components/TasksDashboard/__tests__/TasksDashboard.test.js +94 -7
  27. data/webpack/ForemanTasks/Components/TasksDashboard/__tests__/TasksDashboardActions.test.js +97 -16
  28. data/webpack/ForemanTasks/Components/TasksDashboard/__tests__/TasksDashboardReducer.test.js +112 -46
  29. data/webpack/ForemanTasks/Components/TasksDashboard/__tests__/TasksDashboardSelectors.test.js +103 -15
  30. data/webpack/ForemanTasks/Components/TasksTable/TasksTableConstants.js +1 -1
  31. data/webpack/ForemanTasks/Components/common/taskResultIcon.js +53 -0
  32. data/webpack/ForemanTasks/Routes/ShowTaskDetails/TaskDetailsPage.js +74 -0
  33. data/webpack/ForemanTasks/Routes/ShowTaskDetails/__tests__/TaskDetailsPage.test.js +265 -0
  34. data/webpack/Routes/routes.js +6 -0
  35. data/webpack/Routes/routes.test.js +16 -5
  36. metadata +10 -7
  37. data/app/views/foreman_tasks/tasks/show.html.erb +0 -18
  38. data/webpack/ForemanTasks/Components/TaskActions/__snapshots__/TaskAction.test.js.snap +0 -233
  39. data/webpack/ForemanTasks/Components/TasksDashboard/__tests__/__snapshots__/TasksDashboard.test.js.snap +0 -51
  40. data/webpack/ForemanTasks/Components/TasksDashboard/__tests__/__snapshots__/TasksDashboardActions.test.js.snap +0 -151
  41. data/webpack/ForemanTasks/Components/TasksDashboard/__tests__/__snapshots__/TasksDashboardReducer.test.js.snap +0 -275
  42. data/webpack/ForemanTasks/Components/TasksDashboard/__tests__/__snapshots__/TasksDashboardSelectors.test.js.snap +0 -119
@@ -1,69 +1,238 @@
1
- import React from 'react';
1
+ import React, { useState, useEffect } from 'react';
2
2
  import PropTypes from 'prop-types';
3
- import { Alert } from '@patternfly/react-core';
3
+ import classNames from 'classnames';
4
+ import {
5
+ Alert,
6
+ AlertVariant,
7
+ CodeBlock,
8
+ CodeBlockCode,
9
+ EmptyState,
10
+ EmptyStateBody,
11
+ EmptyStateHeader,
12
+ Icon,
13
+ EmptyStateVariant,
14
+ Flex,
15
+ FlexItem,
16
+ Grid,
17
+ GridItem,
18
+ Split,
19
+ SplitItem,
20
+ Stack,
21
+ StackItem,
22
+ Tab,
23
+ Tabs,
24
+ TabTitleIcon,
25
+ TabTitleText,
26
+ } from '@patternfly/react-core';
27
+ import {
28
+ CheckCircleIcon,
29
+ ExclamationCircleIcon,
30
+ } from '@patternfly/react-icons';
4
31
  import { translate as __ } from 'foremanReact/common/I18n';
5
32
 
6
- const Errors = ({ ...props }) => {
7
- const { failedSteps, executionPlan } = props;
8
- if (!executionPlan)
33
+ import './Errors.scss';
34
+
35
+ const isStoppedStep = step =>
36
+ ['skipped', 'skipping'].includes(String(step.state ?? ''));
37
+
38
+ const STEP_SUMMARY_MAX_LENGTH = 120;
39
+
40
+ const getStepSummary = step =>
41
+ step.error?.message || step.action_class || __('Unknown error');
42
+
43
+ const truncateStepSummary = summary => {
44
+ if (summary.length <= STEP_SUMMARY_MAX_LENGTH) {
45
+ return summary;
46
+ }
47
+
48
+ return `${summary.slice(0, STEP_SUMMARY_MAX_LENGTH - 3)}...`;
49
+ };
50
+
51
+ const getStepStatus = step => (isStoppedStep(step) ? 'warning' : 'danger');
52
+
53
+ const ErrorTabTitle = ({ step }) => {
54
+ const status = getStepStatus(step);
55
+
56
+ return (
57
+ <>
58
+ <TabTitleIcon>
59
+ <Icon status={status}>
60
+ <ExclamationCircleIcon />
61
+ </Icon>
62
+ </TabTitleIcon>
63
+ <TabTitleText
64
+ className={classNames(
65
+ 'task-errors-tab-title',
66
+ `task-errors-tab-title--${status}`
67
+ )}
68
+ >
69
+ {truncateStepSummary(getStepSummary(step))}
70
+ </TabTitleText>
71
+ </>
72
+ );
73
+ };
74
+
75
+ ErrorTabTitle.propTypes = {
76
+ step: PropTypes.shape({
77
+ action_class: PropTypes.string,
78
+ state: PropTypes.string,
79
+ error: PropTypes.shape({
80
+ message: PropTypes.string,
81
+ }),
82
+ }).isRequired,
83
+ };
84
+
85
+ const ErrorDetailSection = ({ label, children }) => (
86
+ <StackItem className="task-errors-detail-section">
87
+ <Flex
88
+ direction={{ default: 'column' }}
89
+ spaceItems={{ default: 'spaceItemsXs' }}
90
+ >
91
+ <FlexItem className="task-errors-detail-label">
92
+ <strong>{label}</strong>
93
+ </FlexItem>
94
+ <FlexItem>
95
+ <CodeBlock className="task-errors-codeblock">
96
+ <CodeBlockCode className="task-errors-codeblock-code">
97
+ {children}
98
+ </CodeBlockCode>
99
+ </CodeBlock>
100
+ </FlexItem>
101
+ </Flex>
102
+ </StackItem>
103
+ );
104
+
105
+ ErrorDetailSection.propTypes = {
106
+ label: PropTypes.node.isRequired,
107
+ children: PropTypes.node.isRequired,
108
+ };
109
+
110
+ const ErrorDetailsPane = ({ step }) => {
111
+ if (!step) {
112
+ return null;
113
+ }
114
+
115
+ return (
116
+ <Stack>
117
+ {step.error && (
118
+ <>
119
+ <ErrorDetailSection label={`${__('Exception')}:`}>
120
+ {step.error.exception_class}: {step.error.message}
121
+ </ErrorDetailSection>
122
+ <ErrorDetailSection label={__('Backtrace')}>
123
+ {(step.error.backtrace || []).join('\n')}
124
+ </ErrorDetailSection>
125
+ </>
126
+ )}
127
+ <ErrorDetailSection label={__('Input')}>{step.input}</ErrorDetailSection>
128
+ <ErrorDetailSection label={__('Output')}>
129
+ {step.output}
130
+ </ErrorDetailSection>
131
+ </Stack>
132
+ );
133
+ };
134
+
135
+ ErrorDetailsPane.propTypes = {
136
+ step: PropTypes.shape({
137
+ input: PropTypes.node,
138
+ output: PropTypes.node,
139
+ error: PropTypes.shape({
140
+ exception_class: PropTypes.string,
141
+ message: PropTypes.string,
142
+ backtrace: PropTypes.array,
143
+ }),
144
+ }),
145
+ };
146
+
147
+ ErrorDetailsPane.defaultProps = {
148
+ step: null,
149
+ };
150
+
151
+ const Errors = ({ executionPlan, failedSteps }) => {
152
+ const [selectedIndex, setSelectedIndex] = useState(0);
153
+
154
+ useEffect(() => {
155
+ setSelectedIndex(idx => {
156
+ if (idx >= failedSteps.length) {
157
+ return Math.max(0, failedSteps.length - 1);
158
+ }
159
+
160
+ return idx;
161
+ });
162
+ }, [failedSteps.length]);
163
+
164
+ if (!executionPlan) {
9
165
  return (
10
166
  <Alert
11
- variant="danger"
12
- isInline
167
+ title={__('Execution plan data not available ')}
168
+ variant={AlertVariant.danger}
13
169
  ouiaId="task-errors-plan-missing"
14
- title={__('Execution plan unavailable')}
15
- >
16
- {__('Execution plan data not available ')}
17
- </Alert>
170
+ />
18
171
  );
19
- if (!failedSteps.length)
172
+ }
173
+
174
+ if (!failedSteps.length) {
20
175
  return (
21
- <Alert
22
- variant="success"
23
- isInline
24
- ouiaId="task-errors-none"
25
- title={__('No errors')}
26
- />
176
+ <Grid>
177
+ <GridItem span={12}>
178
+ <Flex
179
+ direction={{ default: 'column' }}
180
+ alignItems={{ default: 'alignItemsCenter' }}
181
+ justifyContent={{ default: 'justifyContentCenter' }}
182
+ fullWidth={{ default: 'fullWidth' }}
183
+ >
184
+ <FlexItem>
185
+ <EmptyState variant={EmptyStateVariant.full}>
186
+ <EmptyStateHeader
187
+ titleText={__('No errors found')}
188
+ headingLevel="h2"
189
+ icon={
190
+ <Icon size="xl" status="success">
191
+ <CheckCircleIcon />
192
+ </Icon>
193
+ }
194
+ />
195
+ <EmptyStateBody>
196
+ {__('The task finished with no errors or warnings.')}
197
+ </EmptyStateBody>
198
+ </EmptyState>
199
+ </FlexItem>
200
+ </Flex>
201
+ </GridItem>
202
+ </Grid>
27
203
  );
204
+ }
205
+
206
+ const selectedStep = failedSteps[selectedIndex];
207
+
28
208
  return (
29
- <div>
30
- {failedSteps.map((step, i) => (
31
- <Alert
32
- variant="danger"
33
- isInline
34
- key={i}
35
- ouiaId={`task-error-${i}`}
36
- title={__('Step error')}
209
+ <Split hasGutter>
210
+ <SplitItem className="task-errors-tabs-split">
211
+ <Tabs
212
+ id="task-errors-tabs"
213
+ ouiaId="task-errors-tabs"
214
+ activeKey={selectedIndex}
215
+ className="task-errors-tabs"
216
+ onSelect={(_event, tabIndex) => setSelectedIndex(Number(tabIndex))}
217
+ isVertical
218
+ isBox
219
+ aria-label={__('Failed task errors')}
37
220
  >
38
- <span>{__('Action')}:</span>
39
- <span>
40
- <pre>{step.action_class}</pre>
41
- </span>
42
- <span>{__('Input')}:</span>
43
- <span>
44
- <pre>{step.input}</pre>
45
- </span>
46
- <span>{__('Output')}:</span>
47
- <span>
48
- <pre>{step.output}</pre>
49
- </span>
50
- {step.error && (
51
- <React.Fragment>
52
- <span>{__('Exception')}:</span>
53
- <span>
54
- <pre>
55
- {step.error.exception_class}: {step.error.message}
56
- </pre>
57
- </span>
58
- <span>{__('Backtrace')}:</span>
59
- <span>
60
- <pre>{(step.error.backtrace || []).join('\n')}</pre>
61
- </span>
62
- </React.Fragment>
63
- )}
64
- </Alert>
65
- ))}
66
- </div>
221
+ {failedSteps.map((step, i) => (
222
+ <Tab
223
+ key={`${step.action_class}-${i}`}
224
+ eventKey={i}
225
+ ouiaId={`task-error-${i}`}
226
+ title={<ErrorTabTitle step={step} />}
227
+ aria-label={getStepSummary(step)}
228
+ />
229
+ ))}
230
+ </Tabs>
231
+ </SplitItem>
232
+ <SplitItem isFilled>
233
+ <ErrorDetailsPane step={selectedStep} />
234
+ </SplitItem>
235
+ </Split>
67
236
  );
68
237
  };
69
238
 
@@ -0,0 +1,41 @@
1
+ .task-details-react {
2
+ .task-errors-detail-section {
3
+ margin-top: 1rem;
4
+ margin-left: 1.5rem;
5
+ }
6
+
7
+ .task-errors-detail-label {
8
+ padding-bottom: 0.25rem;
9
+ }
10
+
11
+ .task-errors-tabs-split {
12
+ flex: 0 0 min(33%, 20rem);
13
+ }
14
+
15
+
16
+ .task-errors-codeblock {
17
+ background-color: transparent;
18
+ }
19
+
20
+ .task-errors-codeblock-code {
21
+ margin: -1rem;
22
+ background-color: transparent;
23
+ }
24
+
25
+ .task-errors-tab-title {
26
+ word-break: break-all;
27
+ font-weight: var(--pf-v5-global--FontWeight--bold);
28
+
29
+ &--danger {
30
+ color: var(--pf-v5-global--danger-color--200);
31
+ }
32
+
33
+ &--warning {
34
+ color: var(--pf-v5-global--warning-color--200);
35
+ }
36
+ }
37
+
38
+ .task-errors-tabs {
39
+ --pf-v5-c-tabs--m-vertical--MaxWidth: 100%;
40
+ }
41
+ }
@@ -5,17 +5,12 @@ import {
5
5
  GridItem,
6
6
  Progress,
7
7
  ProgressVariant,
8
- Icon,
9
8
  } from '@patternfly/react-core';
10
- import {
11
- CheckCircleIcon,
12
- ExclamationCircleIcon,
13
- ExclamationTriangleIcon,
14
- QuestionCircleIcon,
15
- } from '@patternfly/react-icons';
16
9
  import { translate as __ } from 'foremanReact/common/I18n';
17
10
  import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime';
18
11
 
12
+ import { taskResultIconEl } from '../../common/taskResultIcon';
13
+
19
14
  const isDelayed = ({ startAt, startedAt }) => {
20
15
  if (
21
16
  startAt == null ||
@@ -35,41 +30,6 @@ const isDelayed = ({ startAt, startedAt }) => {
35
30
  return a.getTime() !== b.getTime();
36
31
  };
37
32
 
38
- const resultIconEl = (state, result) => {
39
- if (state !== 'stopped')
40
- return (
41
- <Icon>
42
- <QuestionCircleIcon />
43
- </Icon>
44
- );
45
- switch (result) {
46
- case 'success':
47
- return (
48
- <Icon status="success">
49
- <CheckCircleIcon />
50
- </Icon>
51
- );
52
- case 'error':
53
- return (
54
- <Icon status="danger">
55
- <ExclamationCircleIcon />
56
- </Icon>
57
- );
58
- case 'warning':
59
- return (
60
- <Icon status="warning">
61
- <ExclamationTriangleIcon />
62
- </Icon>
63
- );
64
- default:
65
- return (
66
- <Icon>
67
- <QuestionCircleIcon />
68
- </Icon>
69
- );
70
- }
71
- };
72
-
73
33
  const progressVariantForResult = result => {
74
34
  switch (result) {
75
35
  case 'error':
@@ -117,7 +77,7 @@ const TaskInfo = props => {
117
77
  title: 'Result',
118
78
  value: (
119
79
  <span>
120
- {resultIconEl(state, result)} {result}
80
+ {taskResultIconEl(state, result)} {result}
121
81
  </span>
122
82
  ),
123
83
  },
@@ -182,14 +142,9 @@ const TaskInfo = props => {
182
142
  </React.Fragment>
183
143
  ))}
184
144
  <GridItem span={12} className="pf-v5-u-pb-lg" />
185
- <GridItem span={6}>
186
- <div className="progress-description">
187
- <span className="list-group-item-heading">{__('State')}: </span>
188
- {state}
189
- </div>
190
- </GridItem>
191
- <GridItem span={6} className="progress-label-top-right">
192
- <span>{`${progress}% ${__('Complete')}`}</span>
145
+ <GridItem span={12}>
146
+ <span className="list-group-item-heading">{__('State')}: </span>
147
+ {state}
193
148
  </GridItem>
194
149
  <GridItem span={12}>
195
150
  <Progress
@@ -27,12 +27,7 @@ export const TaskSkeleton = () => {
27
27
  </React.Fragment>
28
28
  ))}
29
29
  <GridItem span={12} className="pf-v5-u-pb-lg" />
30
- <GridItem span={6}>
31
- <div className="progress-description">
32
- <Skeleton />
33
- </div>
34
- </GridItem>
35
- <GridItem span={6} className="progress-label-top-right">
30
+ <GridItem span={12}>
36
31
  <Skeleton />
37
32
  </GridItem>
38
33
  <GridItem span={12}>
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { render, screen } from '@testing-library/react';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
3
  import '@testing-library/jest-dom';
4
4
 
5
5
  import Errors from '../Errors';
@@ -20,6 +20,8 @@ const failedStepFixture = {
20
20
  output: '{}\n',
21
21
  };
22
22
 
23
+ const executionPlan = { state: 'paused', cancellable: false };
24
+
23
25
  describe('Errors', () => {
24
26
  it('renders warning when execution plan is missing', () => {
25
27
  render(<Errors failedSteps={[]} executionPlan={null} />);
@@ -30,30 +32,176 @@ describe('Errors', () => {
30
32
 
31
33
  it('renders success state when there are no failed steps', () => {
32
34
  render(<Errors failedSteps={[]} executionPlan={{ state: 'paused' }} />);
33
- const noErrors = screen.getAllByText(/^no errors$/i);
34
- expect(noErrors.length).toBeGreaterThanOrEqual(1);
35
+ expect(
36
+ screen.getByRole('heading', { level: 2, name: /no errors found/i })
37
+ ).toBeInTheDocument();
38
+ expect(
39
+ screen.getByText(/the task finished with no errors or warnings/i)
40
+ ).toBeInTheDocument();
35
41
  });
36
42
 
37
43
  it('renders failed step details when failedSteps is non-empty', () => {
38
44
  const { container } = render(
39
45
  <Errors
40
- executionPlan={{ state: 'paused', cancellable: false }}
46
+ executionPlan={executionPlan}
41
47
  failedSteps={[failedStepFixture]}
42
48
  />
43
49
  );
44
- const stepAlert = container.querySelector(
50
+ const errorTab = container.querySelector(
45
51
  '[data-ouia-component-id="task-error-0"]'
46
52
  );
47
- expect(stepAlert).toBeInTheDocument();
48
- expect(stepAlert).toHaveClass('pf-m-inline');
53
+ expect(errorTab).toBeInTheDocument();
54
+ expect(
55
+ screen.getByRole('tab', {
56
+ name: /action actions::katello::eventqueue::monitor is already active/i,
57
+ })
58
+ ).toBeInTheDocument();
49
59
  expect(
50
- screen.getByText('Actions::Katello::EventQueue::Monitor')
60
+ screen.getByLabelText(/failed task errors/i)
51
61
  ).toBeInTheDocument();
62
+ expect(screen.getByText('Input')).toBeInTheDocument();
63
+ expect(screen.getByText('Output')).toBeInTheDocument();
64
+ expect(screen.getByText('Exception:')).toBeInTheDocument();
65
+ expect(screen.getByText('Backtrace')).toBeInTheDocument();
52
66
  expect(screen.getByText(/runtimeerror/i)).toBeInTheDocument();
67
+ expect(screen.getByText(/singleton_lock/i)).toBeInTheDocument();
68
+ });
69
+
70
+ it('switches detail pane when a different error tab is clicked', () => {
71
+ const firstStep = {
72
+ ...failedStepFixture,
73
+ action_class: 'Action::First',
74
+ input: 'INPUT_FROM_FIRST_STEP',
75
+ output: '{}',
76
+ };
77
+ const secondStep = {
78
+ ...failedStepFixture,
79
+ action_class: 'Action::Second',
80
+ error: {
81
+ exception_class: 'StandardError',
82
+ message: 'second step failure',
83
+ backtrace: [],
84
+ },
85
+ input: 'INPUT_FROM_SECOND_STEP',
86
+ output: 'OUTPUT_SECOND',
87
+ state: 'error',
88
+ };
89
+
90
+ render(
91
+ <Errors
92
+ executionPlan={executionPlan}
93
+ failedSteps={[firstStep, secondStep]}
94
+ />
95
+ );
96
+
97
+ expect(screen.getByText('INPUT_FROM_FIRST_STEP')).toBeInTheDocument();
98
+ expect(screen.queryByText('INPUT_FROM_SECOND_STEP')).not.toBeInTheDocument();
99
+
100
+ const tabs = screen.getAllByRole('tab');
101
+ expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
102
+ expect(tabs[1]).toHaveAttribute('aria-selected', 'false');
103
+
104
+ fireEvent.click(tabs[1]);
105
+
106
+ expect(screen.getByText('INPUT_FROM_SECOND_STEP')).toBeInTheDocument();
107
+ expect(screen.queryByText('INPUT_FROM_FIRST_STEP')).not.toBeInTheDocument();
108
+ expect(tabs[0]).toHaveAttribute('aria-selected', 'false');
109
+ expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
110
+ expect(screen.getByText('OUTPUT_SECOND')).toBeInTheDocument();
111
+ });
112
+
113
+ it('clamps selection when failedSteps shrinks', () => {
114
+ const firstStep = {
115
+ ...failedStepFixture,
116
+ action_class: 'Action::First',
117
+ input: 'AFTER_CLAMP',
118
+ output: '{}',
119
+ };
120
+ const secondStep = {
121
+ ...failedStepFixture,
122
+ action_class: 'Action::Second',
123
+ input: 'REMOVED',
124
+ output: '{}',
125
+ state: 'error',
126
+ };
127
+
128
+ const { rerender } = render(
129
+ <Errors
130
+ executionPlan={executionPlan}
131
+ failedSteps={[firstStep, secondStep]}
132
+ />
133
+ );
134
+
135
+ fireEvent.click(screen.getAllByRole('tab')[1]);
136
+ expect(screen.getByText('REMOVED')).toBeInTheDocument();
137
+
138
+ rerender(
139
+ <Errors executionPlan={executionPlan} failedSteps={[firstStep]} />
140
+ );
141
+
142
+ expect(screen.getAllByRole('tab')).toHaveLength(1);
143
+ expect(screen.getByText('AFTER_CLAMP')).toBeInTheDocument();
144
+ });
145
+
146
+ it('truncates long tab titles to 120 characters with ellipsis', () => {
147
+ const longMessage = 'x'.repeat(150);
148
+ const longStep = {
149
+ ...failedStepFixture,
150
+ error: {
151
+ ...failedStepFixture.error,
152
+ message: longMessage,
153
+ },
154
+ };
155
+
156
+ const { container } = render(
157
+ <Errors executionPlan={executionPlan} failedSteps={[longStep]} />
158
+ );
159
+
160
+ const tabTitle = container.querySelector('.task-errors-tab-title');
161
+ expect(tabTitle.textContent).toHaveLength(120);
162
+ expect(tabTitle.textContent.endsWith('...')).toBe(true);
163
+ expect(
164
+ screen.getByRole('tab', { name: longMessage })
165
+ ).toBeInTheDocument();
166
+ });
167
+
168
+ it('uses warning styling for skipped steps', () => {
169
+ const skippedStep = {
170
+ action_class: 'Actions::Example',
171
+ state: 'skipped',
172
+ input: '{}',
173
+ output: '{}',
174
+ };
175
+
176
+ const { container } = render(
177
+ <Errors executionPlan={executionPlan} failedSteps={[skippedStep]} />
178
+ );
179
+
53
180
  expect(
54
- screen.getByText(
55
- /action actions::katello::eventqueue::monitor is already active/i
56
- )
181
+ screen.getByRole('tab', { name: /actions::example/i })
57
182
  ).toBeInTheDocument();
183
+
184
+ const errorTab = container.querySelector(
185
+ '[data-ouia-component-id="task-error-0"]'
186
+ );
187
+ expect(errorTab.querySelector('.pf-m-warning')).toBeInTheDocument();
188
+ });
189
+
190
+ it('omits exception and backtrace when step has no error object', () => {
191
+ const stepWithoutError = {
192
+ action_class: 'Actions::NoError',
193
+ state: 'error',
194
+ input: 'plain-input',
195
+ output: 'plain-output',
196
+ };
197
+
198
+ render(
199
+ <Errors executionPlan={executionPlan} failedSteps={[stepWithoutError]} />
200
+ );
201
+
202
+ expect(screen.getByText('plain-input')).toBeInTheDocument();
203
+ expect(screen.getByText('plain-output')).toBeInTheDocument();
204
+ expect(screen.queryByText('Exception:')).not.toBeInTheDocument();
205
+ expect(screen.queryByText('Backtrace')).not.toBeInTheDocument();
58
206
  });
59
207
  });
@@ -1,7 +1,6 @@
1
1
  import React from 'react';
2
2
  import { render, screen } from '@testing-library/react';
3
3
  import '@testing-library/jest-dom';
4
- import { STATUS } from 'foremanReact/constants';
5
4
 
6
5
  import Task from '../Task';
7
6
 
@@ -29,7 +28,6 @@ describe('Task', () => {
29
28
  parentTask="parent-id"
30
29
  taskReload
31
30
  canEdit
32
- status={STATUS.RESOLVED}
33
31
  taskProgressToggle={jest.fn()}
34
32
  taskReloadStart={jest.fn()}
35
33
  />
@@ -1,7 +1,6 @@
1
1
  import React from 'react';
2
2
  import { render, screen, fireEvent } from '@testing-library/react';
3
3
  import '@testing-library/jest-dom';
4
- import { STATUS } from 'foremanReact/constants';
5
4
  import { TaskButtons } from '../TaskButtons';
6
5
 
7
6
  const setUnlockModalOpen = jest.fn();
@@ -169,7 +168,6 @@ describe('TaskButtons', () => {
169
168
  resumeTaskRequest,
170
169
  taskProgressToggle,
171
170
  taskReloadStart,
172
- status: STATUS.RESOLVED,
173
171
  canEdit: true,
174
172
  resumable: true,
175
173
  cancellable: true,