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
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect } from 'react';
1
+ import React, { useState, useEffect, useRef } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import { useQuery } from '@apollo/client';
4
4
  import {
@@ -25,69 +25,78 @@ import hostgroupsQuery from './hostgroups.gql';
25
25
 
26
26
  export const maxResults = 100;
27
27
 
28
+ const queries = {
29
+ HOSTS: hostsQuery,
30
+ HOST_GROUPS: hostgroupsQuery,
31
+ };
32
+
33
+ const dataName = {
34
+ HOSTS: 'hosts',
35
+ HOST_GROUPS: 'hostgroups',
36
+ };
37
+
38
+ const useNameSearch = queryKey => {
39
+ const org = useForemanOrganization();
40
+ const location = useForemanLocation();
41
+ const [search, setSearch] = useState('');
42
+
43
+ const { loading, data, error } = useQuery(queries[queryKey], {
44
+ variables: {
45
+ search: [
46
+ `name~"${search}"`,
47
+ org ? `organization_id=${org.id}` : null,
48
+ location ? `location_id=${location.id}` : null,
49
+ ]
50
+ .filter(i => i)
51
+ .join(' and '),
52
+ },
53
+ });
54
+ return [
55
+ setSearch,
56
+ {
57
+ subtotal: data?.[dataName[queryKey]]?.totalCount,
58
+ results:
59
+ data?.[dataName[queryKey]]?.nodes.map(node => ({
60
+ id: decodeId(node.id),
61
+ name: node.name,
62
+ displayName: node.displayName,
63
+ })) || [],
64
+ error: error?.message || null,
65
+ },
66
+ loading,
67
+ ];
68
+ };
69
+
28
70
  export const SearchSelect = ({
29
71
  name,
30
72
  selected,
31
73
  setSelected,
32
74
  placeholderText,
33
75
  apiKey,
34
- url,
35
76
  setLabel,
36
77
  }) => {
37
- const useNameSearch = queryKey => {
38
- const org = useForemanOrganization();
39
- const location = useForemanLocation();
40
- const [search, setSearch] = useState('');
41
- const queries = {
42
- HOSTS: hostsQuery,
43
- HOST_GROUPS: hostgroupsQuery,
44
- };
45
- // Was from JobWizardConstants. Move into ours maybe.
46
- const dataName = {
47
- HOSTS: 'hosts',
48
- HOST_GROUPS: 'hostgroups',
49
- };
50
-
51
- const { loading, data } = useQuery(queries[queryKey], {
52
- variables: {
53
- search: [
54
- `name~"${search}"`,
55
- org ? `organization_id=${org.id}` : null,
56
- location ? `location_id=${location.id}` : null,
57
- ]
58
- .filter(i => i)
59
- .join(' and '),
60
- },
61
- });
62
- return [
63
- setSearch,
64
- {
65
- subtotal: data?.[dataName[queryKey]]?.totalCount,
66
- results:
67
- data?.[dataName[queryKey]]?.nodes.map(node => ({
68
- id: decodeId(node.id),
69
- name: node.name,
70
- displayName: node.displayName,
71
- })) || [],
72
- },
73
- loading,
74
- ];
75
- };
76
-
77
- const [onSearch, response, isLoading] = useNameSearch(apiKey, url);
78
+ const [onSearch, response, isLoading] = useNameSearch(apiKey);
78
79
  const [inputValue, setInputValue] = useState('');
79
80
  const [isOpen, setIsOpen] = useState(false);
80
- const [typingTimeout, setTypingTimeout] = useState(null);
81
+ const typingTimeoutRef = useRef(null);
82
+
81
83
  useEffect(() => {
82
- onSearch(selected.name || '');
83
- if (typingTimeout) {
84
- return () => clearTimeout(typingTimeout);
85
- }
86
- return undefined;
84
+ onSearch('');
85
+ return () => {
86
+ if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
87
+ };
87
88
  // eslint-disable-next-line react-hooks/exhaustive-deps
88
89
  }, []);
90
+
89
91
  let selectOptions = [];
90
- if (response.subtotal > maxResults) {
92
+
93
+ if (response.error) {
94
+ selectOptions = [
95
+ <SelectOption isDisabled key="error">
96
+ {sprintf(__('Error loading results: %s'), response.error)}
97
+ </SelectOption>,
98
+ ];
99
+ } else if (response.subtotal > maxResults) {
91
100
  selectOptions = [
92
101
  <SelectOption
93
102
  isDisabled
@@ -102,10 +111,11 @@ export const SearchSelect = ({
102
111
  </SelectOption>,
103
112
  ];
104
113
  }
114
+
105
115
  selectOptions = [
106
116
  ...selectOptions,
107
117
  ...Immutable.asMutable(response?.results || [])?.map((result, index) => (
108
- <SelectOption key={index + 1} value={result.id}>
118
+ <SelectOption key={result.id || index} value={result.id}>
109
119
  {setLabel(result)}
110
120
  </SelectOption>
111
121
  )),
@@ -124,9 +134,10 @@ export const SearchSelect = ({
124
134
  }
125
135
  setInputValue('');
126
136
  };
137
+
127
138
  const autoSearch = searchTerm => {
128
- if (typingTimeout) clearTimeout(typingTimeout);
129
- setTypingTimeout(setTimeout(() => onSearch(searchTerm), 500));
139
+ if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
140
+ typingTimeoutRef.current = setTimeout(() => onSearch(searchTerm), 500);
130
141
  };
131
142
 
132
143
  const toggle = toggleRef => (
@@ -149,17 +160,19 @@ export const SearchSelect = ({
149
160
  aria-label={`${name} typeahead input`}
150
161
  role="combobox"
151
162
  isExpanded={isOpen}
152
- aria-controls="select-typeahead-listbox"
163
+ aria-controls={`${name}-listbox`}
153
164
  placeholder={placeholderText}
154
165
  />
155
166
  <TextInputGroupUtilities>
156
- {isLoading && <Spinner size="md" />}
167
+ {isLoading && (
168
+ <Spinner size="md" aria-label={__('Loading results')} />
169
+ )}
157
170
  {selected.length > 0 && (
158
171
  <Button
159
172
  variant="plain"
160
173
  aria-label={__('Clear selections')}
161
174
  onClick={() => {
162
- setSelected([]);
175
+ setSelected(() => []);
163
176
  setInputValue('');
164
177
  }}
165
178
  >
@@ -170,6 +183,7 @@ export const SearchSelect = ({
170
183
  </TextInputGroup>
171
184
  </MenuToggle>
172
185
  );
186
+
173
187
  return (
174
188
  <Select
175
189
  id={name}
@@ -177,11 +191,11 @@ export const SearchSelect = ({
177
191
  selected={selected.map(({ id }) => id)}
178
192
  onSelect={onSelect}
179
193
  onOpenChange={setIsOpen}
180
- role="menu"
194
+ role="listbox"
181
195
  toggle={toggle}
182
196
  >
183
197
  <SelectList
184
- id="select-typeahead-listbox"
198
+ id={`${name}-listbox`}
185
199
  style={{ maxHeight: '45vh', overflowY: 'auto' }}
186
200
  >
187
201
  {selectOptions}
@@ -192,17 +206,15 @@ export const SearchSelect = ({
192
206
 
193
207
  SearchSelect.propTypes = {
194
208
  name: PropTypes.string,
195
- selected: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
209
+ selected: PropTypes.array,
196
210
  setSelected: PropTypes.func.isRequired,
197
211
  setLabel: PropTypes.func.isRequired,
198
212
  placeholderText: PropTypes.string,
199
213
  apiKey: PropTypes.string.isRequired,
200
- url: PropTypes.string,
201
214
  };
202
215
 
203
216
  SearchSelect.defaultProps = {
204
217
  name: 'typeahead select',
205
- selected: {},
218
+ selected: [],
206
219
  placeholderText: '',
207
- url: '',
208
220
  };
@@ -17,17 +17,17 @@ const SelectedChip = ({ selected, setSelected, categoryName, setLabel }) => {
17
17
  className="hosts-chip-group"
18
18
  categoryName={categoryName}
19
19
  isClosable
20
- closeBtnAriaLabel="Remove all"
20
+ closeBtnAriaLabel={__('Remove all')}
21
21
  collapsedText={sprintf(__('%s more'), selected.length - NUM_CHIPS)}
22
22
  numChips={NUM_CHIPS}
23
23
  onClick={() => {
24
24
  setSelected(() => []);
25
25
  }}
26
26
  >
27
- {selected.map((result, index) => (
27
+ {selected.map(result => (
28
28
  <Chip
29
29
  ouiaId={`${categoryName}-${result.id}`}
30
- key={index}
30
+ key={result.id}
31
31
  id={`${categoryName}-${result.id}`}
32
32
  onClick={() => deleteItem(result.id)}
33
33
  closeBtnAriaLabel={`Remove ${result.name}`}
@@ -71,16 +71,14 @@ export const SelectedChips = ({
71
71
  setSelected={setSelectedHostGroups}
72
72
  setLabel={setLabel}
73
73
  />
74
- <SelectedChip
75
- selected={
76
- hostsSearchQuery
77
- ? [{ id: hostsSearchQuery, name: hostsSearchQuery }]
78
- : []
79
- }
80
- categoryName={__('Search query')}
81
- setSelected={clearSearch}
82
- setLabel={setLabel}
83
- />
74
+ {hostsSearchQuery && (
75
+ <SelectedChip
76
+ selected={[{ id: hostsSearchQuery, name: hostsSearchQuery }]}
77
+ categoryName={__('Search query')}
78
+ setSelected={() => clearSearch()}
79
+ setLabel={setLabel}
80
+ />
81
+ )}
84
82
  {showClear && (
85
83
  <Button
86
84
  ouiaId="clear-chips"
@@ -4,11 +4,12 @@
4
4
  */
5
5
  import React, { useState, useEffect } from 'react';
6
6
  import PropTypes from 'prop-types';
7
- import { sprintf, translate as __ } from 'foremanReact/common/I18n';
7
+ import { translate as __ } from 'foremanReact/common/I18n';
8
8
  import { API } from 'foremanReact/redux/API';
9
9
  import {
10
10
  FormGroup,
11
11
  HelperText,
12
+ HelperTextItem,
12
13
  Select,
13
14
  SelectOption,
14
15
  InputGroup,
@@ -17,24 +18,25 @@ import {
17
18
  FormHelperText,
18
19
  } from '@patternfly/react-core';
19
20
  import { FilterIcon } from '@patternfly/react-icons';
21
+ import { extractErrorMessage } from '../../common/helpers';
20
22
  import { SearchSelect } from './SearchSelect';
21
23
  import { SelectedChips } from './SelectedChips';
22
24
  import { HostSearch } from './HostSearch';
23
25
 
24
- // Was from JobWizardConstants. Move into ours maybe.
25
- const HOST_METHODS = {
26
- hosts: __('Hosts'),
27
- hostGroups: __('Host groups'),
28
- searchQuery: __('Search query'),
26
+ const HOST_METHOD_LABELS = {
27
+ hosts: () => __('Hosts'),
28
+ hostGroups: () => __('Host groups'),
29
+ searchQuery: () => __('Search query'),
29
30
  };
31
+
30
32
  const ERROR_MESSAGES = {
31
- hosts: __('Please select at least one host'),
32
- hostGroups: __('Please select at least one host group'),
33
- searchQuery: __('Please enter a search query'),
33
+ hosts: () => __('Please select at least one host'),
34
+ hostGroups: () => __('Please select at least one host group'),
35
+ searchQuery: () => __('Please enter a search query'),
34
36
  };
35
37
 
36
38
  const HostSelector = ({ onChange, targetCount = 0 }) => {
37
- const [hostMethod, setHostMethod] = useState(HOST_METHODS.hosts);
39
+ const [hostMethod, setHostMethod] = useState('hosts');
38
40
  const [isOpen, setIsOpen] = useState(false);
39
41
  const [errorText, setErrorText] = useState('');
40
42
  const [hostsSearchQuery, setHostsSearchQuery] = useState('');
@@ -121,7 +123,7 @@ const HostSelector = ({ onChange, targetCount = 0 }) => {
121
123
  }
122
124
  } catch (error) {
123
125
  if (!cancelled) {
124
- setFetchError(error.message || __('Failed to fetch hosts'));
126
+ setFetchError(extractErrorMessage(error));
125
127
  onChange([]);
126
128
  }
127
129
  } finally {
@@ -143,7 +145,7 @@ const HostSelector = ({ onChange, targetCount = 0 }) => {
143
145
  const onSelect = (_event, selection) => {
144
146
  setHostMethod(selection);
145
147
  setIsOpen(false);
146
- setErrorText(ERROR_MESSAGES[selection] || '');
148
+ setErrorText(ERROR_MESSAGES[selection]());
147
149
  };
148
150
 
149
151
  const onToggleClick = () => setIsOpen(!isOpen);
@@ -154,25 +156,18 @@ const HostSelector = ({ onChange, targetCount = 0 }) => {
154
156
  onClick={onToggleClick}
155
157
  isExpanded={isOpen}
156
158
  icon={<FilterIcon />}
159
+ aria-label={__('Select host targeting method')}
157
160
  >
158
- {hostMethod}
161
+ {HOST_METHOD_LABELS[hostMethod]()}
159
162
  </MenuToggle>
160
163
  );
161
164
 
162
165
  return (
163
166
  <div className="host-selector">
164
167
  <FormGroup fieldId="host-selector" label={__('Hosts')}>
165
- {targetCount > 0 && (
166
- <HelperText>
167
- <HelperText variant="success">
168
- {sprintf(__('%s hosts selected'), targetCount)}
169
- </HelperText>
170
- </HelperText>
171
- )}
172
-
173
168
  {isLoading && (
174
- <HelperText>
175
- <HelperText>{__('Loading hosts...')}</HelperText>
169
+ <HelperText aria-live="polite">
170
+ <HelperTextItem>{__('Loading hosts...')}</HelperTextItem>
176
171
  </HelperText>
177
172
  )}
178
173
 
@@ -188,16 +183,16 @@ const HostSelector = ({ onChange, targetCount = 0 }) => {
188
183
  className="without_select2"
189
184
  aria-label={__('Host selection method')}
190
185
  >
191
- {Object.values(HOST_METHODS).map((method, index) => (
192
- <SelectOption key={index} value={method}>
193
- {method}
186
+ {Object.entries(HOST_METHOD_LABELS).map(([key, labelFn]) => (
187
+ <SelectOption key={key} value={key}>
188
+ {labelFn()}
194
189
  </SelectOption>
195
190
  ))}
196
191
  </Select>
197
192
  </FormGroup>
198
193
  </InputGroupItem>
199
194
 
200
- {hostMethod === HOST_METHODS.hosts && (
195
+ {hostMethod === 'hosts' && (
201
196
  <SearchSelect
202
197
  selected={selectedTargets.hosts}
203
198
  setSelected={setSelectedHosts}
@@ -208,7 +203,7 @@ const HostSelector = ({ onChange, targetCount = 0 }) => {
208
203
  />
209
204
  )}
210
205
 
211
- {hostMethod === HOST_METHODS.hostGroups && (
206
+ {hostMethod === 'hostGroups' && (
212
207
  <SearchSelect
213
208
  selected={selectedTargets.hostGroups}
214
209
  setSelected={setSelectedHostGroups}
@@ -219,7 +214,7 @@ const HostSelector = ({ onChange, targetCount = 0 }) => {
219
214
  />
220
215
  )}
221
216
 
222
- {hostMethod === HOST_METHODS.searchQuery && (
217
+ {hostMethod === 'searchQuery' && (
223
218
  <HostSearch
224
219
  setValue={setHostsSearchQuery}
225
220
  value={hostsSearchQuery}
@@ -228,14 +223,14 @@ const HostSelector = ({ onChange, targetCount = 0 }) => {
228
223
  </InputGroup>
229
224
 
230
225
  {!hasSelection && (
231
- <FormHelperText>
232
- <HelperText variant="error">{errorText}</HelperText>
226
+ <FormHelperText aria-live="assertive">
227
+ <HelperTextItem variant="error">{errorText}</HelperTextItem>
233
228
  </FormHelperText>
234
229
  )}
235
230
 
236
231
  {fetchError && (
237
- <FormHelperText>
238
- <HelperText variant="error">{fetchError}</HelperText>
232
+ <FormHelperText aria-live="assertive">
233
+ <HelperTextItem variant="error">{fetchError}</HelperTextItem>
239
234
  </FormHelperText>
240
235
  )}
241
236
  </FormGroup>
@@ -9,7 +9,7 @@ import { ENCRYPTED_DEFAULT_PLACEHOLDER } from '../common/constants';
9
9
 
10
10
  const Loading = () => (
11
11
  <div style={{ textAlign: 'center', padding: '2rem' }}>
12
- <Spinner size="lg" />
12
+ <Spinner size="lg" aria-label={__('Loading OpenBolt options')} />
13
13
  <p>{__('Loading OpenBolt options...')}</p>
14
14
  </div>
15
15
  );
@@ -74,7 +74,8 @@ const OpenBoltOptionsSection = ({
74
74
  if (aIsBoolean !== bIsBoolean) return aIsBoolean ? -1 : 1;
75
75
  return 0;
76
76
  });
77
- return [['transport', transport], ...entries];
77
+ if (transport) return [['transport', transport], ...entries];
78
+ return entries;
78
79
  };
79
80
 
80
81
  const render = () => {
@@ -105,6 +105,7 @@ const ParameterField = ({
105
105
  isChecked={!!(value ?? defaultValue)}
106
106
  onChange={(_event, checked) => onChange(name, checked)}
107
107
  aria-label={name}
108
+ label={name}
108
109
  />
109
110
  );
110
111
  }
@@ -125,6 +126,7 @@ const ParameterField = ({
125
126
  value={resolvedValue}
126
127
  onChange={(_event, newValue) => onChange(name, newValue)}
127
128
  isRequired={isRequired && !hasEncryptedDefault}
129
+ aria-label={description || name}
128
130
  />
129
131
  );
130
132
  };
@@ -15,10 +15,11 @@ const SmartProxySelect = ({
15
15
  }) => (
16
16
  <FormGroup label={__('Smart Proxy')} fieldId="smart-proxy-input">
17
17
  <FormSelect
18
- id="proxy-select"
18
+ id="smart-proxy-input"
19
19
  value={selectedProxy}
20
20
  onChange={onProxyChange}
21
21
  isDisabled={isLoading}
22
+ aria-label={__('Select Smart Proxy')}
22
23
  title={__('Select a Smart Proxy to run the task from.')}
23
24
  // Foreman tries injecting select2 which breaks this component
24
25
  className="without_select2"
@@ -24,9 +24,9 @@ const TaskSelect = ({
24
24
  <Flex spaceItems={{ default: 'spaceItemsSm' }}>
25
25
  <FlexItem flex={{ default: 'flex_1' }}>
26
26
  <FormSelect
27
- id="task-select"
27
+ id="task-name-input"
28
28
  // Force remount on isDisabled so the tooltip based on the title changes
29
- key={`task-select-${isDisabled}`}
29
+ key={`task-name-input-${isDisabled}`}
30
30
  title={
31
31
  isDisabled
32
32
  ? __('You must first select a Smart Proxy')
@@ -67,7 +67,7 @@ const TaskSelect = ({
67
67
  </FlexItem>
68
68
  </Flex>
69
69
  <span id="task-select-helper" className="pf-v5-u-screen-reader">
70
- {__('Select a OpenBolt task to execute on the specified targets')}
70
+ {__('Select an OpenBolt task to execute on the specified targets')}
71
71
  </span>
72
72
  </FormGroup>
73
73
  );
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import EmptyContent from '../EmptyContent';
4
+
5
+ describe('EmptyContent', () => {
6
+ test('renders the title text', () => {
7
+ render(<EmptyContent title="No items found" />);
8
+ expect(screen.getByText('No items found')).toBeInTheDocument();
9
+ });
10
+ });
@@ -0,0 +1,83 @@
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 { MemoryRouter } from 'react-router-dom';
6
+ import { API } from 'foremanReact/redux/API';
7
+ import LaunchTask from '../index';
8
+
9
+ // Mock HostSelector to avoid Apollo/GraphQL dependency chain
10
+ jest.mock('../HostSelector', () => {
11
+ const MockHostSelector = () => (
12
+ <div data-testid="host-selector">Host Selector</div>
13
+ );
14
+ MockHostSelector.displayName = 'HostSelector';
15
+ return MockHostSelector;
16
+ });
17
+
18
+ const mockStore = createStore(() => ({}));
19
+
20
+ const renderLaunchTask = () =>
21
+ render(
22
+ <Provider store={mockStore}>
23
+ <MemoryRouter>
24
+ <LaunchTask />
25
+ </MemoryRouter>
26
+ </Provider>
27
+ );
28
+
29
+ afterEach(() => {
30
+ jest.clearAllMocks();
31
+ });
32
+
33
+ describe('LaunchTask', () => {
34
+ beforeEach(() => {
35
+ // useSmartProxies fetches on mount
36
+ API.get.mockResolvedValue({
37
+ data: {
38
+ results: [
39
+ { id: 1, name: 'proxy-one' },
40
+ { id: 2, name: 'proxy-two' },
41
+ ],
42
+ },
43
+ });
44
+ });
45
+
46
+ test('renders the form with Launch Task button', async () => {
47
+ renderLaunchTask();
48
+ await waitFor(() => {
49
+ expect(screen.getByText(/Launch Task/)).toBeInTheDocument();
50
+ });
51
+ });
52
+
53
+ test('renders Smart Proxy select with fetched proxies', async () => {
54
+ renderLaunchTask();
55
+ await waitFor(() => {
56
+ expect(screen.getByText('proxy-one')).toBeInTheDocument();
57
+ expect(screen.getByText('proxy-two')).toBeInTheDocument();
58
+ });
59
+ });
60
+
61
+ test('Launch Task button is disabled when form is incomplete', async () => {
62
+ renderLaunchTask();
63
+ await waitFor(() => {
64
+ const button = screen.getByRole('button', { name: /Launch Task/ });
65
+ expect(button).toBeDisabled();
66
+ });
67
+ });
68
+
69
+ test('shows task select as disabled before proxy is selected', async () => {
70
+ renderLaunchTask();
71
+ await waitFor(() => {
72
+ const taskSelect = screen.getByLabelText('Select Task');
73
+ expect(taskSelect).toBeDisabled();
74
+ });
75
+ });
76
+
77
+ test('renders host selector', async () => {
78
+ renderLaunchTask();
79
+ await waitFor(() => {
80
+ expect(screen.getByTestId('host-selector')).toBeInTheDocument();
81
+ });
82
+ });
83
+ });
@@ -0,0 +1,86 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import ParameterField from '../ParameterField';
4
+
5
+ describe('ParameterField', () => {
6
+ test('renders text input for string type', () => {
7
+ const { container } = render(
8
+ <ParameterField
9
+ name="username"
10
+ metadata={{ type: 'String' }}
11
+ value="admin"
12
+ onChange={jest.fn()}
13
+ />
14
+ );
15
+ const input = container.querySelector('input[type="text"]');
16
+ expect(input).toBeInTheDocument();
17
+ expect(input.value).toBe('admin');
18
+ });
19
+
20
+ test('renders password input for sensitive fields', () => {
21
+ const { container } = render(
22
+ <ParameterField
23
+ name="password"
24
+ metadata={{ type: 'String', sensitive: true }}
25
+ value="secret"
26
+ onChange={jest.fn()}
27
+ />
28
+ );
29
+ const input = container.querySelector('input[type="password"]');
30
+ expect(input).toBeInTheDocument();
31
+ });
32
+
33
+ test('renders checkbox for boolean type', () => {
34
+ render(
35
+ <ParameterField
36
+ name="verbose"
37
+ metadata={{ type: 'boolean' }}
38
+ value
39
+ onChange={jest.fn()}
40
+ />
41
+ );
42
+ expect(screen.getByRole('checkbox')).toBeInTheDocument();
43
+ });
44
+
45
+ test('renders checkbox for Optional[Boolean] type', () => {
46
+ render(
47
+ <ParameterField
48
+ name="noop"
49
+ metadata={{ type: 'Optional[Boolean]' }}
50
+ value={false}
51
+ onChange={jest.fn()}
52
+ />
53
+ );
54
+ expect(screen.getByRole('checkbox')).toBeInTheDocument();
55
+ });
56
+
57
+ test('renders select for array (enum) type', () => {
58
+ const { container } = render(
59
+ <ParameterField
60
+ name="transport"
61
+ metadata={{ type: ['ssh', 'winrm'] }}
62
+ value="ssh"
63
+ onChange={jest.fn()}
64
+ />
65
+ );
66
+ const select = container.querySelector('select');
67
+ expect(select).toBeInTheDocument();
68
+ expect(screen.getByText('ssh')).toBeInTheDocument();
69
+ expect(screen.getByText('winrm')).toBeInTheDocument();
70
+ });
71
+
72
+ test('calls onChange when value changes', () => {
73
+ const handleChange = jest.fn();
74
+ const { container } = render(
75
+ <ParameterField
76
+ name="username"
77
+ metadata={{ type: 'String' }}
78
+ value=""
79
+ onChange={handleChange}
80
+ />
81
+ );
82
+ const input = container.querySelector('input');
83
+ fireEvent.change(input, { target: { value: 'new-value' } });
84
+ expect(handleChange).toHaveBeenCalledWith('username', 'new-value');
85
+ });
86
+ });