foreman_remote_execution 18.0.0 → 18.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/foreman_remote_execution/locale/de/foreman_remote_execution.js +54 -9
  3. data/app/assets/javascripts/foreman_remote_execution/locale/en_GB/foreman_remote_execution.js +54 -9
  4. data/app/assets/javascripts/foreman_remote_execution/locale/es/foreman_remote_execution.js +54 -9
  5. data/app/assets/javascripts/foreman_remote_execution/locale/fr/foreman_remote_execution.js +66 -21
  6. data/app/assets/javascripts/foreman_remote_execution/locale/ja/foreman_remote_execution.js +86 -41
  7. data/app/assets/javascripts/foreman_remote_execution/locale/ka/foreman_remote_execution.js +111 -66
  8. data/app/assets/javascripts/foreman_remote_execution/locale/ko/foreman_remote_execution.js +67 -22
  9. data/app/assets/javascripts/foreman_remote_execution/locale/pt_BR/foreman_remote_execution.js +54 -9
  10. data/app/assets/javascripts/foreman_remote_execution/locale/ru/foreman_remote_execution.js +54 -9
  11. data/app/assets/javascripts/foreman_remote_execution/locale/zh_CN/foreman_remote_execution.js +60 -15
  12. data/app/assets/javascripts/foreman_remote_execution/locale/zh_TW/foreman_remote_execution.js +54 -9
  13. data/app/assets/javascripts/foreman_remote_execution/template_invocation.js +0 -170
  14. data/app/assets/stylesheets/foreman_remote_execution/job_invocations.scss +0 -6
  15. data/app/controllers/job_invocations_controller.rb +1 -98
  16. data/app/helpers/job_invocations_helper.rb +0 -49
  17. data/app/helpers/remote_execution_helper.rb +23 -123
  18. data/app/models/remote_execution_provider.rb +1 -1
  19. data/config/routes.rb +0 -6
  20. data/lib/foreman_remote_execution/plugin.rb +2 -2
  21. data/lib/foreman_remote_execution/version.rb +1 -1
  22. data/locale/de/LC_MESSAGES/foreman_remote_execution.mo +0 -0
  23. data/locale/de/foreman_remote_execution.po +54 -9
  24. data/locale/en_GB/LC_MESSAGES/foreman_remote_execution.mo +0 -0
  25. data/locale/en_GB/foreman_remote_execution.po +54 -9
  26. data/locale/es/LC_MESSAGES/foreman_remote_execution.mo +0 -0
  27. data/locale/es/foreman_remote_execution.po +54 -9
  28. data/locale/foreman_remote_execution.pot +296 -230
  29. data/locale/fr/LC_MESSAGES/foreman_remote_execution.mo +0 -0
  30. data/locale/fr/foreman_remote_execution.po +66 -21
  31. data/locale/ja/LC_MESSAGES/foreman_remote_execution.mo +0 -0
  32. data/locale/ja/foreman_remote_execution.po +86 -41
  33. data/locale/ka/LC_MESSAGES/foreman_remote_execution.mo +0 -0
  34. data/locale/ka/foreman_remote_execution.po +111 -66
  35. data/locale/ko/LC_MESSAGES/foreman_remote_execution.mo +0 -0
  36. data/locale/ko/foreman_remote_execution.po +67 -22
  37. data/locale/pt_BR/LC_MESSAGES/foreman_remote_execution.mo +0 -0
  38. data/locale/pt_BR/foreman_remote_execution.po +54 -9
  39. data/locale/ru/LC_MESSAGES/foreman_remote_execution.mo +0 -0
  40. data/locale/ru/foreman_remote_execution.po +54 -9
  41. data/locale/zh_CN/LC_MESSAGES/foreman_remote_execution.mo +0 -0
  42. data/locale/zh_CN/foreman_remote_execution.po +60 -15
  43. data/locale/zh_TW/LC_MESSAGES/foreman_remote_execution.mo +0 -0
  44. data/locale/zh_TW/foreman_remote_execution.po +54 -9
  45. data/package.json +1 -14
  46. data/test/functional/job_invocations_controller_test.rb +0 -77
  47. data/test/unit/remote_execution_provider_test.rb +1 -1
  48. data/webpack/JobInvocationDetail/JobInvocationConstants.js +5 -0
  49. data/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js +0 -10
  50. data/webpack/JobInvocationDetail/TemplateInvocation.js +12 -69
  51. data/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputCodeBlock.js +94 -72
  52. data/webpack/JobInvocationDetail/TemplateInvocationHelpers.js +33 -0
  53. data/webpack/JobInvocationDetail/__tests__/JobInvocationDetailEmptyState.test.js +1 -1
  54. data/webpack/JobInvocationDetail/__tests__/MainInformation.test.js +0 -6
  55. data/webpack/JobInvocationDetail/__tests__/OutputCodeBlock.test.js +1 -1
  56. data/webpack/JobInvocationDetail/__tests__/TableToolbarActions.test.js +1 -1
  57. data/webpack/JobInvocationDetail/__tests__/TemplateInvocation.test.js +5 -1
  58. data/webpack/JobInvocationDetail/__tests__/TemplateInvocationHelpers.test.js +94 -0
  59. data/webpack/JobInvocationDetail/__tests__/TemplateInvocationPolling.test.js +338 -139
  60. data/webpack/JobInvocationDetail/useTemplateInvocationOutputPolling.js +185 -0
  61. data/webpack/JobWizard/JobWizardPageRerun.js +0 -10
  62. data/webpack/JobWizard/__tests__/autofill.test.js +23 -0
  63. data/webpack/JobWizard/__tests__/integration.test.js +1 -1
  64. data/webpack/JobWizard/autofill.js +18 -4
  65. data/webpack/JobWizard/index.js +1 -18
  66. data/webpack/JobWizard/steps/HostsAndInputs/__tests__/HostsAndInputs.test.js +49 -1
  67. data/webpack/global_index.js +0 -2
  68. data/webpack/index.js +0 -15
  69. data/webpack/react_app/components/RegistrationExtension/__tests__/RexInterface.test.js +1 -1
  70. metadata +6 -53
  71. data/app/assets/stylesheets/foreman_remote_execution/modal_window.scss +0 -4
  72. data/app/helpers/job_invocations_chart_helper.rb +0 -80
  73. data/app/views/job_invocations/_card_results.html.erb +0 -12
  74. data/app/views/job_invocations/_card_schedule.html.erb +0 -33
  75. data/app/views/job_invocations/_card_target_hosts.html.erb +0 -57
  76. data/app/views/job_invocations/_card_user_input.html.erb +0 -16
  77. data/app/views/job_invocations/_description_fields.html.erb +0 -4
  78. data/app/views/job_invocations/_form.html.erb +0 -144
  79. data/app/views/job_invocations/_preview_hosts_modal.html.erb +0 -15
  80. data/app/views/job_invocations/_rerun_taxonomies.html.erb +0 -22
  81. data/app/views/job_invocations/_tab_hosts.html.erb +0 -11
  82. data/app/views/job_invocations/_tab_overview.html.erb +0 -41
  83. data/app/views/job_invocations/_tab_preview_templates.html.erb +0 -20
  84. data/app/views/job_invocations/_user_input.html.erb +0 -21
  85. data/app/views/job_invocations/new.html.erb +0 -9
  86. data/app/views/job_invocations/refresh.js.erb +0 -4
  87. data/app/views/job_invocations/show.html.erb +0 -59
  88. data/app/views/job_invocations/show.js.erb +0 -12
  89. data/app/views/job_invocations/show.json.erb +0 -5
  90. data/test/support/remote_execution_helper.rb +0 -5
  91. data/webpack/react_app/components/TargetingHosts/TargetingHosts.js +0 -62
  92. data/webpack/react_app/components/TargetingHosts/TargetingHostsConsts.js +0 -2
  93. data/webpack/react_app/components/TargetingHosts/TargetingHostsHelpers.js +0 -10
  94. data/webpack/react_app/components/TargetingHosts/TargetingHostsLabelsRow.js +0 -45
  95. data/webpack/react_app/components/TargetingHosts/TargetingHostsLabelsRow.scss +0 -30
  96. data/webpack/react_app/components/TargetingHosts/TargetingHostsPage.js +0 -78
  97. data/webpack/react_app/components/TargetingHosts/TargetingHostsPage.scss +0 -3
  98. data/webpack/react_app/components/TargetingHosts/TargetingHostsSelectors.js +0 -25
  99. data/webpack/react_app/components/TargetingHosts/__tests__/HostItem.test.js +0 -76
  100. data/webpack/react_app/components/TargetingHosts/__tests__/HostStatus.test.js +0 -6
  101. data/webpack/react_app/components/TargetingHosts/__tests__/TargetingHosts.test.js +0 -6
  102. data/webpack/react_app/components/TargetingHosts/__tests__/TargetingHostsPage.test.js +0 -9
  103. data/webpack/react_app/components/TargetingHosts/__tests__/TargetingHostsSelectors.test.js +0 -31
  104. data/webpack/react_app/components/TargetingHosts/__tests__/__snapshots__/HostStatus.test.js.snap +0 -12
  105. data/webpack/react_app/components/TargetingHosts/__tests__/__snapshots__/TargetingHosts.test.js.snap +0 -96
  106. data/webpack/react_app/components/TargetingHosts/__tests__/__snapshots__/TargetingHostsPage.test.js.snap +0 -75
  107. data/webpack/react_app/components/TargetingHosts/__tests__/__snapshots__/TargetingHostsSelectors.test.js.snap +0 -16
  108. data/webpack/react_app/components/TargetingHosts/__tests__/fixtures.js +0 -56
  109. data/webpack/react_app/components/TargetingHosts/components/HostItem.js +0 -39
  110. data/webpack/react_app/components/TargetingHosts/components/HostStatus.js +0 -54
  111. data/webpack/react_app/components/TargetingHosts/index.js +0 -150
  112. data/webpack/react_app/components/jobInvocations/AggregateStatus/index.js +0 -60
  113. data/webpack/react_app/components/jobInvocations/AggregateStatus/index.test.js +0 -76
  114. data/webpack/react_app/components/jobInvocations/index.js +0 -94
  115. data/webpack/react_app/extend/reducers.js +0 -5
  116. data/webpack/react_app/redux/actions/jobInvocations/index.js +0 -87
  117. data/webpack/react_app/redux/consts.js +0 -5
  118. data/webpack/react_app/redux/reducers/index.js +0 -6
  119. data/webpack/react_app/redux/reducers/jobInvocations/index.fixtures.js +0 -46
  120. data/webpack/react_app/redux/reducers/jobInvocations/index.js +0 -34
  121. data/webpack/react_app/redux/reducers/jobInvocations/index.test.js +0 -43
