foreman_openbolt 1.0.0 → 1.1.1

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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +190 -19
  3. data/Rakefile +17 -93
  4. data/app/controllers/foreman_openbolt/task_controller.rb +61 -49
  5. data/app/lib/actions/foreman_openbolt/cleanup_proxy_artifacts.rb +11 -10
  6. data/app/lib/actions/foreman_openbolt/poll_task_status.rb +70 -60
  7. data/app/models/foreman_openbolt/task_job.rb +16 -17
  8. data/config/routes.rb +0 -1
  9. data/lib/foreman_openbolt/engine.rb +11 -11
  10. data/lib/foreman_openbolt/version.rb +1 -1
  11. data/lib/proxy_api/openbolt.rb +25 -9
  12. data/lib/tasks/foreman_openbolt_tasks.rake +1 -22
  13. data/locale/gemspec.rb +1 -1
  14. data/package.json +11 -15
  15. data/test/acceptance/acceptance_helper.rb +146 -0
  16. data/test/acceptance/docker/docker-compose.yml +69 -0
  17. data/test/acceptance/docker/foreman/Dockerfile +45 -0
  18. data/test/acceptance/docker/foreman/entrypoint.sh +26 -0
  19. data/test/acceptance/docker/target/Dockerfile +29 -0
  20. data/test/acceptance/docker/target/entrypoint.sh +11 -0
  21. data/test/acceptance/fixtures/modules/acceptance/tasks/complex_params.json +30 -0
  22. data/test/acceptance/fixtures/modules/acceptance/tasks/complex_params.sh +16 -0
  23. data/test/acceptance/fixtures/modules/acceptance/tasks/echo.json +13 -0
  24. data/test/acceptance/fixtures/modules/acceptance/tasks/echo.sh +3 -0
  25. data/test/acceptance/fixtures/modules/acceptance/tasks/failing_task.json +8 -0
  26. data/test/acceptance/fixtures/modules/acceptance/tasks/failing_task.sh +3 -0
  27. data/test/acceptance/fixtures/modules/acceptance/tasks/noop_task.json +8 -0
  28. data/test/acceptance/fixtures/modules/acceptance/tasks/noop_task.sh +2 -0
  29. data/test/acceptance/fixtures/modules/acceptance/tasks/slow_task.json +14 -0
  30. data/test/acceptance/fixtures/modules/acceptance/tasks/slow_task.sh +3 -0
  31. data/test/acceptance/fixtures/modules/acceptance/tasks/target_conditional.json +13 -0
  32. data/test/acceptance/fixtures/modules/acceptance/tasks/target_conditional.sh +9 -0
  33. data/test/acceptance/fixtures/openbolt.yml +7 -0
  34. data/test/acceptance/tests/error_handling_test.rb +40 -0
  35. data/test/acceptance/tests/host_selector_test.rb +31 -0
  36. data/test/acceptance/tests/launch_task_test.rb +96 -0
  37. data/test/acceptance/tests/parameter_table_test.rb +61 -0
  38. data/test/acceptance/tests/settings_test.rb +95 -0
  39. data/test/acceptance/tests/ssh_options_test.rb +77 -0
  40. data/test/acceptance/tests/task_execution_test.rb +40 -0
  41. data/test/acceptance/tests/task_history_test.rb +84 -0
  42. data/test/acceptance/tests/transport_options_test.rb +121 -0
  43. data/test/test_plugin_helper.rb +12 -3
  44. data/test/unit/controllers/task_controller_test.rb +351 -0
  45. data/test/unit/docker/Dockerfile +47 -0
  46. data/test/unit/docker/docker-compose.yml +33 -0
  47. data/test/unit/docker/entrypoint.sh +4 -0
  48. data/test/unit/factories/foreman_openbolt_factories.rb +39 -0
  49. data/test/unit/lib/actions/cleanup_proxy_artifacts_test.rb +51 -0
  50. data/test/unit/lib/actions/poll_task_status_test.rb +141 -0
  51. data/test/unit/lib/proxy_api/openbolt_test.rb +174 -0
  52. data/test/unit/models/task_job_test.rb +278 -0
  53. data/webpack/__mocks__/foremanReact/common/I18n.js +15 -0
  54. data/webpack/__mocks__/foremanReact/components/ToastsList/index.js +6 -0
  55. data/webpack/__mocks__/foremanReact/redux/API/index.js +11 -0
  56. data/webpack/src/Components/LaunchTask/FieldTable.js +8 -5
  57. data/webpack/src/Components/LaunchTask/HostSelector/SearchSelect.js +74 -62
  58. data/webpack/src/Components/LaunchTask/HostSelector/SelectedChips.js +11 -13
  59. data/webpack/src/Components/LaunchTask/HostSelector/index.js +28 -33
  60. data/webpack/src/Components/LaunchTask/OpenBoltOptionsSection.js +3 -2
  61. data/webpack/src/Components/LaunchTask/ParameterField.js +2 -0
  62. data/webpack/src/Components/LaunchTask/SmartProxySelect.js +2 -1
  63. data/webpack/src/Components/LaunchTask/TaskSelect.js +3 -3
  64. data/webpack/src/Components/LaunchTask/__tests__/EmptyContent.test.js +10 -0
  65. data/webpack/src/Components/LaunchTask/__tests__/LaunchTask.test.js +83 -0
  66. data/webpack/src/Components/LaunchTask/__tests__/ParameterField.test.js +86 -0
  67. data/webpack/src/Components/LaunchTask/__tests__/ParametersSection.test.js +50 -0
  68. data/webpack/src/Components/LaunchTask/__tests__/SmartProxySelect.test.js +63 -0
  69. data/webpack/src/Components/LaunchTask/__tests__/TaskSelect.test.js +39 -0
  70. data/webpack/src/Components/LaunchTask/hooks/__tests__/useOpenBoltOptions.test.js +90 -0
  71. data/webpack/src/Components/LaunchTask/hooks/__tests__/useSmartProxies.test.js +69 -0
  72. data/webpack/src/Components/LaunchTask/hooks/__tests__/useTasksData.test.js +103 -0
  73. data/webpack/src/Components/LaunchTask/hooks/useOpenBoltOptions.js +9 -11
  74. data/webpack/src/Components/LaunchTask/hooks/useSmartProxies.js +12 -13
  75. data/webpack/src/Components/LaunchTask/hooks/useTasksData.js +6 -13
  76. data/webpack/src/Components/LaunchTask/index.js +9 -27
  77. data/webpack/src/Components/TaskExecution/ExecutionDetails.js +29 -29
  78. data/webpack/src/Components/TaskExecution/ExecutionDisplay.js +9 -10
  79. data/webpack/src/Components/TaskExecution/LoadingIndicator.js +7 -2
  80. data/webpack/src/Components/TaskExecution/ResultDisplay.js +13 -17
  81. data/webpack/src/Components/TaskExecution/TaskDetails.js +58 -67
  82. data/webpack/src/Components/TaskExecution/__tests__/ExecutionDetails.test.js +47 -0
  83. data/webpack/src/Components/TaskExecution/__tests__/ExecutionDisplay.test.js +29 -0
  84. data/webpack/src/Components/TaskExecution/__tests__/LoadingIndicator.test.js +25 -0
  85. data/webpack/src/Components/TaskExecution/__tests__/ResultDisplay.test.js +28 -0
  86. data/webpack/src/Components/TaskExecution/__tests__/TaskDetails.test.js +38 -0
  87. data/webpack/src/Components/TaskExecution/__tests__/TaskExecution.test.js +80 -0
  88. data/webpack/src/Components/TaskExecution/hooks/__tests__/useJobPolling.test.js +177 -0
  89. data/webpack/src/Components/TaskExecution/hooks/useJobPolling.js +34 -33
  90. data/webpack/src/Components/TaskExecution/index.js +10 -12
  91. data/webpack/src/Components/TaskHistory/TaskPopover.js +9 -12
  92. data/webpack/src/Components/TaskHistory/__tests__/TaskHistory.test.js +109 -0
  93. data/webpack/src/Components/TaskHistory/__tests__/TaskPopover.test.js +26 -0
  94. data/webpack/src/Components/TaskHistory/index.js +21 -29
  95. data/webpack/src/Components/common/HostsPopover.js +12 -3
  96. data/webpack/src/Components/common/__tests__/HostsPopover.test.js +20 -0
  97. data/webpack/src/Components/common/__tests__/helpers.test.js +135 -0
  98. data/webpack/src/Components/common/helpers.js +34 -5
  99. data/webpack/test_setup.js +34 -11
  100. metadata +65 -87
  101. data/test/factories/foreman_openbolt_factories.rb +0 -7
  102. data/test/unit/foreman_openbolt_test.rb +0 -13
  103. data/webpack/global_test_setup.js +0 -11
  104. data/webpack/webpack.config.js +0 -7
