foreman-tasks 12.2.4 → 13.1.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/foreman_tasks/tasks_controller.rb +0 -5
  3. data/app/services/foreman_tasks/troubleshooting_help_generator.rb +1 -1
  4. data/config/routes.rb +3 -2
  5. data/lib/foreman_tasks/engine.rb +2 -2
  6. data/lib/foreman_tasks/version.rb +1 -1
  7. data/test/controllers/tasks_controller_test.rb +0 -9
  8. data/test/foreman_tasks_test_helper.rb +2 -2
  9. data/test/integration/tasks_test.rb +17 -0
  10. data/test/test_plugin_helper.rb +8 -0
  11. data/webpack/ForemanTasks/Components/TaskDetails/Components/Task.js +8 -70
  12. data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskButtons.js +115 -104
  13. data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskButtons.scss +45 -0
  14. data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskHelper.js +54 -0
  15. data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskInfo.js +237 -177
  16. data/webpack/ForemanTasks/Components/TaskDetails/Components/TaskSkeleton.js +1 -6
  17. data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Task.test.js +27 -35
  18. data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskButtons.test.js +83 -69
  19. data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskHelper.test.js +118 -1
  20. data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/TaskInfo.test.js +190 -20
  21. data/webpack/ForemanTasks/Components/TaskDetails/ExecutionDetails.js +65 -0
  22. data/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.js +76 -65
  23. data/webpack/ForemanTasks/Components/TaskDetails/TaskDetails.scss +1 -44
  24. data/webpack/ForemanTasks/Components/TaskDetails/TaskDetailsConstants.js +2 -1
  25. data/webpack/ForemanTasks/Components/TaskDetails/TaskDetailsSelectors.js +20 -10
  26. data/webpack/ForemanTasks/Components/TaskDetails/__tests__/ExecutionDetails.test.js +194 -0
  27. data/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.fixtures.js +100 -1
  28. data/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetails.test.js +226 -18
  29. data/webpack/ForemanTasks/Components/TaskDetails/__tests__/TaskDetailsSelectors.test.js +81 -0
  30. data/webpack/ForemanTasks/Components/common/taskResultIcon.js +53 -0
  31. data/webpack/ForemanTasks/Routes/ShowTaskDetails/TaskDetailsHeader.js +174 -0
  32. data/webpack/ForemanTasks/Routes/ShowTaskDetails/TaskDetailsHeader.scss +5 -0
  33. data/webpack/ForemanTasks/Routes/ShowTaskDetails/TaskDetailsPage.js +51 -0
  34. data/webpack/ForemanTasks/Routes/ShowTaskDetails/__tests__/TaskDetailsHeader.test.js +142 -0
  35. data/webpack/ForemanTasks/Routes/ShowTaskDetails/__tests__/TaskDetailsPage.test.js +277 -0
  36. data/webpack/ForemanTasks/{Components/TaskDetails → Routes/ShowTaskDetails}/index.js +13 -12
  37. data/webpack/Routes/routes.js +6 -0
  38. data/webpack/Routes/routes.test.js +16 -5
  39. data/webpack/index.js +0 -5
  40. metadata +16 -3
  41. data/app/views/foreman_tasks/tasks/show.html.erb +0 -18
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
- import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
3
4
  import '@testing-library/jest-dom';
4
- import { STATUS } from 'foremanReact/constants';
5
5
  import { TaskButtons } from '../TaskButtons';
6
6
 
7
7
  const setUnlockModalOpen = jest.fn();
@@ -17,29 +17,42 @@ const defaultProps = {
17
17
  setForceUnlockModalOpen,
18
18
  };
19
19
 
