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
@@ -0,0 +1,177 @@
1
+ import { renderHook, act } from '@testing-library/react-hooks';
2
+ import { API } from 'foremanReact/redux/API';
3
+ import useJobPolling from '../useJobPolling';
4
+
5
+ jest.useFakeTimers();
6
+
7
+ afterEach(() => {
8
+ jest.clearAllMocks();
9
+ jest.clearAllTimers();
10
+ });
11
+
12
+ describe('useJobPolling', () => {
13
+ test('returns undefined state when jobId is null', () => {
14
+ const { result } = renderHook(() => useJobPolling(null));
15
+ expect(result.current.status).toBe('pending');
16
+ expect(result.current.isPolling).toBe(false);
17
+ expect(result.current.result).toBeNull();
18
+ });
19
+
20
+ test('polls for status and completes when job succeeds', async () => {
21
+ const statusData = {
22
+ status: 'success',
23
+ submitted_at: '2026-01-01T00:00:00Z',
24
+ completed_at: '2026-01-01T00:01:00Z',
25
+ task_name: 'test::task',
26
+ task_description: 'A test task',
27
+ task_parameters: { name: 'nginx' },
28
+ targets: ['host1.example.com'],
29
+ smart_proxy: { id: 1, name: 'proxy1' },
30
+ };
31
+ const resultData = {
32
+ command: 'bolt task run test::task',
33
+ value: { items: [{ status: 'success' }] },
34
+ log: 'Task completed',
35
+ };
36
+
37
+ API.get
38
+ .mockResolvedValueOnce({ data: statusData })
39
+ .mockResolvedValueOnce({ data: resultData });
40
+
41
+ let hookResult;
42
+ await act(async () => {
43
+ hookResult = renderHook(() => useJobPolling('job-123'));
44
+ // Let the poll promise resolve
45
+ await new Promise(resolve => setImmediate(resolve));
46
+ });
47
+
48
+ const { result } = hookResult;
49
+ expect(result.current.status).toBe('success');
50
+ expect(result.current.taskName).toBe('test::task');
51
+ expect(result.current.targets).toEqual(['host1.example.com']);
52
+ expect(result.current.result).toEqual({
53
+ command: 'bolt task run test::task',
54
+ result: { items: [{ status: 'success' }] },
55
+ log: 'Task completed',
56
+ });
57
+ expect(result.current.isPolling).toBe(false);
58
+ });
59
+
60
+ test('does not fetch result for INVALID status', async () => {
61
+ const statusData = {
62
+ status: 'invalid',
63
+ task_name: 'broken::task',
64
+ targets: [],
65
+ };
66
+
67
+ API.get.mockResolvedValueOnce({ data: statusData });
68
+
69
+ let hookResult;
70
+ await act(async () => {
71
+ hookResult = renderHook(() => useJobPolling('job-456'));
72
+ await new Promise(resolve => setImmediate(resolve));
73
+ });
74
+
75
+ const { result } = hookResult;
76
+ expect(result.current.status).toBe('invalid');
77
+ expect(result.current.result).toBeNull();
78
+ // Only one API call (status), no result fetch
79
+ expect(API.get).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ test('sets error when status endpoint fails', async () => {
83
+ API.get.mockRejectedValueOnce({ message: 'Connection refused' });
84
+
85
+ let hookResult;
86
+ await act(async () => {
87
+ hookResult = renderHook(() => useJobPolling('job-789'));
88
+ await new Promise(resolve => setImmediate(resolve));
89
+ });
90
+
91
+ const { result } = hookResult;
92
+ expect(result.current.error).toContain('Connection refused');
93
+ expect(result.current.isPolling).toBe(false);
94
+ });
95
+
96
+ test('handles result fetch failure gracefully', async () => {
97
+ const statusData = {
98
+ status: 'success',
99
+ task_name: 'test::task',
100
+ targets: [],
101
+ };
102
+
103
+ API.get
104
+ .mockResolvedValueOnce({ data: statusData })
105
+ .mockRejectedValueOnce({ message: 'Result unavailable' });
106
+
107
+ let hookResult;
108
+ await act(async () => {
109
+ hookResult = renderHook(() => useJobPolling('job-fail'));
110
+ await new Promise(resolve => setImmediate(resolve));
111
+ });
112
+
113
+ const { result } = hookResult;
114
+ expect(result.current.status).toBe('success');
115
+ expect(result.current.error).toContain('Result unavailable');
116
+ expect(result.current.result).toEqual({ result: null, log: '' });
117
+ });
118
+
119
+ test('cancels polling on unmount', async () => {
120
+ const statusData = {
121
+ status: 'running',
122
+ task_name: 'long::task',
123
+ targets: [],
124
+ };
125
+ API.get.mockResolvedValue({ data: statusData });
126
+
127
+ let hookResult;
128
+ await act(async () => {
129
+ hookResult = renderHook(() => useJobPolling('job-cancel'));
130
+ await new Promise(resolve => setImmediate(resolve));
131
+ });
132
+
133
+ // Unmount while still polling
134
+ hookResult.unmount();
135
+
136
+ // Advance timers past the polling interval
137
+ jest.advanceTimersByTime(10000);
138
+
139
+ // API should have been called once for the initial poll, not more
140
+ const callCount = API.get.mock.calls.length;
141
+ jest.advanceTimersByTime(10000);
142
+ expect(API.get.mock.calls).toHaveLength(callCount);
143
+ });
144
+
145
+ test('loads metadata only once across multiple polls', async () => {
146
+ API.get
147
+ .mockResolvedValueOnce({
148
+ data: { status: 'running', task_name: 'my::task', targets: ['host1'] },
149
+ })
150
+ .mockResolvedValueOnce({
151
+ data: {
152
+ status: 'success',
153
+ task_name: 'changed::name',
154
+ targets: ['host2'],
155
+ },
156
+ })
157
+ .mockResolvedValueOnce({
158
+ data: { command: 'bolt', value: {}, log: '' },
159
+ });
160
+
161
+ let hookResult;
162
+ await act(async () => {
163
+ hookResult = renderHook(() => useJobPolling('job-meta'));
164
+ // First poll resolves (running)
165
+ await new Promise(resolve => setImmediate(resolve));
166
+ // Advance past the polling interval
167
+ jest.advanceTimersByTime(5000);
168
+ // Second poll resolves (success) + result fetch
169
+ await new Promise(resolve => setImmediate(resolve));
170
+ });
171
+
172
+ const { result } = hookResult;
173
+ // Task name should be from the first poll, not the second
174
+ expect(result.current.taskName).toBe('my::task');
175
+ expect(result.current.targets).toEqual(['host1']);
176
+ });
177
+ });
@@ -1,7 +1,8 @@
1
1
  /* eslint-disable no-await-in-loop */
2
- import { useState, useEffect } from 'react';
3
- import { translate as __ } from 'foremanReact/common/I18n';
2
+ import { useState, useEffect, useRef } from 'react';
3
+ import { sprintf, translate as __ } from 'foremanReact/common/I18n';
4
4
  import { API } from 'foremanReact/redux/API';
5
+ import { extractErrorMessage } from '../../common/helpers';
5
6
  import {
6
7
  STATUS,
7
8
  COMPLETED_STATUSES,
@@ -9,13 +10,7 @@ import {
9
10
  ROUTES,
10
11
  } from '../../common/constants';
11
12
 
12
- /**
13
- * Custom hook for polling job status
14
- * @param {string} proxyId - Smart Proxy ID
15
- * @param {string} jobId - Job ID to poll
16
- * @returns {Object}
17
- */
18
- const useJobPolling = (proxyId, jobId) => {
13
+ const useJobPolling = jobId => {
19
14
  const [status, setStatus] = useState(STATUS.PENDING);
20
15
  const [result, setResult] = useState(null);
21
16
  const [error, setError] = useState(null);
@@ -26,13 +21,15 @@ const useJobPolling = (proxyId, jobId) => {
26
21
  const [taskDescription, setTaskDescription] = useState(null);
27
22
  const [taskParameters, setTaskParameters] = useState({});
28
23
  const [targets, setTargets] = useState([]);
24
+ const [smartProxy, setSmartProxy] = useState(null);
25
+ const metadataLoaded = useRef(false);
29
26
 
30
27
  // There are a bunch of checks of 'cancelled' here so that if the
31
28
  // user navigates away while polling, we don't keep trying to update state.
32
29
  useEffect(() => {
33
30
  // Have to return undefined since we are returning a cleanup function
34
31
  // otherwise and React wants all code paths to return something.
35
- if (!proxyId || !jobId) return undefined;
32
+ if (!jobId) return undefined;
36
33
 
37
34
  let cancelled = false;
38
35
 
@@ -41,41 +38,40 @@ const useJobPolling = (proxyId, jobId) => {
41
38
 
42
39
  while (!cancelled) {
43
40
  try {
44
- const { data: statusData, status: statusCode } = await API.get(
45
- `${ROUTES.API.JOB_STATUS}?proxy_id=${proxyId}&job_id=${jobId}`
41
+ const { data: statusData } = await API.get(
42
+ `${ROUTES.API.JOB_STATUS}?job_id=${jobId}`
46
43
  );
47
44
 
48
45
  if (cancelled) break;
49
46
 
50
- if (statusCode !== 200) {
51
- const errorMsg = statusData
52
- ? statusData.error || JSON.stringify(statusData)
53
- : 'Unknown error';
54
- throw new Error(`HTTP ${statusCode} - ${errorMsg}`);
55
- }
56
-
57
47
  const jobStatus = statusData?.status;
58
48
  if (!jobStatus) {
59
- throw new Error('No job status returned');
49
+ throw new Error(__('No job status returned'));
60
50
  }
61
51
 
62
52
  setStatus(jobStatus);
63
- setSubmittedAt(statusData.submitted_at || null);
64
53
  setCompletedAt(statusData.completed_at || null);
65
- setTaskName(statusData.task_name || null);
66
- setTaskDescription(statusData.task_description || null);
67
- setTaskParameters(statusData.task_parameters || {});
68
- setTargets(statusData.targets || []);
54
+
55
+ // Task metadata only needs to be set once since it never changes
56
+ if (!metadataLoaded.current) {
57
+ setSubmittedAt(statusData.submitted_at || null);
58
+ setTaskName(statusData.task_name || null);
59
+ setTaskDescription(statusData.task_description || null);
60
+ setTaskParameters(statusData.task_parameters || {});
61
+ setTargets(statusData.targets || []);
62
+ setSmartProxy(statusData.smart_proxy || null);
63
+ metadataLoaded.current = true;
64
+ }
69
65
 
70
66
  // If job is complete, fetch results and break
71
67
  if (COMPLETED_STATUSES.includes(jobStatus)) {
72
68
  if (jobStatus === STATUS.INVALID) break;
73
69
  try {
74
- const { data: resultData, status: resultCode } = await API.get(
75
- `${ROUTES.API.JOB_RESULT}?proxy_id=${proxyId}&job_id=${jobId}`
70
+ const { data: resultData } = await API.get(
71
+ `${ROUTES.API.JOB_RESULT}?job_id=${jobId}`
76
72
  );
77
73
 
78
- if (!cancelled && resultCode === 200 && resultData) {
74
+ if (!cancelled && resultData) {
79
75
  setResult({
80
76
  command: resultData.command || '',
81
77
  result: resultData.value,
@@ -86,8 +82,10 @@ const useJobPolling = (proxyId, jobId) => {
86
82
  // Don't fail the whole thing if result fetch fails
87
83
  if (!cancelled) {
88
84
  setError(
89
- __('Failed to fetch job result: ') +
90
- (resultError.message || 'Unknown error')
85
+ sprintf(
86
+ __('Failed to fetch job result: %s'),
87
+ extractErrorMessage(resultError)
88
+ )
91
89
  );
92
90
  setResult({ result: null, log: '' });
93
91
  }
@@ -104,8 +102,10 @@ const useJobPolling = (proxyId, jobId) => {
104
102
  } catch (err) {
105
103
  if (!cancelled) {
106
104
  setError(
107
- __('Failed to fetch job status: ') +
108
- (err.message || 'Unknown error')
105
+ sprintf(
106
+ __('Failed to fetch job status: %s'),
107
+ extractErrorMessage(err)
108
+ )
109
109
  );
110
110
  }
111
111
  break;
@@ -123,7 +123,7 @@ const useJobPolling = (proxyId, jobId) => {
123
123
  cancelled = true;
124
124
  setIsPolling(false);
125
125
  };
126
- }, [proxyId, jobId]);
126
+ }, [jobId]);
127
127
 
128
128
  return {
129
129
  status,
@@ -136,6 +136,7 @@ const useJobPolling = (proxyId, jobId) => {
136
136
  taskDescription,
137
137
  taskParameters,
138
138
  targets,
139
+ smartProxy,
139
140
  };
140
141
  };
141
142
 
@@ -16,9 +16,7 @@ const TaskExecution = () => {
16
16
  const showMessage = useShowMessage();
17
17
 
18
18
  const params = new URLSearchParams(location.search);
19
- const proxyId = params.get('proxy_id');
20
19
  const jobId = params.get('job_id');
21
- const proxyName = params.get('proxy_name');
22
20
 
23
21
  const {
24
22
  status: jobStatus,
@@ -31,7 +29,8 @@ const TaskExecution = () => {
31
29
  taskDescription,
32
30
  taskParameters,
33
31
  targets,
34
- } = useJobPolling(proxyId, jobId);
32
+ smartProxy,
33
+ } = useJobPolling(jobId);
35
34
 
36
35
  useEffect(() => {
37
36
  if (pollError) {
@@ -41,16 +40,14 @@ const TaskExecution = () => {
41
40
 
42
41
  // Redirect if missing required params
43
42
  useEffect(() => {
44
- if (!proxyId || !jobId) {
45
- showMessage(
46
- __('Invalid task execution URL - missing required parameters')
47
- );
43
+ if (!jobId) {
44
+ showMessage(__('Invalid task execution URL - missing job ID'));
48
45
  history.push(ROUTES.PAGES.LAUNCH_TASK);
49
46
  }
50
- }, [proxyId, jobId, showMessage, history]);
47
+ }, [jobId, showMessage, history]);
51
48
 
52
49
  // Don't render if missing required params
53
- if (!proxyId || !jobId) {
50
+ if (!jobId) {
54
51
  return null;
55
52
  }
56
53
 
@@ -66,7 +63,9 @@ const TaskExecution = () => {
66
63
  const isComplete = COMPLETED_STATUSES.includes(jobStatus);
67
64
  const jobCommand = jobData?.command;
68
65
  const jobResult = jobData?.result;
69
- const jobLog = `OpenBolt command: ${jobCommand}\n${stripAnsi(jobData?.log)}`;
66
+ const jobLog = jobData
67
+ ? `OpenBolt command: ${jobCommand}\n${stripAnsi(jobData.log)}`
68
+ : '';
70
69
 
71
70
  return (
72
71
  <Stack hasGutter>
@@ -82,8 +81,7 @@ const TaskExecution = () => {
82
81
 
83
82
  <StackItem>
84
83
  <ExecutionDisplay
85
- proxyId={proxyId}
86
- proxyName={proxyName}
84
+ smartProxy={smartProxy}
87
85
  jobId={jobId}
88
86
  jobStatus={jobStatus}
89
87
  isPolling={isPolling}
@@ -1,23 +1,14 @@
1
1
  import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
- import { translate as __ } from 'foremanReact/common/I18n';
3
+ import { sprintf, translate as __ } from 'foremanReact/common/I18n';
4
4
  import { Popover, Button } from '@patternfly/react-core';
5
5
  import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table';
6
+ import { displayValue } from '../common/helpers';
6
7
 
7
8
  const TaskPopover = ({ taskName, taskDescription, taskParameters }) => {
8
9
  const hasParameters =
9
10
  taskParameters && Object.keys(taskParameters).length > 0;
10
11
 
11
- const displayValue = value => {
12
- if (value === null || value === undefined) {
13
- return '-';
14
- }
15
- if (typeof value === 'object') {
16
- return JSON.stringify(value);
17
- }
18
- return String(value);
19
- };
20
-
21
12
  const popoverContent = (
22
13
  <div style={{ maxWidth: '500px' }}>
23
14
  {taskDescription && (
@@ -41,6 +32,7 @@ const TaskPopover = ({ taskName, taskDescription, taskParameters }) => {
41
32
  borders
42
33
  isStriped
43
34
  isStickyHeader
35
+ aria-label={__('Task parameters')}
44
36
  style={{
45
37
  border: '1px solid var(--pf-v5-global--BorderColor--100)',
46
38
  }}
@@ -74,7 +66,12 @@ const TaskPopover = ({ taskName, taskDescription, taskParameters }) => {
74
66
 
75
67
  return (
76
68
  <Popover bodyContent={popoverContent} position="right">
77
- <Button variant="link" isInline className="pf-v5-u-font-family-monospace">
69
+ <Button
70
+ variant="link"
71
+ isInline
72
+ className="pf-v5-u-font-family-monospace"
73
+ aria-label={sprintf(__('View details for task %s'), taskName)}
74
+ >
78
75
  {taskName}
79
76
  </Button>
80
77
  </Popover>
@@ -0,0 +1,109 @@
1
+ import React from 'react';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import { Provider } from 'react-redux';
4
+ import { createStore } from 'redux';
5
+ import { API } from 'foremanReact/redux/API';
6
+ import { addToast } from 'foremanReact/components/ToastsList';
7
+ import TaskHistory from '../index';
8
+
9
+ const mockStore = createStore(() => ({}));
10
+ const wrapper = ({ children }) => (
11
+ <Provider store={mockStore}>{children}</Provider>
12
+ );
13
+
14
+ afterEach(() => {
15
+ jest.clearAllMocks();
16
+ });
17
+
18
+ const sampleJobs = [
19
+ {
20
+ job_id: 'job-1',
21
+ task_name: 'mymod::install',
22
+ task_description: 'Install a package',
23
+ task_parameters: { name: 'nginx' },
24
+ status: 'success',
25
+ targets: ['host1.example.com'],
26
+ submitted_at: '2026-01-15T10:00:00Z',
27
+ completed_at: '2026-01-15T10:01:00Z',
28
+ duration: 60,
29
+ },
30
+ {
31
+ job_id: 'job-2',
32
+ task_name: 'mymod::mytask',
33
+ task_description: 'Restart a service',
34
+ task_parameters: {},
35
+ status: 'running',
36
+ targets: ['host2.example.com'],
37
+ submitted_at: '2026-01-15T11:00:00Z',
38
+ completed_at: null,
39
+ duration: null,
40
+ },
41
+ ];
42
+
43
+ describe('TaskHistory', () => {
44
+ test('shows loading spinner initially', () => {
45
+ API.get.mockReturnValue(new Promise(() => {}));
46
+ render(<TaskHistory />, { wrapper });
47
+ expect(screen.getByLabelText('Loading task history')).toBeInTheDocument();
48
+ });
49
+
50
+ test('renders task history table with jobs', async () => {
51
+ API.get.mockResolvedValue({
52
+ data: { results: sampleJobs, total: 2 },
53
+ });
54
+
55
+ render(<TaskHistory />, { wrapper });
56
+
57
+ await waitFor(() => {
58
+ expect(screen.getByText('mymod::install')).toBeInTheDocument();
59
+ });
60
+ expect(screen.getByText('mymod::mytask')).toBeInTheDocument();
61
+ expect(screen.getByText('success')).toBeInTheDocument();
62
+ expect(screen.getByText('running')).toBeInTheDocument();
63
+ });
64
+
65
+ test('shows empty state when no jobs exist', async () => {
66
+ API.get.mockResolvedValue({
67
+ data: { results: [], total: 0 },
68
+ });
69
+
70
+ render(<TaskHistory />, { wrapper });
71
+
72
+ await waitFor(() => {
73
+ expect(screen.getByText('No task history found')).toBeInTheDocument();
74
+ });
75
+ });
76
+
77
+ test('shows toast and stops loading when API call fails', async () => {
78
+ API.get.mockRejectedValue({ message: 'Network error' });
79
+
80
+ render(<TaskHistory />, { wrapper });
81
+
82
+ await waitFor(() => {
83
+ expect(
84
+ screen.queryByLabelText('Loading task history')
85
+ ).not.toBeInTheDocument();
86
+ });
87
+ expect(addToast).toHaveBeenCalledWith(
88
+ expect.objectContaining({
89
+ type: 'danger',
90
+ message: expect.stringContaining('Failed to load task history'),
91
+ })
92
+ );
93
+ });
94
+
95
+ test('calls API with page and per_page params', async () => {
96
+ API.get.mockResolvedValue({
97
+ data: { results: [], total: 0 },
98
+ });
99
+
100
+ render(<TaskHistory />, { wrapper });
101
+
102
+ await waitFor(() => {
103
+ expect(API.get).toHaveBeenCalledWith(expect.stringContaining('page=1'));
104
+ expect(API.get).toHaveBeenCalledWith(
105
+ expect.stringContaining('per_page=20')
106
+ );
107
+ });
108
+ });
109
+ });
@@ -0,0 +1,26 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import TaskPopover from '../TaskPopover';
4
+
5
+ describe('TaskPopover', () => {
6
+ test('renders task name as button', () => {
7
+ render(<TaskPopover taskName="mymod::install" />);
8
+ expect(screen.getByText('mymod::install')).toBeInTheDocument();
9
+ expect(
10
+ screen.getByRole('button', { name: /mymod::install/ })
11
+ ).toBeInTheDocument();
12
+ });
13
+
14
+ test('renders task name button with description and parameters provided', () => {
15
+ render(
16
+ <TaskPopover
17
+ taskName="test::task"
18
+ taskDescription="A test task"
19
+ taskParameters={{ name: 'nginx' }}
20
+ />
21
+ );
22
+ expect(
23
+ screen.getByRole('button', { name: /test::task/ })
24
+ ).toBeInTheDocument();
25
+ });
26
+ });
@@ -1,5 +1,5 @@
1
1
  import React, { useState, useEffect } from 'react';
2
- import { translate as __ } from 'foremanReact/common/I18n';
2
+ import { sprintf, translate as __ } from 'foremanReact/common/I18n';
3
3
  import { API } from 'foremanReact/redux/API';
4
4
  import {
5
5
  Label,
@@ -22,7 +22,12 @@ import {
22
22
  UnknownIcon,
23
23
  } from '@patternfly/react-icons';
24
24
  import { ROUTES, STATUS } from '../common/constants';
25
- import { useShowMessage } from '../common/helpers';
25
+ import {
26
+ useShowMessage,
27
+ extractErrorMessage,
28
+ formatDuration,
29
+ formatDate,
30
+ } from '../common/helpers';
26
31
  import HostsPopover from '../common/HostsPopover';
27
32
  import TaskPopover from './TaskPopover';
28
33
 
@@ -44,21 +49,6 @@ const getStatusLabel = status => {
44
49
  );
45
50
  };
46
51
 
47
- const formatDuration = duration => {
48
- if (!duration) return '-';
49
- const seconds = Math.round(duration);
50
- if (seconds < 60) return `${seconds}s`;
51
- const minutes = Math.floor(seconds / 60);
52
- const remainingSeconds = seconds % 60;
53
- return `${minutes}m ${remainingSeconds}s`;
54
- };
55
-
56
- const formatDate = dateString => {
57
- if (!dateString) return '-';
58
- const date = new Date(dateString);
59
- return date.toLocaleString();
60
- };
61
-
62
52
  const TaskHistory = () => {
63
53
  const [taskHistory, setTaskHistory] = useState([]);
64
54
  const [isLoadingTaskHistory, setIsLoadingTaskHistory] = useState(true);
@@ -75,17 +65,23 @@ const TaskHistory = () => {
75
65
  setIsLoadingTaskHistory(true);
76
66
 
77
67
  try {
78
- const { data, status } = await API.get(
68
+ const { data } = await API.get(
79
69
  `${ROUTES.API.TASK_HISTORY}?page=${page}&per_page=${perPage}`
80
70
  );
81
71
 
82
- if (!cancelled && status === 200 && data) {
72
+ if (!cancelled && data) {
83
73
  setTaskHistory(data.results || []);
84
74
  setTotal(data.total || 0);
85
75
  }
86
76
  } catch (error) {
87
- if (!cancelled)
88
- showMessage(__('Failed to load task history: ') + error.message);
77
+ if (!cancelled) {
78
+ showMessage(
79
+ sprintf(
80
+ __('Failed to load task history: %s'),
81
+ extractErrorMessage(error)
82
+ )
83
+ );
84
+ }
89
85
  } finally {
90
86
  if (!cancelled) setIsLoadingTaskHistory(false);
91
87
  }
@@ -100,7 +96,7 @@ const TaskHistory = () => {
100
96
 
101
97
  const spinner = () => (
102
98
  <Bullseye>
103
- <Spinner size="xl" />
99
+ <Spinner size="xl" aria-label={__('Loading task history')} />
104
100
  </Bullseye>
105
101
  );
106
102
 
@@ -120,7 +116,7 @@ const TaskHistory = () => {
120
116
  const jobTable = () => (
121
117
  <>
122
118
  <Table
123
- aria-label="Task history table"
119
+ aria-label={__('Task history table')}
124
120
  borders
125
121
  isStriped
126
122
  isStickyHeader
@@ -131,7 +127,7 @@ const TaskHistory = () => {
131
127
  <Th modifier="wrap">{__('Task Name')}</Th>
132
128
  <Th modifier="wrap">{__('Status')}</Th>
133
129
  <Th modifier="wrap">{__('Targets')}</Th>
134
- <Th modifier="wrap">{__('Started')}</Th>
130
+ <Th modifier="wrap">{__('Submitted')}</Th>
135
131
  <Th modifier="wrap">{__('Completed')}</Th>
136
132
  <Th modifier="wrap">{__('Duration')}</Th>
137
133
  <Th modifier="wrap">{__('Details')}</Th>
@@ -158,11 +154,7 @@ const TaskHistory = () => {
158
154
  <Td hasRightBorder>{formatDuration(job.duration)}</Td>
159
155
  <Td hasRightBorder>
160
156
  <a
161
- href={`${ROUTES.PAGES.TASK_EXECUTION}?proxy_id=${
162
- job.smart_proxy.id
163
- }&job_id=${job.job_id}&proxy_name=${encodeURIComponent(
164
- job.smart_proxy.name
165
- )}`}
157
+ href={`${ROUTES.PAGES.TASK_EXECUTION}?job_id=${job.job_id}`}
166
158
  aria-label={__('View Details')}
167
159
  title={__('View Details')}
168
160
  >