@@ -1,17 +1,13 @@
1
- import React, { useEffect, useRef } from 'react';
1
+ import React from 'react';
2
2
  import { isEmpty } from 'lodash';
3
3
  import PropTypes from 'prop-types';
4
4
  import { ClipboardCopyButton, Alert, Skeleton } from '@patternfly/react-core';
5
- import { useDispatch, useSelector } from 'react-redux';
6
- import { APIActions } from 'foremanReact/redux/API';
5
+ import { useSelector } from 'react-redux';
7
6
  import { translate as __ } from 'foremanReact/common/I18n';
8
7
  import { useForemanHostDetailsPageUrl } from 'foremanReact/Root/Context/ForemanContext';
9
8
  import { STATUS } from 'foremanReact/constants';
10
9
  import {
11
- showTemplateInvocationUrl,
12
10
  templateInvocationPageUrl,
13
- GET_TEMPLATE_INVOCATION,
14
- AUTO_REFRESH_INTERVAL_MS,
15
11
  CLIPBOARD_COPIED_EXIT_DELAY_MS,
16
12
  CLIPBOARD_DEFAULT_EXIT_DELAY_MS,
17
13
  } from './JobInvocationConstants';
@@ -19,6 +15,7 @@ import {
19
15
  selectTemplateInvocationStatus,
20
16
  selectTemplateInvocation,
21
17
  } from './JobInvocationSelectors';