@@ -21,7 +21,7 @@ import {
21
21
  ClockIcon,
22
22
  TimesCircleIcon,
23
23
  } from '@patternfly/react-icons';
24
-
24
+ import { formatDuration, formatDate } from '../common/helpers';
25
25
  import { STATUS, POLLING_CONFIG } from '../common/constants';
26
26
  import HostsPopover from '../common/HostsPopover';
27
27
 
@@ -38,12 +38,12 @@ const STATUS_CONFIGS = {
38
38
  },
39
39
  [STATUS.EXCEPTION]: {
40
40
  icon: ExclamationCircleIcon,
41
- color: 'red',
41
+ color: 'orange',
42
42
  label: __('Exception'),
43
43
  },
44
44
  [STATUS.INVALID]: {
45
45
  icon: ExclamationCircleIcon,
46
- color: 'red',
46
+ color: 'yellow',
47
47
  label: __('Invalid'),
48
48
  },
49
49
  [STATUS.RUNNING]: {
@@ -65,24 +65,6 @@ const getStatusConfig = status =>
65
65
  label: __('Unknown'),
66
66
  };
67
67
 
68
- const formatExecutionTime = (submittedAt, completedAt) => {
69
- if (!submittedAt) return __('-');
70
-
71
- const start = new Date(submittedAt);
72
- const end = completedAt ? new Date(completedAt) : new Date();
73
- const totalSeconds = Math.floor((end - start) / 1000);
74
- const hours = Math.floor(totalSeconds / 3600);
75
- const minutes = Math.floor((totalSeconds % 3600) / 60);
76
- const seconds = totalSeconds % 60;
77
-
78
- const parts = [];
79
- if (hours > 0) parts.push(`${hours}h`);
80
- if (minutes > 0 || hours > 0) parts.push(`${minutes}m`);
81
- parts.push(`${seconds}s`);
82
-
83
- return parts.join('');
84
- };
85
-
86
68
  const DescriptionItem = ({ label, children }) => (
87
69
  <DescriptionListGroup>
88
70
  <DescriptionListTerm>{label}</DescriptionListTerm>
@@ -129,8 +111,7 @@ StatusLabel.propTypes = {
129
111
  };
130
112
 
131
113
  const ExecutionDetails = ({
132
- proxyId,
133
- proxyName,
114
+ smartProxy,
134
115
  jobId,
135
116
  jobStatus,
136
117
  isPolling,
@@ -142,8 +123,8 @@ const ExecutionDetails = ({
142
123
  <CardBody>
143
124
  <DescriptionList isHorizontal>
144
125
  <DescriptionItem label={__('Smart Proxy')}>
145
- {proxyId && proxyName ? (
146
- <a href={`/smart_proxies/${proxyId}`}>{proxyName}</a>
126
+ {smartProxy?.id && smartProxy?.name ? (
127
+ <a href={`/smart_proxies/${smartProxy.id}`}>{smartProxy.name}</a>
147
128
  ) : (
148
129
  <em>{__('Unknown')}</em>
149
130
  )}
@@ -161,8 +142,24 @@ const ExecutionDetails = ({
161
142
  <StatusLabel status={jobStatus} isPolling={isPolling} />
162
143
  </DescriptionItem>
163
144
 
164
- <DescriptionItem label={__('Execution Time')}>
165
- {formatExecutionTime(submittedAt, completedAt)}
145
+ <DescriptionItem label={__('Submitted')}>
146
+ {formatDate(submittedAt)}
147
+ </DescriptionItem>
148
+
149
+ {completedAt && (
150
+ <DescriptionItem label={__('Completed')}>
151
+ {formatDate(completedAt)}
152
+ </DescriptionItem>
153
+ )}
154
+
155
+ <DescriptionItem label={__('Duration')}>
156
+ {submittedAt
157
+ ? formatDuration(
158
+ ((completedAt ? new Date(completedAt) : new Date()) -
159
+ new Date(submittedAt)) /
160
+ 1000
161
+ )
162
+ : '-'}
166
163
  </DescriptionItem>
167
164
  </DescriptionList>
168
165
  </CardBody>
@@ -170,8 +167,10 @@ const ExecutionDetails = ({
170
167
  );
171
168
 
172
169
  ExecutionDetails.propTypes = {
173
- proxyId: PropTypes.string.isRequired,
174
- proxyName: PropTypes.string.isRequired,
170
+ smartProxy: PropTypes.shape({
171
+ id: PropTypes.number,
172
+ name: PropTypes.string,
173
+ }),
175
174
  jobId: PropTypes.string.isRequired,
176
175
  jobStatus: PropTypes.string.isRequired,
177
176
  isPolling: PropTypes.bool.isRequired,
@@ -181,6 +180,7 @@ ExecutionDetails.propTypes = {
181
180
  };
182
181
 
183
182
  ExecutionDetails.defaultProps = {
183
+ smartProxy: null,
184
184
  submittedAt: null,
185
185
  completedAt: null,
186
186
  };
@@ -9,11 +9,9 @@ import ExecutionDetails from './ExecutionDetails';
9
9
  import TaskDetails from './TaskDetails';
10
10
 
11
11
  const ExecutionDisplay = ({
12
- proxyId,
13
- proxyName,
12
+ smartProxy,
14
13
  jobId,
15
14
  jobStatus,
16
- pollCount,
17
15
  isPolling,
18
16
  targets,
19
17
  submittedAt,
@@ -41,11 +39,9 @@ const ExecutionDisplay = ({
41
39
  }
42
40
  >
43
41
  <ExecutionDetails
44
- proxyId={proxyId}
45
- proxyName={proxyName}
42
+ smartProxy={smartProxy}
46
43
  jobId={jobId}
47
44
  jobStatus={jobStatus}
48
- pollCount={pollCount}
49
45
  isPolling={isPolling}
50
46
  targets={targets}
51
47
  submittedAt={submittedAt}
@@ -75,23 +71,26 @@ const ExecutionDisplay = ({
75
71
  };
76
72
 
77
73
  ExecutionDisplay.propTypes = {
78
- proxyId: PropTypes.string.isRequired,
79
- proxyName: PropTypes.string.isRequired,
74
+ smartProxy: PropTypes.shape({
75
+ id: PropTypes.number,
76
+ name: PropTypes.string,
77
+ }),
80
78
  jobId: PropTypes.string.isRequired,
81
79
  jobStatus: PropTypes.string.isRequired,
82
- pollCount: PropTypes.number.isRequired,
83
80
  isPolling: PropTypes.bool.isRequired,
84
81
  targets: PropTypes.arrayOf(PropTypes.string).isRequired,
85
82
  submittedAt: PropTypes.string,
86
83
  completedAt: PropTypes.string,
87
- taskName: PropTypes.string.isRequired,
84
+ taskName: PropTypes.string,
88
85
  taskDescription: PropTypes.string,
89
86
  taskParameters: PropTypes.object,
90
87
  };
91
88
 
92
89
  ExecutionDisplay.defaultProps = {
90
+ smartProxy: null,
93
91
  submittedAt: null,
94
92
  completedAt: null,
93
+ taskName: null,
95
94
  taskDescription: null,
96
95
  taskParameters: {},
97
96
  };
@@ -26,13 +26,18 @@ const LoadingIndicator = ({ jobStatus }) => {
26
26
  <Card>
27
27
  <CardBody>
28
28
  <Bullseye>
29
- <EmptyState variant={EmptyStateVariant.lg}>
29
+ <EmptyState
30
+ variant={EmptyStateVariant.lg}
31
+ role="status"
32
+ aria-live="polite"
33
+ aria-atomic="true"
34
+ >
30
35
  <EmptyStateHeader
31
36
  titleText={getMessage()}
32
37
  icon={<EmptyStateIcon icon={Spinner} />}
33
38
  headingLevel="h3"
34
39
  />
35
- <EmptyStateBody role="status" aria-live="polite" aria-atomic="true">
40
+ <EmptyStateBody>
36
41
  {__(
37
42
  'This page will update automatically when the task completes.'
38
43
  )}
@@ -27,18 +27,21 @@ import {
27
27
  const CopyButton = ({ getText }) => {
28
28
  const [copied, setCopied] = useState(false);
29
29
 
30
- const handleCopy = () => {
31
- /* eslint-disable promise/prefer-await-to-then */
32
- navigator.clipboard.writeText(getText()).then(() => {
30
+ const handleCopy = async () => {
31
+ try {
32
+ await navigator.clipboard.writeText(getText());
33
33
  setCopied(true);
34
34
  setTimeout(() => setCopied(false), 2000);
35
- });
35
+ } catch (err) {
36
+ // eslint-disable-next-line no-console
37
+ console.warn('Clipboard copy failed:', err);
38
+ }
36
39
  };
37
40
 
38
41
  return (
39
42
  <Button
40
43
  variant="control"
41
- aria-label="Copy"
44
+ aria-label={copied ? __('Copied!') : __('Copy')}
42
45
  onClick={handleCopy}
43
46
  icon={<CopyIcon />}
44
47
  >
@@ -103,22 +106,15 @@ const ResultDisplay = ({ jobResult, jobLog }) => {
103
106
  <Card>
104
107
  <CardBody>
105
108
  <div style={{ position: 'relative' }}>
106
- <div
107
- style={{
108
- position: 'absolute',
109
- right: 0,
110
- zIndex: 1,
111
- }}
112
- >
113
- {((activeTabKey === 0 && hasResult) ||
114
- (activeTabKey === 1 && hasLog)) && (
109
+ {((activeTabKey === 0 && hasResult) ||
110
+ (activeTabKey === 1 && hasLog)) && (
111
+ <div style={{ position: 'absolute', right: 0, zIndex: 1 }}>
115
112
  <CopyButton getText={getActiveContent} />
116
- )}
117
- </div>
113
+ </div>
114
+ )}
118
115
  <Tabs
119
116
  activeKey={activeTabKey}
120
117
  onSelect={(_event, tabIndex) => setActiveTabKey(tabIndex)}
121
- actions={<CopyButton getText={getActiveContent} />}
122
118
  >
123
119
  <Tab
124
120
  eventKey={0}
@@ -10,88 +10,79 @@ import {
10
10
  DescriptionListTerm,
11
11
  } from '@patternfly/react-core';
12
12
  import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table';
13
+ import { displayValue } from '../common/helpers';
13
14
 
14
- const TaskDetails = ({ taskName, taskDescription, taskParameters }) => {
15
- const displayValue = value => {
16
- if (value === null || value === undefined) {
17
- return '-';
18
- }
19
- if (typeof value === 'object') {
20
- return JSON.stringify(value);
21
- }
22
- return String(value);
23
- };
15
+ const TaskDetails = ({ taskName, taskDescription, taskParameters }) => (
16
+ <Card>
17
+ <CardBody>
18
+ <DescriptionList isHorizontal>
19
+ <DescriptionListGroup>
20
+ <DescriptionListTerm>{__('Task Name')}</DescriptionListTerm>
21
+ <DescriptionListDescription>
22
+ <span className="pf-v5-u-font-family-monospace">{taskName}</span>
23
+ </DescriptionListDescription>
24
+ </DescriptionListGroup>
24
25
 
25
- return (
26
- <Card>
27
- <CardBody>
28
- <DescriptionList isHorizontal>
26
+ {taskDescription && (
29
27
  <DescriptionListGroup>
30
- <DescriptionListTerm>{__('Task Name')}</DescriptionListTerm>
28
+ <DescriptionListTerm>{__('Description')}</DescriptionListTerm>
31
29
  <DescriptionListDescription>
32
- <span className="pf-v5-u-font-family-monospace">{taskName}</span>
30
+ {taskDescription}
33
31
  </DescriptionListDescription>
34
32
  </DescriptionListGroup>
33
+ )}
35
34
 
36
- {taskDescription && (
37
- <DescriptionListGroup>
38
- <DescriptionListTerm>{__('Description')}</DescriptionListTerm>
39
- <DescriptionListDescription>
40
- {taskDescription}
41
- </DescriptionListDescription>
42
- </DescriptionListGroup>
43
- )}
44
-
45
- {taskParameters && Object.keys(taskParameters).length > 0 && (
46
- <DescriptionListGroup>
47
- <DescriptionListTerm>{__('Parameters')}</DescriptionListTerm>
48
- <DescriptionListDescription>
49
- <Table
50
- variant="compact"
51
- borders
52
- isStriped
53
- gridBreakPoint="grid-md"
54
- style={{ wordBreak: 'break-word' }}
55
- >
56
- <Thead>
57
- <Tr>
58
- <Th width={30}>{__('Name')}</Th>
59
- <Th width={70}>{__('Value')}</Th>
35
+ {taskParameters && Object.keys(taskParameters).length > 0 && (
36
+ <DescriptionListGroup>
37
+ <DescriptionListTerm>{__('Parameters')}</DescriptionListTerm>
38
+ <DescriptionListDescription>
39
+ <Table
40
+ variant="compact"
41
+ borders
42
+ isStriped
43
+ gridBreakPoint="grid-md"
44
+ style={{ wordBreak: 'break-word' }}
45
+ aria-label={__('Task parameters')}
46
+ >
47
+ <Thead>
48
+ <Tr>
49
+ <Th width={30}>{__('Name')}</Th>
50
+ <Th width={70}>{__('Value')}</Th>
51
+ </Tr>
52
+ </Thead>
53
+ <Tbody>
54
+ {Object.entries(taskParameters).map(([key, value]) => (
55
+ <Tr key={key}>
56
+ <Td>
57
+ <span className="pf-v5-u-font-family-monospace">
58
+ {key}
59
+ </span>
60
+ </Td>
61
+ <Td>
62
+ <span className="pf-v5-u-font-family-monospace">
63
+ {displayValue(value)}
64
+ </span>
65
+ </Td>
60
66
  </Tr>
61
- </Thead>
62
- <Tbody>
63
- {Object.entries(taskParameters).map(([key, value]) => (
64
- <Tr key={key}>
65
- <Td>
66
- <span className="pf-v5-u-font-family-monospace">
67
- {key}
68
- </span>
69
- </Td>
70
- <Td>
71
- <span className="pf-v5-u-font-family-monospace">
72
- {displayValue(value)}
73
- </span>
74
- </Td>
75
- </Tr>
76
- ))}
77
- </Tbody>
78
- </Table>
79
- </DescriptionListDescription>
80
- </DescriptionListGroup>
81
- )}
82
- </DescriptionList>
83
- </CardBody>
84
- </Card>
85
- );
86
- };
67
+ ))}
68
+ </Tbody>
69
+ </Table>
70
+ </DescriptionListDescription>
71
+ </DescriptionListGroup>
72
+ )}
73
+ </DescriptionList>
74
+ </CardBody>
75
+ </Card>
76
+ );
87
77
 
88
78
  TaskDetails.propTypes = {
89
- taskName: PropTypes.string.isRequired,
79
+ taskName: PropTypes.string,
90
80
  taskDescription: PropTypes.string,
91
81
  taskParameters: PropTypes.object,
92
82
  };
93
83
 
94
84
  TaskDetails.defaultProps = {
85
+ taskName: null,
95
86
  taskDescription: null,
96
87
  taskParameters: {},
97
88
  };
@@ -0,0 +1,47 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import ExecutionDetails from '../ExecutionDetails';
4
+
5
+ const defaultProps = {
6
+ smartProxy: { id: 1, name: 'test-proxy' },
7
+ jobId: 'job-abc-123',
8
+ jobStatus: 'running',
9
+ isPolling: true,
10
+ targets: ['host1.example.com', 'host2.example.com'],
11
+ submittedAt: '2026-01-15T10:30:00Z',
12
+ completedAt: null,
13
+ };
14
+
15
+ describe('ExecutionDetails', () => {
16
+ test('renders job ID', () => {
17
+ render(<ExecutionDetails {...defaultProps} />);
18
+ expect(screen.getByText('job-abc-123')).toBeInTheDocument();
19
+ });
20
+
21
+ test('renders smart proxy name as link', () => {
22
+ render(<ExecutionDetails {...defaultProps} />);
23
+ const link = screen.getByText('test-proxy');
24
+ expect(link).toBeInTheDocument();
25
+ expect(link.closest('a')).toHaveAttribute('href', '/smart_proxies/1');
26
+ });
27
+
28
+ test('renders unknown proxy when smartProxy is null', () => {
29
+ render(<ExecutionDetails {...defaultProps} smartProxy={null} />);
30
+ expect(screen.getByText('Unknown')).toBeInTheDocument();
31
+ });
32
+
33
+ test('renders status label', () => {
34
+ render(<ExecutionDetails {...defaultProps} jobStatus="success" />);
35
+ expect(screen.getByText('Success')).toBeInTheDocument();
36
+ });
37
+
38
+ test('shows polling indicator when polling', () => {
39
+ render(<ExecutionDetails {...defaultProps} isPolling />);
40
+ expect(screen.getByText(/Updating every 5 seconds/)).toBeInTheDocument();
41
+ });
42
+
43
+ test('renders target count via HostsPopover', () => {
44
+ render(<ExecutionDetails {...defaultProps} />);
45
+ expect(screen.getByText('2')).toBeInTheDocument();
46
+ });
47
+ });
@@ -0,0 +1,29 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import ExecutionDisplay from '../ExecutionDisplay';
4
+
5
+ const defaultProps = {
6
+ smartProxy: { id: 1, name: 'proxy1' },
7
+ jobId: 'job-123',
8
+ jobStatus: 'running',
9
+ isPolling: true,
10
+ targets: ['host1.example.com'],
11
+ submittedAt: '2026-01-15T10:30:00Z',
12
+ completedAt: null,
13
+ taskName: 'test::task',
14
+ taskDescription: 'A test task',
15
+ taskParameters: { name: 'nginx' },
16
+ };
17
+
18
+ describe('ExecutionDisplay', () => {
19
+ test('renders both tab titles', () => {
20
+ render(<ExecutionDisplay {...defaultProps} />);
21
+ expect(screen.getByText('Execution Details')).toBeInTheDocument();
22
+ expect(screen.getByText('Task Details')).toBeInTheDocument();
23
+ });
24
+
25
+ test('renders execution details content by default', () => {
26
+ render(<ExecutionDisplay {...defaultProps} />);
27
+ expect(screen.getByText('job-123')).toBeInTheDocument();
28
+ });
29
+ });
@@ -0,0 +1,25 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import LoadingIndicator from '../LoadingIndicator';
4
+
5
+ describe('LoadingIndicator', () => {
6
+ test('shows running status message for running jobs', () => {
7
+ render(<LoadingIndicator jobStatus="running" />);
8
+ expect(screen.getByText(/running/i)).toBeInTheDocument();
9
+ });
10
+
11
+ test('shows running status message for pending jobs', () => {
12
+ render(<LoadingIndicator jobStatus="pending" />);
13
+ expect(screen.getByText(/pending/i)).toBeInTheDocument();
14
+ });
15
+
16
+ test('shows processing message for non-running statuses', () => {
17
+ render(<LoadingIndicator jobStatus="success" />);
18
+ expect(screen.getByText('Processing task results...')).toBeInTheDocument();
19
+ });
20
+
21
+ test('renders with status role for accessibility', () => {
22
+ render(<LoadingIndicator jobStatus="running" />);
23
+ expect(screen.getByRole('status')).toBeInTheDocument();
24
+ });
25
+ });
@@ -0,0 +1,28 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import ResultDisplay from '../ResultDisplay';
4
+
5
+ describe('ResultDisplay', () => {
6
+ test('renders result JSON in the default tab', () => {
7
+ const jobResult = { items: [{ status: 'success' }] };
8
+ render(<ResultDisplay jobResult={jobResult} jobLog="" />);
9
+ expect(screen.getByText('Result')).toBeInTheDocument();
10
+ expect(screen.getByText(/items/)).toBeInTheDocument();
11
+ expect(screen.getByText(/success/)).toBeInTheDocument();
12
+ });
13
+
14
+ test('renders log content when log tab is selected', () => {
15
+ render(
16
+ <ResultDisplay jobResult={{}} jobLog="Task finished successfully" />
17
+ );
18
+ fireEvent.click(screen.getByText('Log Output'));
19
+ expect(screen.getByText('Task finished successfully')).toBeInTheDocument();
20
+ });
21
+
22
+ test('shows empty state when result is empty', () => {
23
+ render(<ResultDisplay jobResult={{}} jobLog="" />);
24
+ expect(
25
+ screen.getByText('No result data returned from the task.')
26
+ ).toBeInTheDocument();
27
+ });
28
+ });
@@ -0,0 +1,38 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import TaskDetails from '../TaskDetails';
4
+
5
+ describe('TaskDetails', () => {
6
+ test('renders task name', () => {
7
+ render(<TaskDetails taskName="mymod::install" />);
8
+ expect(screen.getByText('mymod::install')).toBeInTheDocument();
9
+ });
10
+
11
+ test('renders description when provided', () => {
12
+ render(
13
+ <TaskDetails
14
+ taskName="mymod::install"
15
+ taskDescription="Install a system package"
16
+ />
17
+ );
18
+ expect(screen.getByText('Install a system package')).toBeInTheDocument();
19
+ });
20
+
21
+ test('does not render description section when null', () => {
22
+ render(<TaskDetails taskName="test::task" taskDescription={null} />);
23
+ expect(screen.queryByText('Description')).not.toBeInTheDocument();
24
+ });
25
+
26
+ test('renders parameters table when parameters exist', () => {
27
+ render(
28
+ <TaskDetails
29
+ taskName="test::task"
30
+ taskParameters={{ name: 'nginx', version: '1.0' }}
31
+ />
32
+ );
33
+ expect(screen.getByText('name')).toBeInTheDocument();
34
+ expect(screen.getByText('nginx')).toBeInTheDocument();
35
+ expect(screen.getByText('version')).toBeInTheDocument();
36
+ expect(screen.getByText('1.0')).toBeInTheDocument();
37
+ });
38
+ });
@@ -0,0 +1,80 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { Provider } from 'react-redux';
4
+ import { createStore } from 'redux';
5
+ import { MemoryRouter } from 'react-router-dom';
6
+ import { API } from 'foremanReact/redux/API';
7
+ import TaskExecution from '../index';
8
+
9
+ const mockStore = createStore(() => ({}));
10
+
11
+ const renderWithProviders = (jobId = 'test-job-123') => {
12
+ const initialEntry = jobId
13
+ ? `/foreman_openbolt/page_task_execution?job_id=${jobId}`
14
+ : '/foreman_openbolt/page_task_execution';
15
+
16
+ return render(
17
+ <Provider store={mockStore}>
18
+ <MemoryRouter initialEntries={[initialEntry]}>
19
+ <TaskExecution />
20
+ </MemoryRouter>
21
+ </Provider>
22
+ );
23
+ };
24
+
25
+ afterEach(() => {
26
+ jest.clearAllMocks();
27
+ });
28
+
29
+ describe('TaskExecution', () => {
30
+ test('renders Run Another Task button', () => {
31
+ API.get.mockReturnValue(new Promise(() => {}));
32
+ renderWithProviders();
33
+ expect(screen.getByText('Run Another Task')).toBeInTheDocument();
34
+ });
35
+
36
+ test('renders ExecutionDisplay with job details', () => {
37
+ API.get.mockReturnValue(new Promise(() => {}));
38
+ renderWithProviders();
39
+ expect(screen.getByText('Execution Details')).toBeInTheDocument();
40
+ });
41
+
42
+ test('shows loading indicator while polling', () => {
43
+ API.get.mockReturnValue(new Promise(() => {}));
44
+ renderWithProviders();
45
+ expect(screen.getByRole('status')).toBeInTheDocument();
46
+ });
47
+
48
+ test('returns null and redirects when job_id is missing', () => {
49
+ const { container } = renderWithProviders(null);
50
+ expect(container.innerHTML).toBe('');
51
+ });
52
+
53
+ test('strips ANSI codes from log output when result is available', async () => {
54
+ const statusData = {
55
+ status: 'success',
56
+ task_name: 'test::task',
57
+ targets: ['host1'],
58
+ submitted_at: '2026-01-01T00:00:00Z',
59
+ completed_at: '2026-01-01T00:01:00Z',
60
+ smart_proxy: { id: 1, name: 'proxy1' },
61
+ };
62
+ const resultData = {
63
+ command: 'bolt task run test::task',
64
+ value: { items: [] },
65
+ log: '\u001b[32mSuccess\u001b[0m: Task completed',
66
+ };
67
+
68
+ API.get
69
+ .mockResolvedValueOnce({ data: statusData })
70
+ .mockResolvedValueOnce({ data: resultData });
71
+
72
+ renderWithProviders();
73
+
74
+ // Wait for result to render, then verify ANSI codes are stripped
75
+ await screen.findByText('Result');
76
+ // The log should contain the clean text without ANSI escape sequences
77
+ expect(screen.getByText(/Success: Task completed/)).toBeInTheDocument();
78
+ expect(screen.queryByText(/\[32m/)).not.toBeInTheDocument();
79
+ });
80
+ });