20
+ const openTaskActionsMenu = async () => {
21
+ await act(async () => {
22
+ fireEvent.click(screen.getByRole('button', { name: /^task actions$/i }));
23
+ await new Promise(resolve => setTimeout(resolve, 0));
24
+ });
25
+
26
+ await waitFor(() => {
27
+ expect(screen.getByRole('menu')).toBeInTheDocument();
28
+ });
29
+ };
30
+
20
31
  describe('TaskButtons', () => {
21
32
  beforeEach(() => {
22
33
  jest.clearAllMocks();
23
34
  });
24
35
 
25
36
  describe('rendering', () => {
26
- it('renders reload button with correct text when taskReload is false', () => {
37
+ it('shows start auto-reloading in overflow when taskReload is false', async () => {
27
38
  render(<TaskButtons {...defaultProps} taskReload={false} />);
39
+ await openTaskActionsMenu();
28
40
  expect(
29
- screen.getByRole('button', { name: /start auto-reloading/i })
41
+ screen.getByRole('menuitem', { name: /start auto-reloading/i })
30
42
  ).toBeInTheDocument();
31
43
  expect(
32
- screen.queryByRole('button', { name: /stop auto-reloading/i })
44
+ screen.queryByRole('menuitem', { name: /stop auto-reloading/i })
33
45
  ).not.toBeInTheDocument();
34
46
  });
35
47
 
36
- it('renders reload button with correct text when taskReload is true', () => {
48
+ it('shows stop auto-reloading in overflow when taskReload is true', async () => {
37
49
  render(<TaskButtons {...defaultProps} taskReload />);
50
+ await openTaskActionsMenu();
38
51
  expect(
39
- screen.getByRole('button', { name: /stop auto-reloading/i })
52
+ screen.getByRole('menuitem', { name: /stop auto-reloading/i })
40
53
  ).toBeInTheDocument();
41
54
  expect(
42
- screen.queryByRole('button', { name: /start auto-reloading/i })
55
+ screen.queryByRole('menuitem', { name: /start auto-reloading/i })
43
56
  ).not.toBeInTheDocument();
44
57
  });
45
58
 
@@ -55,15 +68,21 @@ describe('TaskButtons', () => {
55
68
  expect(dynflowLink).toHaveAttribute('target', '_blank');
56
69
  });
57
70
 
58
- it('disables dynflow console link when dynflowEnableConsole is false', () => {
59
- render(<TaskButtons {...defaultProps} dynflowEnableConsole={false} />);
71
+ it('marks dynflow console link disabled when dynflowEnableConsole is false', () => {
72
+ render(
73
+ <TaskButtons
74
+ {...defaultProps}
75
+ dynflowEnableConsole={false}
76
+ externalId="ext-id"
77
+ />
78
+ );
60
79
  const dynflowLink = screen.getByRole('link', {
61
80
  name: /dynflow console/i,
62
81
  });
63
- expect(dynflowLink).not.toBeDisabled();
82
+ expect(dynflowLink).toHaveAttribute('aria-disabled', 'true');
64
83
  });
65
84
 
66
- it('enables dynflow console link when dynflowEnableConsole is true', () => {
85
+ it('does not disable dynflow console link when dynflowEnableConsole is true', () => {
67
86
  render(
68
87
  <TaskButtons
69
88
  {...defaultProps}
@@ -74,18 +93,20 @@ describe('TaskButtons', () => {
74
93
  const dynflowLink = screen.getByRole('link', {
75
94
  name: /dynflow console/i,
76
95
  });
77
- expect(dynflowLink).not.toBeDisabled();
96
+ expect(dynflowLink).not.toHaveAttribute('aria-disabled', 'true');
78
97
  });
79
98
 
80
- it('disables resume and cancel buttons when canEdit is false', () => {
99
+ it('disables resume overflow item and cancel button when canEdit is false', async () => {
81
100
  render(<TaskButtons {...defaultProps} canEdit={false} />);
82
- expect(screen.getByRole('button', { name: /resume/i })).toBeDisabled();
83
101
  expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled();
102
+ await openTaskActionsMenu();
103
+ expect(screen.getByRole('menuitem', { name: /^resume$/i })).toBeDisabled();
84
104
  });
85
105
 
86
- it('disables resume button when resumable is false', () => {
106
+ it('disables resume overflow item when resumable is false', async () => {
87
107
  render(<TaskButtons {...defaultProps} canEdit resumable={false} />);
88
- expect(screen.getByRole('button', { name: /resume/i })).toBeDisabled();
108
+ await openTaskActionsMenu();
109
+ expect(screen.getByRole('menuitem', { name: /^resume$/i })).toBeDisabled();
89
110
  });
90
111
 
91
112
  it('disables cancel button when cancellable is false', () => {
@@ -93,66 +114,63 @@ describe('TaskButtons', () => {
93
114
  expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled();
94
115
  });
95
116
 
96
- it('disables unlock button when state is not paused', () => {
117
+ it('disables unlock overflow item when state is not paused', async () => {
97
118
  render(<TaskButtons {...defaultProps} canEdit state="running" />);
98
- expect(screen.getByRole('button', { name: /^unlock$/i })).toBeDisabled();
119
+ await openTaskActionsMenu();
120
+ expect(screen.getByRole('menuitem', { name: /^unlock$/i })).toBeDisabled();
99
121
  });
100
122
 
101
- it('enables unlock button when state is paused and canEdit is true', () => {
123
+ it('enables unlock overflow item when state is paused and canEdit is true', async () => {
102
124
  render(<TaskButtons {...defaultProps} canEdit state="paused" />);
103
- expect(
104
- screen.getByRole('button', { name: /^unlock$/i })
105
- ).not.toBeDisabled();
125
+ await openTaskActionsMenu();
126
+ expect(screen.getByRole('menuitem', { name: /^unlock$/i })).not.toBeDisabled();
106
127
  });
107
128
 
108
- it('disables force unlock button when state is stopped', () => {
129
+ it('disables force unlock overflow item when state is stopped', async () => {
109
130
  render(<TaskButtons {...defaultProps} canEdit state="stopped" />);
110
- expect(
111
- screen.getByRole('button', { name: /force unlock/i })
112
- ).toBeDisabled();
131
+ await openTaskActionsMenu();
132
+ expect(screen.getByRole('menuitem', { name: /force unlock/i })).toBeDisabled();
113
133
  });
114
134
 
115
- it('enables force unlock button when state is not stopped and canEdit is true', () => {
135
+ it('enables force unlock overflow item when state is not stopped and canEdit is true', async () => {
116
136
  render(<TaskButtons {...defaultProps} canEdit state="running" />);
117
- expect(
118
- screen.getByRole('button', { name: /force unlock/i })
119
- ).not.toBeDisabled();
137
+ await openTaskActionsMenu();
138
+ expect(screen.getByRole('menuitem', { name: /force unlock/i })).not.toBeDisabled();
120
139
  });
121
140
 
122
- it('renders parent task button when parentTask is provided', () => {
141
+ it('includes parent task overflow link when parentTask is provided', async () => {
123
142
  render(<TaskButtons {...defaultProps} parentTask="parent-123" />);
124
- const parentButton = screen.getByRole('link', { name: /parent task/i });
125
- expect(parentButton).toBeInTheDocument();
126
- expect(parentButton).toHaveAttribute(
127
- 'href',
128
- '/foreman_tasks/tasks/parent-123'
129
- );
143
+ await openTaskActionsMenu();
144
+ expect(
145
+ screen.getByRole('menuitem', { name: /parent task/i })
146
+ ).toHaveAttribute('href', '/foreman_tasks/tasks/parent-123');
130
147
  });
131
148
 
132
- it('does not render parent task button when parentTask is not provided', () => {
149
+ it('does not include parent overflow item when parentTask is not provided', async () => {
133
150
  render(<TaskButtons {...defaultProps} />);
151
+ await openTaskActionsMenu();
134
152
  expect(
135
- screen.queryByRole('link', { name: /parent task/i })
153
+ screen.queryByRole('menuitem', { name: /parent task/i })
136
154
  ).not.toBeInTheDocument();
137
155
  });
138
156
 
139
- it('renders sub tasks button when hasSubTasks is true', () => {
157
+ it('includes sub tasks overflow link when hasSubTasks is true', async () => {
140
158
  render(<TaskButtons {...defaultProps} hasSubTasks id="task-123" />);
141
- const subTasksButton = screen.getByRole('link', { name: /sub tasks/i });
142
- expect(subTasksButton).toBeInTheDocument();
143
- expect(subTasksButton).toHaveAttribute(
144
- 'href',
145
- '/foreman_tasks/tasks/task-123/sub_tasks'
146
- );
159
+ await openTaskActionsMenu();
160
+ expect(
161
+ screen.getByRole('menuitem', { name: /sub tasks/i })
162
+ ).toHaveAttribute('href', '/foreman_tasks/tasks/task-123/sub_tasks');
147
163
  });
148
164
 
149
- it('does not render sub tasks button when hasSubTasks is false', () => {
165
+ it('does not include sub tasks overflow item when hasSubTasks is false', async () => {
150
166
  render(<TaskButtons {...defaultProps} hasSubTasks={false} />);
167
+ await openTaskActionsMenu();
151
168
  expect(
152
- screen.queryByRole('link', { name: /sub tasks/i })
169
+ screen.queryByRole('menuitem', { name: /sub tasks/i })
153
170
  ).not.toBeInTheDocument();
154
171
  });
155
172
  });
173
+
156
174
  describe('user interactions', () => {
157
175
  const cancelTaskRequest = jest.fn();
158
176
  const resumeTaskRequest = jest.fn();
@@ -169,51 +187,47 @@ describe('TaskButtons', () => {
169
187
  resumeTaskRequest,
170
188
  taskProgressToggle,
171
189
  taskReloadStart,
172
- status: STATUS.RESOLVED,
173
190
  canEdit: true,
174
191
  resumable: true,
175
192
  cancellable: true,
176
193
  state: 'paused',
177
194
  };
178
195
 
179
- it('calls taskProgressToggle when reload button is clicked', () => {
196
+ it('calls taskProgressToggle when overflow reload item is clicked', async () => {
180
197
  render(<TaskButtons {...props} />);
181
- const reloadButton = screen.getByRole('button', {
182
- name: /start auto-reloading/i,
183
- });
184
- fireEvent.click(reloadButton);
198
+ await openTaskActionsMenu();
199
+ fireEvent.click(
200
+ screen.getByRole('menuitem', { name: /start auto-reloading/i })
201
+ );
185
202
  expect(taskProgressToggle).toHaveBeenCalled();
186
203
  });
187
204
 
188
- it('calls taskReloadStart and resumeTaskRequest when resume button is clicked', () => {
205
+ it('calls taskReloadStart and resumeTaskRequest when resume menu item is clicked', async () => {
189
206
  render(<TaskButtons {...props} />);
190
- const resumeButton = screen.getByRole('button', { name: /resume/i });
191
- fireEvent.click(resumeButton);
207
+ await openTaskActionsMenu();
208
+ fireEvent.click(screen.getByRole('menuitem', { name: /^resume$/i }));
192
209
  expect(taskReloadStart).toHaveBeenCalledWith(id);
193
210
  expect(resumeTaskRequest).toHaveBeenCalledWith(id, action);
194
211
  });
195
212
 
196
- it('calls taskReloadStart and cancelTaskRequest when cancel button is clicked', () => {
213
+ it('calls taskReloadStart and cancelTaskRequest when cancel button is clicked', async () => {
197
214
  render(<TaskButtons {...props} />);
198
- const cancelButton = screen.getByRole('button', { name: /cancel/i });
199
- fireEvent.click(cancelButton);
215
+ await userEvent.click(screen.getByRole('button', { name: /cancel/i }));
200
216
  expect(taskReloadStart).toHaveBeenCalledWith(id);
201
217
  expect(cancelTaskRequest).toHaveBeenCalledWith(id, action);
202
218
  });
203
219
 
204
- it('calls setUnlockModalOpen when unlock button is clicked', () => {
220
+ it('calls setUnlockModalOpen when unlock menu item is clicked', async () => {
205
221
  render(<TaskButtons {...props} />);
206
- const unlockButton = screen.getByRole('button', { name: /^unlock$/i });
207
- fireEvent.click(unlockButton);
222
+ await openTaskActionsMenu();
223
+ fireEvent.click(screen.getByRole('menuitem', { name: /^unlock$/i }));
208
224
  expect(setUnlockModalOpen).toHaveBeenCalledWith(true);
209
225
  });
210
226
 
211
- it('calls setForceUnlockModalOpen when force unlock button is clicked', () => {
227
+ it('calls setForceUnlockModalOpen when force unlock menu item is clicked', async () => {
212
228
  render(<TaskButtons {...props} />);
213
- const forceUnlockButton = screen.getByRole('button', {
214
- name: /force unlock/i,
215
- });
216
- fireEvent.click(forceUnlockButton);
229
+ await openTaskActionsMenu();
230
+ fireEvent.click(screen.getByRole('menuitem', { name: /force unlock/i }));
217
231
  expect(setForceUnlockModalOpen).toHaveBeenCalledWith(true);
218
232
  });
219
233
  });
@@ -1,4 +1,9 @@
1
- import { durationInWords } from '../TaskHelper';
1
+ import {
2
+ durationInWords,
3
+ formatTaskDuration,
4
+ isDelayed,
5
+ parseUsernameLinkHref,
6
+ } from '../TaskHelper';
2
7
 
3
8
  describe('durationInWords', () => {
4
9
  it('should work for seconds', () => {
@@ -20,3 +25,115 @@ describe('durationInWords', () => {
20
25
  });
21
26
  });
22
27
  });
28
+
29
+ describe('isDelayed', () => {
30
+ it('returns false when startAt is missing', () => {
31
+ expect(isDelayed({ startAt: null, startedAt: '2020-01-02' })).toBe(false);
32
+ expect(isDelayed({ startAt: '', startedAt: '2020-01-02' })).toBe(false);
33
+ expect(isDelayed({ startedAt: '2020-01-02' })).toBe(false);
34
+ });
35
+
36
+ it('returns false when startedAt is missing', () => {
37
+ expect(isDelayed({ startAt: '2020-01-01', startedAt: null })).toBe(false);
38
+ expect(isDelayed({ startAt: '2020-01-01', startedAt: '' })).toBe(false);
39
+ expect(isDelayed({ startAt: '2020-01-01' })).toBe(false);
40
+ });
41
+
42
+ it('returns false when either date is invalid', () => {
43
+ expect(
44
+ isDelayed({ startAt: 'not-a-date', startedAt: '2020-01-02' })
45
+ ).toBe(false);
46
+ expect(
47
+ isDelayed({ startAt: '2020-01-01', startedAt: 'not-a-date' })
48
+ ).toBe(false);
49
+ });
50
+
51
+ it('returns false when startAt and startedAt are the same instant', () => {
52
+ expect(
53
+ isDelayed({ startAt: '2020-01-01T10:00:00', startedAt: '2020-01-01T10:00:00' })
54
+ ).toBe(false);
55
+ });
56
+
57
+ it('ignores millisecond differences when comparing startAt and startedAt', () => {
58
+ expect(
59
+ isDelayed({
60
+ startAt: '2020-01-01T10:00:00.123',
61
+ startedAt: '2020-01-01T10:00:00.456',
62
+ })
63
+ ).toBe(false);
64
+ });
65
+
66
+ it('returns true when startAt and startedAt differ', () => {
67
+ expect(
68
+ isDelayed({ startAt: '2020-01-01', startedAt: '2020-01-02' })
69
+ ).toBe(true);
70
+ });
71
+ });
72
+
73
+ describe('parseUsernameLinkHref', () => {
74
+ it('returns null for empty or non-string input', () => {
75
+ expect(parseUsernameLinkHref(null)).toBeNull();
76
+ expect(parseUsernameLinkHref(undefined)).toBeNull();
77
+ expect(parseUsernameLinkHref('')).toBeNull();
78
+ expect(parseUsernameLinkHref(123)).toBeNull();
79
+ });
80
+
81
+ it('extracts href from double-quoted attribute values', () => {
82
+ expect(
83
+ parseUsernameLinkHref('<a href="/users/bob">Bob</a>')
84
+ ).toBe('/users/bob');
85
+ });
86
+
87
+ it('extracts href from single-quoted attribute values', () => {
88
+ expect(
89
+ parseUsernameLinkHref("<a href='/users/bob'>Bob</a>")
90
+ ).toBe('/users/bob');
91
+ });
92
+
93
+ it('returns null when no href attribute is present', () => {
94
+ expect(parseUsernameLinkHref('<a>Bob</a>')).toBeNull();
95
+ });
96
+ });
97
+
98
+ describe('formatTaskDuration', () => {
99
+ it('returns N/A when the task is not stopped', () => {
100
+ expect(
101
+ formatTaskDuration('2020-01-01T10:00:00', '2020-01-01T10:00:01', 'running')
102
+ ).toBe('N/A');
103
+ });
104
+
105
+ it('returns N/A when start or end time is missing', () => {
106
+ expect(formatTaskDuration('', '2020-01-01T10:00:01', 'stopped')).toBe(
107
+ 'N/A'
108
+ );
109
+ expect(formatTaskDuration('2020-01-01T10:00:00', '', 'stopped')).toBe(
110
+ 'N/A'
111
+ );
112
+ });
113
+
114
+ it('returns N/A when dates are invalid or end precedes start', () => {
115
+ expect(
116
+ formatTaskDuration('not-a-date', '2020-01-01T10:00:01', 'stopped')
117
+ ).toBe('N/A');
118
+ expect(
119
+ formatTaskDuration('2020-01-01T10:00:00', 'not-a-date', 'stopped')
120
+ ).toBe('N/A');
121
+ expect(
122
+ formatTaskDuration(
123
+ '2020-01-01T10:00:01',
124
+ '2020-01-01T10:00:00',
125
+ 'stopped'
126
+ )
127
+ ).toBe('N/A');
128
+ });
129
+
130
+ it('returns a humanized duration for stopped tasks with valid timestamps', () => {
131
+ expect(
132
+ formatTaskDuration(
133
+ '1/1/1 10:00:00',
134
+ '1/1/1 10:00:01',
135
+ 'stopped'
136
+ )
137
+ ).toBe('1 second');
138
+ });
139
+ });
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
3
4
  import '@testing-library/jest-dom';
4
5
 
5
6
  jest.mock('foremanReact/components/common/dates/RelativeDateTime', () => {
@@ -10,41 +11,210 @@ jest.mock('foremanReact/components/common/dates/RelativeDateTime', () => {
10
11
  });
11
12
 
12
13
  import TaskInfo from '../TaskInfo';
14
+ import { durationInWords } from '../TaskHelper';
13
15
 
14
16
  describe('TaskInfo', () => {
15
- it('renders default metadata labels when given minimal props', () => {
16
- render(<TaskInfo id="test" />);
17
- expect(screen.getByText(/name:/i)).toBeInTheDocument();
18
- expect(screen.getByText(/start at:/i)).toBeInTheDocument();
19
- expect(screen.getByText(/result:/i)).toBeInTheDocument();
17
+ afterEach(() => {
18
+ jest.clearAllMocks();
20
19
  });
21
20
 
22
- it('renders task fields and troubleshooting when full props are provided', () => {
21
+ it('renders overview metadata headings for minimal props', () => {
22
+ render(<TaskInfo id="tid" />);
23
+
24
+ expect(screen.getByText('Result')).toBeInTheDocument();
25
+ expect(screen.getByText('State')).toBeInTheDocument();
26
+ expect(screen.getByText('Started at')).toBeInTheDocument();
27
+ expect(screen.getByText('Duration')).toBeInTheDocument();
28
+ expect(screen.getByText('Triggered by')).toBeInTheDocument();
29
+ expect(screen.getByText('Id')).toBeInTheDocument();
30
+
31
+ expect(
32
+ screen.getByRole('button', { name: /show more details/i })
33
+ ).toBeInTheDocument();
34
+ expect(screen.getByText('tid')).toBeInTheDocument();
35
+ expect(screen.getByText(/^Error$/)).toBeInTheDocument();
36
+ });
37
+
38
+ it('shows progress only while task state is not stopped', () => {
39
+ const props = {
40
+ id: 'r1',
41
+ state: 'running',
42
+ result: 'pending',
43
+ progress: 42,
44
+ startedAt: '',
45
+ usernamePath: '',
46
+ };
47
+
48
+ const { rerender } = render(<TaskInfo {...props} />);
49
+ expect(screen.getByRole('progressbar')).toBeInTheDocument();
50
+ expect(screen.getByRole('progressbar')).toHaveAttribute(
51
+ 'aria-valuenow',
52
+ '42'
53
+ );
54
+
55
+ rerender(<TaskInfo {...props} state="stopped" />);
56
+ expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
57
+ });
58
+
59
+ it('expands to show label, execution type, start at, ended at, and external id', async () => {
23
60
  render(
24
61
  <TaskInfo
25
- id="test"
26
- startAt="2019-06-17 16:04:09 +0300"
62
+ id="tid"
27
63
  label="Actions::Katello::EventQueue::Monitor"
28
64
  action="Monitor Event Queue"
29
- username="admin"
65
+ externalId="ext-42"
66
+ startAt="2019-06-17 16:04:09 +0300"
30
67
  startedAt="2019-06-17 16:04:09 +0300"
31
- endedAt={null}
68
+ endedAt=""
32
69
  state="paused"
33
70
  result="error"
34
- progress={0.5}
35
- help={
36
- "A paused task represents a process that has not finished properly. Any task in paused state can lead to potential inconsistency and needs to be resolved.\nThe recommended approach is to investigate the error messages below and in 'errors' tab, address the primary cause of the issue and resume the task."
37
- }
38
- output={{}}
71
+ progress={1}
72
+ username="admin"
39
73
  usernamePath=""
40
74
  />
41
75
  );
42
- expect(screen.getByText('Monitor Event Queue')).toBeInTheDocument();
43
- expect(screen.getByText(/troubleshooting/i)).toBeInTheDocument();
76
+
77
+ await userEvent.click(
78
+ screen.getByRole('button', { name: /show more details/i })
79
+ );
80
+
81
+ expect(screen.getByText('Execution type')).toBeInTheDocument();
82
+ expect(screen.getByText('Label')).toBeInTheDocument();
83
+ expect(screen.getByText('Start at')).toBeInTheDocument();
84
+ expect(screen.getByText('Ended at')).toBeInTheDocument();
85
+ expect(screen.getByText('External Id')).toBeInTheDocument();
86
+ expect(
87
+ screen.getByText('Actions::Katello::EventQueue::Monitor')
88
+ ).toBeInTheDocument();
89
+ expect(screen.getByText('ext-42')).toBeInTheDocument();
90
+ expect(screen.getByText(/^Error$/)).toBeInTheDocument();
91
+ expect(screen.getAllByText(/^Paused$/).length).toBeGreaterThanOrEqual(1);
92
+ expect(screen.getByText('1% Complete')).toBeInTheDocument();
93
+ });
94
+
95
+ it('reports delayed execution type when startAt and startedAt differ', async () => {
96
+ render(
97
+ <TaskInfo
98
+ id=""
99
+ state="stopped"
100
+ startAt="2020-01-01"
101
+ startedAt="2020-01-02"
102
+ />
103
+ );
104
+
105
+ await userEvent.click(
106
+ screen.getByRole('button', { name: /show more details/i })
107
+ );
108
+
109
+ expect(screen.getByText('Delayed')).toBeInTheDocument();
110
+ });
111
+
112
+ it('reports immediate execution type when startAt is missing', async () => {
113
+ render(<TaskInfo id="" state="stopped" startedAt="2020-01-02" />);
114
+
115
+ await userEvent.click(
116
+ screen.getByRole('button', { name: /show more details/i })
117
+ );
118
+
119
+ expect(screen.getByText('Immediate')).toBeInTheDocument();
120
+ });
121
+
122
+ it('renders triggered-by link when usernamePath embeds an href', () => {
123
+ render(
124
+ <TaskInfo
125
+ id=""
126
+ username="bob"
127
+ usernamePath='<a href="/users/bob">Bob</a>'
128
+ state=""
129
+ />
130
+ );
131
+
132
+ const link = screen.getByRole('link', { name: 'bob' });
133
+ expect(link).toHaveAttribute('href', '/users/bob');
134
+ });
135
+
136
+ it('renders triggered-by link when usernamePath uses single-quoted href', () => {
137
+ render(
138
+ <TaskInfo
139
+ id=""
140
+ username="bob"
141
+ usernamePath="<a href='/users/bob'>Bob</a>"
142
+ state=""
143
+ />
144
+ );
145
+
146
+ const link = screen.getByRole('link', { name: 'bob' });
147
+ expect(link).toHaveAttribute('href', '/users/bob');
148
+ });
149
+
150
+ it('formats stop duration when started and ended are valid', () => {
151
+ const startedAt = '2020-01-01T10:00:00.000Z';
152
+ const endedAt = '2020-01-01T10:00:45.000Z';
153
+
154
+ render(<TaskInfo id="" state="stopped" startedAt={startedAt} endedAt={endedAt} />);
155
+
44
156
  expect(
45
- screen.getByText(/a paused task represents a process/i)
157
+ screen.getByText(durationInWords(startedAt, endedAt).text)
46
158
  ).toBeInTheDocument();
47
- expect(screen.getByText(/^paused$/)).toBeInTheDocument();
48
- expect(screen.getByText(/^error$/)).toBeInTheDocument();
159
+ });
160
+
161
+ it('shows start before deadline when startBefore is set', async () => {
162
+ render(
163
+ <TaskInfo
164
+ id="tid"
165
+ state="stopped"
166
+ startAt="2020-01-01"
167
+ startBefore="2020-01-05"
168
+ />
169
+ );
170
+
171
+ await userEvent.click(
172
+ screen.getByRole('button', { name: /show more details/i })
173
+ );
174
+
175
+ expect(screen.getByText('2020-01-01')).toBeInTheDocument();
176
+ expect(screen.getByText(/before/i)).toBeInTheDocument();
177
+ expect(screen.getByText('2020-01-05')).toBeInTheDocument();
178
+ });
179
+
180
+ it('does not show start before text when startBefore is missing', async () => {
181
+ render(<TaskInfo id="tid" state="stopped" startAt="2020-01-01" />);
182
+
183
+ await userEvent.click(
184
+ screen.getByRole('button', { name: /show more details/i })
185
+ );
186
+
187
+ expect(screen.getByText('2020-01-01')).toBeInTheDocument();
188
+ expect(screen.queryByText(/before/i)).not.toBeInTheDocument();
189
+ });
190
+
191
+ it('shows troubleshooting help when expanded and help is provided', async () => {
192
+ render(
193
+ <TaskInfo
194
+ id="tid"
195
+ state="paused"
196
+ help="Investigate the error and <a href='/docs'>troubleshooting documentation</a>."
197
+ />
198
+ );
199
+
200
+ await userEvent.click(
201
+ screen.getByRole('button', { name: /show more details/i })
202
+ );
203
+
204
+ expect(screen.getByText('Troubleshooting')).toBeInTheDocument();
205
+ expect(screen.getByText(/Investigate the error/i)).toBeInTheDocument();
206
+ expect(
207
+ screen.getByRole('link', { name: 'troubleshooting documentation' })
208
+ ).toHaveAttribute('href', '/docs');
209
+ });
210
+
211
+ it('does not show troubleshooting help when help is empty', async () => {
212
+ render(<TaskInfo id="tid" state="paused" help="" />);
213
+
214
+ await userEvent.click(
215
+ screen.getByRole('button', { name: /show more details/i })
216
+ );
217
+
218
+ expect(screen.queryByText('Troubleshooting')).not.toBeInTheDocument();
49
219
  });
50
220
  });