18
+ import { useTemplateInvocationOutputPolling } from './useTemplateInvocationOutputPolling';
22
19
  import { OutputToggleGroup } from './TemplateInvocationComponents/OutputToggleGroup';
23
20
  import { PreviewTemplate } from './TemplateInvocationComponents/PreviewTemplate';
24
21
  import { OutputCodeBlock } from './TemplateInvocationComponents/OutputCodeBlock';
@@ -74,64 +71,16 @@ export const TemplateInvocation = ({
74
71
  showCommand,
75
72
  setShowCommand,
76
73
  }) => {
77
- const timeoutRef = useRef(null);
78
- const templateURL = showTemplateInvocationUrl(hostID, jobID);
79
74
  const hostDetailsPageUrl = useForemanHostDetailsPageUrl();
80
75
 
81
76
  const status = useSelector(selectTemplateInvocationStatus(hostID));
82
77
  const response = useSelector(selectTemplateInvocation(hostID));
83
- const dispatch = useDispatch();
84
-
85
- const responseRef = useRef(response);
86
- useEffect(() => {
87
- responseRef.current = response;
88
- }, [response]);
89
-
90
- useEffect(() => {
91
- let cancelled = false;
92
-
93
- const schedulePoll = () => {
94
- if (cancelled) return;
95
- dispatch(
96
- APIActions.get({
97
- url: templateURL,
98
- key: `${GET_TEMPLATE_INVOCATION}_${hostID}`,
99
- handleSuccess: ({ data }) => {
100
- if (cancelled) return;
101
- const isFinished = data?.finished ?? true;
102
- // eslint-disable-next-line camelcase
103
- const autoRefresh = data?.auto_refresh || false;
104
- if (!isFinished && autoRefresh) {
105
- timeoutRef.current = setTimeout(
106
- schedulePoll,
107
- AUTO_REFRESH_INTERVAL_MS
108
- );
109
- } else {
110
- timeoutRef.current = null;
111
- }
112
- },
113
- handleError: () => {
114
- if (cancelled) return;
115
- timeoutRef.current = null;
116
- },
117
- })
118
- );
119
- };
120
-
121
- clearTimeout(timeoutRef.current);
122
- timeoutRef.current = null;
123
-
124
- if (isExpanded) {
125
- if (responseRef.current?.finished) return undefined;
126
- schedulePoll();
127
- }
128
-
129
- return () => {
130
- cancelled = true;
131
- clearTimeout(timeoutRef.current);
132
- timeoutRef.current = null;
133
- };
134
- }, [isExpanded, dispatch, templateURL, hostID]);
78
+ const liveOutput = useTemplateInvocationOutputPolling({
79
+ hostID,
80
+ jobID,
81
+ isExpanded,
82
+ response,
83
+ });
135
84
 
136
85
  if (!response || (status === STATUS.PENDING && isEmpty(response))) {
137
86
  return <Skeleton data-testid="template-invocation-skeleton" />;
@@ -156,13 +105,7 @@ export const TemplateInvocation = ({
156
105
  );
157
106
  }
158
107
 
159
- const {
160
- preview,
161
- output,
162
- input_values: inputValues,
163
- task,
164
- permissions,
165
- } = response;
108
+ const { preview, input_values: inputValues, task, permissions } = response;
166
109
  const { id: taskID, cancellable: taskCancellable } = task || {};
167
110
 
168
111
  return (
@@ -183,7 +126,7 @@ export const TemplateInvocation = ({
183
126
  isInTableView={isInTableView}
184
127
  copyToClipboard={
185
128
  <CopyToClipboard
186
- fullOutput={output
129
+ fullOutput={liveOutput
187
130
  ?.filter(
188
131
  ({ output_type: outputType }) => showOutputType[outputType]
189
132
  )
@@ -226,7 +169,7 @@ export const TemplateInvocation = ({
226
169
  </>
227
170
  )}
228
171
  <OutputCodeBlock
229
- code={output || []}
172
+ code={liveOutput}
230
173
  showOutputType={showOutputType}
231
174
  scrollElement={
232
175
  isInTableView
@@ -9,58 +9,87 @@ import { Button } from '@patternfly/react-core';
9
9
  import { translate as __ } from 'foremanReact/common/I18n';
10
10
  import { MS_PER_SECOND } from 'foremanReact/constants';
11
11
 
12
+ // eslint-disable-next-line no-control-regex
13
+ const COLOR_PATTERN = /\x1b\[[\d;]*m/g;
14
+ const CONSOLE_COLOR = {
15
+ '31': 'red',
16
+ '32': 'lightgreen',
17
+ '33': 'orange',
18
+ '34': 'deepskyblue',
19
+ '35': 'mediumpurple',
20
+ '36': 'cyan',
21
+ '37': 'grey',
22
+ '91': 'red',
23
+ '92': 'lightgreen',
24
+ '93': 'yellow',
25
+ '94': 'lightblue',
26
+ '95': 'violet',
27
+ '96': 'turquoise',
28
+ '0': 'default',
29
+ '39': 'default',
30
+ };
31
+ const parsedOutput = new WeakMap();
32
+
33
+ const colorizeLine = line => {
34
+ line = line.replace(COLOR_PATTERN, seq => {
35
+ const codes = seq.match(/(\d+)/g) || [];
36
+ const lastColorCode =
37
+ [...codes].reverse().find(code_ => code_ in CONSOLE_COLOR) || '0';
38
+ return `{{{format color:${lastColorCode}}}}`;
39
+ });
40
+
41
+ let currentColor = 'default';
42
+ const parts = line.split(/({{{format.*?}}})/).filter(Boolean);
43
+ if (parts.length === 0) return <span>{'\n'}</span>;
44
+
45
+ // eslint-disable-next-line array-callback-return, consistent-return
46
+ return parts.map((consoleLine, index) => {
47
+ if (consoleLine.includes('{{{format')) {
48
+ const colorMatch = consoleLine.match(/color:(\d+)/);
49
+ if (colorMatch) {
50
+ const colorIndex = colorMatch[1];
51
+ currentColor = CONSOLE_COLOR[colorIndex] || 'default';
52
+ }
53
+ } else {
54
+ return (
55
+ <span key={index} style={{ color: currentColor }}>
56
+ {consoleLine.length ? consoleLine : '\n'}
57
+ </span>
58
+ );
59
+ }
60
+ });
61
+ };
62
+
63
+ const getLineOutputs = lineSet => {
64
+ if (parsedOutput.has(lineSet)) return parsedOutput.get(lineSet);
65
+
66
+ const lineOutputs =
67
+ lineSet.output === '\n'
68
+ ? []
69
+ : lineSet.output
70
+ .replace(/\r\n/g, '\n')
71
+ .replace(/\n$/, '')
72
+ .split('\n');
73
+ parsedOutput.set(lineSet, lineOutputs);
74
+ return lineOutputs;
75
+ };
76
+
77
+ const OutputLineSet = React.memo(({ lineSet, startLine }) =>
78
+ getLineOutputs(lineSet).map((lineOutput, index) => (
79
+ <div key={index} className={`line ${lineSet.output_type}`}>
80
+ <span
81
+ className="counter"
82
+ title={new Date(lineSet.timestamp * MS_PER_SECOND).toISOString()}
83
+ >
84
+ {(startLine + index).toString().padStart(4, '\u00A0')}:{' '}
85
+ </span>
86
+ <div className="content">{colorizeLine(lineOutput)}</div>
87
+ </div>
88
+ ))
89
+ );
90
+
12
91
  export const OutputCodeBlock = ({ code, showOutputType, scrollElement }) => {
13
92
  let lineCounter = 0;
14
- // eslint-disable-next-line no-control-regex
15
- const COLOR_PATTERN = /\x1b\[[\d;]*m/g;
16
- const CONSOLE_COLOR = {
17
- '31': 'red',
18
- '32': 'lightgreen',
19
- '33': 'orange',
20
- '34': 'deepskyblue',
21
- '35': 'mediumpurple',
22
- '36': 'cyan',
23
- '37': 'grey',
24
- '91': 'red',
25
- '92': 'lightgreen',
26
- '93': 'yellow',
27
- '94': 'lightblue',
28
- '95': 'violet',
29
- '96': 'turquoise',
30
- '0': 'default',
31
- '39': 'default',
32
- };
33
-
34
- const colorizeLine = line => {
35
- line = line.replace(COLOR_PATTERN, seq => {
36
- const codes = seq.match(/(\d+)/g) || [];
37
- const lastColorCode =
38
- [...codes].reverse().find(code_ => code_ in CONSOLE_COLOR) || '0';
39
- return `{{{format color:${lastColorCode}}}}`;
40
- });
41
-
42
- let currentColor = 'default';
43
- const parts = line.split(/({{{format.*?}}})/).filter(Boolean);
44
- if (parts.length === 0) {
45
- return <span>{'\n'}</span>;
46
- }
47
- // eslint-disable-next-line array-callback-return, consistent-return
48
- return parts.map((consoleLine, index) => {
49
- if (consoleLine.includes('{{{format')) {
50
- const colorMatch = consoleLine.match(/color:(\d+)/);
51
- if (colorMatch) {
52
- const colorIndex = colorMatch[1];
53
- currentColor = CONSOLE_COLOR[colorIndex] || 'default';
54
- }
55
- } else {
56
- return (
57
- <span key={index} style={{ color: currentColor }}>
58
- {consoleLine.length ? consoleLine : '\n'}
59
- </span>
60
- );
61
- }
62
- });
63
- };
64
93
  const filteredCode = code.filter(
65
94
  ({ output_type: outputType }) => showOutputType[outputType]
66
95
  );
@@ -109,28 +138,16 @@ export const OutputCodeBlock = ({ code, showOutputType, scrollElement }) => {
109
138
  if (!filteredCode.length) {
110
139
  return <div>{__('No output for the selected filters')}</div>;
111
140
  }
112
- const codeParse = filteredCode.map(line => {
113
- if (line.output === '\n') {
114
- return null;
115
- }
116
- const lineOutputs = line.output
117
- .replace(/\r\n/g, '\n')
118
- .replace(/\n$/, '')
119
- .split('\n');
120
- return lineOutputs.map((lineOutput, index) => {
121
- lineCounter += 1;
122
- return (
123
- <div key={index} className={`line ${line.output_type}`}>
124
- <span
125
- className="counter"
126
- title={new Date(line.timestamp * MS_PER_SECOND).toISOString()}
127
- >
128
- {lineCounter.toString().padStart(4, '\u00A0')}:{' '}
129
- </span>
130
- <div className="content">{colorizeLine(lineOutput)}</div>
131
- </div>
132
- );
133
- });
141
+ const codeParse = filteredCode.map((lineSet, index) => {
142
+ const startLine = lineCounter + 1;
143
+ lineCounter += getLineOutputs(lineSet).length;
144
+ return (
145
+ <OutputLineSet
146
+ key={`${lineSet.timestamp}-${index}`}
147
+ lineSet={lineSet}
148
+ startLine={startLine}
149
+ />
150
+ );
134
151
  });
135
152
 
136
153
  return (
@@ -167,3 +184,8 @@ OutputCodeBlock.propTypes = {
167
184
  showOutputType: PropTypes.object.isRequired,
168
185
  scrollElement: PropTypes.string.isRequired,
169
186
  };
187
+
188
+ OutputLineSet.propTypes = {
189
+ lineSet: PropTypes.object.isRequired,
190
+ startLine: PropTypes.number.isRequired,
191
+ };
@@ -0,0 +1,33 @@
1
+ export const getLastOutputTimestamp = output =>
2
+ output.length ? output[output.length - 1].timestamp : null;
3
+
4
+ export const normalizeOutput = output =>
5
+ output.reduce((normalized, lineSet) => {
6
+ const previousLineSet = normalized[normalized.length - 1];
7
+
8
+ if (
9
+ previousLineSet &&
10
+ previousLineSet.output_type === lineSet.output_type &&
11
+ !previousLineSet.output.endsWith('\n')
12
+ ) {
13
+ const firstCompleteLine = lineSet.output.match(/^[^\n]*\n/);
14
+ if (firstCompleteLine) {
15
+ normalized[normalized.length - 1] = {
16
+ ...previousLineSet,
17
+ output: previousLineSet.output + firstCompleteLine[0],
18
+ };
19
+
20
+ const remainder = lineSet.output.slice(firstCompleteLine[0].length);
21
+ if (remainder) normalized.push({ ...lineSet, output: remainder });
22
+ return normalized;
23
+ }
24
+ }
25
+
26
+ normalized.push(lineSet);
27
+ return normalized;
28
+ }, []);
29
+
30
+ export const mergeOutput = (currentOutput, newOutput) => {
31
+ if (!newOutput.length) return currentOutput;
32
+ return normalizeOutput([...currentOutput, ...newOutput]);
33
+ };
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { render, screen } from '@testing-library/react';
3
- import '@testing-library/jest-dom/extend-expect';
3
+ import '@testing-library/jest-dom';
4
4
  import { createMemoryHistory } from 'history';
5
5
  import { Router } from 'react-router-dom';
6
6
  import JobInvocationEmptyState from '../JobInvocationEmptyState';
@@ -245,12 +245,6 @@ describe('JobInvocationDetailPage', () => {
245
245
  expect(screen.getByText('Abort')).toBeInTheDocument();
246
246
  expect(screen.queryByText('Enable recurring')).not.toBeInTheDocument();
247
247
  expect(screen.queryByText('Cancel recurring')).not.toBeInTheDocument();
248
- expect(
249
- screen
250
- .getByText('Legacy UI')
251
- .closest('a')
252
- .getAttribute('href')
253
- ).toEqual(`/legacy/job_invocations/${jobId}`);
254
248
  });
255
249
 
256
250
  it('keeps toolbar buttons mounted while job invocation data is refreshing', async () => {
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { render, screen, fireEvent } from '@testing-library/react';
3
- import '@testing-library/jest-dom/extend-expect';
3
+ import '@testing-library/jest-dom';
4
4
  import { OutputCodeBlock } from '../TemplateInvocationComponents/OutputCodeBlock';
5
5
  import { jobInvocationOutput } from './fixtures';
6
6
 
@@ -1,4 +1,4 @@
1
- import '@testing-library/jest-dom/extend-expect';
1
+ import '@testing-library/jest-dom';
2
2
  import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
3
  import axios from 'axios';
4
4
  import { foremanUrl } from 'foremanReact/common/helpers';
@@ -2,13 +2,16 @@ import React from 'react';
2
2
  import configureMockStore from 'redux-mock-store';
3
3
  import { Provider } from 'react-redux';
4
4
  import { render, screen, fireEvent } from '@testing-library/react';
5
- import '@testing-library/jest-dom/extend-expect';
5
+ import '@testing-library/jest-dom';
6
+ // eslint-disable-next-line import/no-extraneous-dependencies
7
+ import API from 'foremanReact/API';
6
8
  import * as api from 'foremanReact/redux/API';
7
9
  import * as selectors from '../JobInvocationSelectors';
8
10
  import { TemplateInvocation } from '../TemplateInvocation';
9
11
  import { mockTemplateInvocationResponse } from './fixtures';
10
12
 
11
13
  jest.spyOn(api, 'get');
14
+ jest.mock('foremanReact/API');
12
15
  jest.mock('../JobInvocationSelectors');
13
16
 
14
17
  jest.mock('foremanReact/components/ToastsList', () => ({
@@ -47,6 +50,7 @@ const mockProps = {
47
50
 
48
51
  describe('TemplateInvocation', () => {
49
52
  beforeEach(() => {
53
+ API.get.mockImplementation(() => new Promise(() => {}));
50
54
  selectors.selectTemplateInvocationStatus.mockImplementation(() => () =>
51
55
  'RESOLVED'
52
56
  );
@@ -0,0 +1,94 @@
1
+ import {
2
+ getLastOutputTimestamp,
3
+ mergeOutput,
4
+ normalizeOutput,
5
+ } from '../TemplateInvocationHelpers';
6
+
7
+ describe('TemplateInvocationHelpers', () => {
8
+ describe('getLastOutputTimestamp', () => {
9
+ it('returns null for empty output', () => {
10
+ expect(getLastOutputTimestamp([])).toBeNull();
11
+ });
12
+
13
+ it('returns the timestamp of the last output chunk', () => {
14
+ expect(
15
+ getLastOutputTimestamp([
16
+ { timestamp: 1, output: 'first\n' },
17
+ { timestamp: 2, output: 'second\n' },
18
+ ])
19
+ ).toBe(2);
20
+ });
21
+ });
22
+
23
+ describe('normalizeOutput', () => {
24
+ it('joins lines split between chunks in one response', () => {
25
+ const result = normalizeOutput([
26
+ { timestamp: 1, output_type: 'stdout', output: 'hel' },
27
+ {
28
+ timestamp: 2,
29
+ output_type: 'stdout',
30
+ output: 'lo\r\nworld\r\n',
31
+ },
32
+ ]);
33
+
34
+ expect(result).toEqual([
35
+ { timestamp: 1, output_type: 'stdout', output: 'hello\r\n' },
36
+ { timestamp: 2, output_type: 'stdout', output: 'world\r\n' },
37
+ ]);
38
+ });
39
+ });
40
+
41
+ describe('mergeOutput', () => {
42
+ it('appends new chunks without changing existing objects', () => {
43
+ const existingChunk = { timestamp: 1, output: 'first\n' };
44
+ const newChunk = { timestamp: 2, output: 'second\n' };
45
+
46
+ const result = mergeOutput([existingChunk], [newChunk]);
47
+
48
+ expect(result).toEqual([existingChunk, newChunk]);
49
+ expect(result[0]).toBe(existingChunk);
50
+ expect(result[1]).toBe(newChunk);
51
+ });
52
+
53
+ it('joins a line split across two polling responses', () => {
54
+ const result = mergeOutput(
55
+ [{ timestamp: 1, output: 'partial ' }],
56
+ [{ timestamp: 2, output: 'line\nnext line\n' }]
57
+ );
58
+
59
+ expect(result).toEqual([
60
+ { timestamp: 1, output: 'partial line\n' },
61
+ { timestamp: 2, output: 'next line\n' },
62
+ ]);
63
+ });
64
+
65
+ it('does not join chunks of different output types', () => {
66
+ const result = mergeOutput(
67
+ [{ timestamp: 1, output_type: 'stdout', output: 'partial ' }],
68
+ [{ timestamp: 2, output_type: 'stderr', output: 'error\n' }]
69
+ );
70
+
71
+ expect(result).toEqual([
72
+ { timestamp: 1, output_type: 'stdout', output: 'partial ' },
73
+ { timestamp: 2, output_type: 'stderr', output: 'error\n' },
74
+ ]);
75
+ });
76
+
77
+ it('does not append an empty chunk after completing a split line', () => {
78
+ const result = mergeOutput(
79
+ [{ timestamp: 1, output_type: 'stdout', output: 'partial ' }],
80
+ [{ timestamp: 2, output_type: 'stdout', output: 'line\n' }]
81
+ );
82
+
83
+ expect(result).toEqual([
84
+ { timestamp: 1, output_type: 'stdout', output: 'partial line\n' },
85
+ ]);
86
+ });
87
+
88
+ it('returns the current output when there is nothing to append', () => {
89
+ const currentOutput = [{ timestamp: 1, output: 'first\n' }];
90
+
91
+ expect(mergeOutput(currentOutput, [])).toBe(currentOutput);
92
+ });
93
+ });
94
+ });