@spinnaker/core 2025.3.3 → 2025.3.4
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.
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/pipeline/config/triggers/pipeline/PipelineTriggerTemplate.d.ts +9 -0
- package/package.json +2 -2
- package/src/pipeline/config/triggers/pipeline/PipelineTriggerTemplate.spec.tsx +744 -0
- package/src/pipeline/config/triggers/pipeline/PipelineTriggerTemplate.tsx +17 -5
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
import { mount, shallow } from 'enzyme';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import { PipelineTriggerTemplate } from './PipelineTriggerTemplate';
|
|
5
|
+
import type { IExecution, IPipelineCommand, IPipelineTrigger } from '../../../../domain';
|
|
6
|
+
import { ReactInjector } from '../../../../reactShims';
|
|
7
|
+
import { ExecutionsTransformer } from '../../../service/ExecutionsTransformer';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* PipelineTriggerTemplate - execution selector for pipeline triggers.
|
|
11
|
+
*
|
|
12
|
+
* Responsibilities:
|
|
13
|
+
* - Fetch and display available pipeline executions as dropdown options
|
|
14
|
+
* - Allow user to select which execution to use as the trigger source
|
|
15
|
+
* - Persist selection when unrelated form fields change (Formik creates new object refs)
|
|
16
|
+
* - Extract fields from parentExecution for re-run scenarios
|
|
17
|
+
*/
|
|
18
|
+
describe('<PipelineTriggerTemplate />', () => {
|
|
19
|
+
let getExecutionsForConfigIdsSpy: jasmine.Spy;
|
|
20
|
+
let addBuildInfoSpy: jasmine.Spy;
|
|
21
|
+
|
|
22
|
+
// Higher buildNumber = older execution (used for buildTime calculation)
|
|
23
|
+
const createExecution = (id: string, buildNumber: number, overrides: Partial<IExecution> = {}): IExecution =>
|
|
24
|
+
({
|
|
25
|
+
id,
|
|
26
|
+
application: 'test-app',
|
|
27
|
+
buildTime: Date.now() - buildNumber * 1000,
|
|
28
|
+
status: 'SUCCEEDED',
|
|
29
|
+
pipelineConfigId: 'source-pipeline-id',
|
|
30
|
+
name: 'Source Pipeline',
|
|
31
|
+
stages: [],
|
|
32
|
+
trigger: { type: 'manual' } as any,
|
|
33
|
+
...overrides,
|
|
34
|
+
} as IExecution);
|
|
35
|
+
|
|
36
|
+
const execution1 = createExecution('exec-1', 1); // most recent
|
|
37
|
+
const execution2 = createExecution('exec-2', 2);
|
|
38
|
+
const execution3 = createExecution('exec-3', 3); // oldest
|
|
39
|
+
|
|
40
|
+
// pipeline = source pipeline config ID that this trigger watches
|
|
41
|
+
// parentPipelineId = specific execution ID to pre-select (used in re-runs)
|
|
42
|
+
const createPipelineTrigger = (pipelineId: string, parentPipelineId?: string): IPipelineTrigger =>
|
|
43
|
+
({
|
|
44
|
+
enabled: true,
|
|
45
|
+
type: 'pipeline',
|
|
46
|
+
application: 'source-app',
|
|
47
|
+
pipeline: pipelineId,
|
|
48
|
+
parentPipelineId,
|
|
49
|
+
status: ['successful'],
|
|
50
|
+
} as IPipelineTrigger);
|
|
51
|
+
|
|
52
|
+
// The component MUTATES command.extraFields and command.triggerInvalid directly.
|
|
53
|
+
// This is the interface contract with ManualPipelineExecutionModal.
|
|
54
|
+
const createCommand = (trigger: IPipelineTrigger): IPipelineCommand => ({
|
|
55
|
+
pipeline: {
|
|
56
|
+
application: 'test-app',
|
|
57
|
+
id: 'pipeline-id',
|
|
58
|
+
name: 'Test Pipeline',
|
|
59
|
+
stages: [],
|
|
60
|
+
triggers: [trigger],
|
|
61
|
+
parameterConfig: [],
|
|
62
|
+
keepWaitingPipelines: false,
|
|
63
|
+
limitConcurrent: true,
|
|
64
|
+
},
|
|
65
|
+
trigger,
|
|
66
|
+
triggerInvalid: false,
|
|
67
|
+
extraFields: {},
|
|
68
|
+
notificationEnabled: false,
|
|
69
|
+
notification: { type: 'email', address: '', when: [] },
|
|
70
|
+
pipelineName: 'Test Pipeline',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const updateCommandSpy = jasmine.createSpy('updateCommand');
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
getExecutionsForConfigIdsSpy = jasmine.createSpy('getExecutionsForConfigIds');
|
|
77
|
+
// ReactInjector.executionService is a getter, not a method - use spyOnProperty
|
|
78
|
+
spyOnProperty(ReactInjector, 'executionService', 'get').and.returnValue({
|
|
79
|
+
getExecutionsForConfigIds: getExecutionsForConfigIdsSpy,
|
|
80
|
+
});
|
|
81
|
+
addBuildInfoSpy = spyOn(ExecutionsTransformer, 'addBuildInfo');
|
|
82
|
+
updateCommandSpy.calls.reset();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// shallow() renders only the component, not children - faster, good for unit tests
|
|
86
|
+
// mount() renders full DOM tree - needed when testing interactions or lifecycle
|
|
87
|
+
describe('Component lifecycle', () => {
|
|
88
|
+
it('displays loading spinner while fetching executions', () => {
|
|
89
|
+
// Promise that never resolves keeps component in loading state
|
|
90
|
+
getExecutionsForConfigIdsSpy.and.returnValue(new Promise(() => {}));
|
|
91
|
+
|
|
92
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
93
|
+
const command = createCommand(trigger);
|
|
94
|
+
|
|
95
|
+
const wrapper = shallow(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
96
|
+
|
|
97
|
+
expect(wrapper.find('Spinner').exists()).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('displays error message on load failure', async () => {
|
|
101
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.reject(new Error('Load failed')));
|
|
102
|
+
|
|
103
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
104
|
+
const command = createCommand(trigger);
|
|
105
|
+
|
|
106
|
+
const wrapper = shallow(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
107
|
+
|
|
108
|
+
// await Promise.resolve() flushes the microtask queue, allowing the
|
|
109
|
+
// component's promise callbacks to execute before we check state
|
|
110
|
+
await Promise.resolve();
|
|
111
|
+
wrapper.update(); // sync enzyme wrapper with React component state
|
|
112
|
+
|
|
113
|
+
expect(wrapper.text()).toContain('Error loading executions');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('displays "No recent executions found" when list is empty', async () => {
|
|
117
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve([]));
|
|
118
|
+
|
|
119
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
120
|
+
const command = createCommand(trigger);
|
|
121
|
+
|
|
122
|
+
const wrapper = shallow(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
123
|
+
|
|
124
|
+
await Promise.resolve();
|
|
125
|
+
wrapper.update();
|
|
126
|
+
|
|
127
|
+
expect(wrapper.text()).toContain('No recent executions found');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('renders execution dropdown with correct options after load', async () => {
|
|
131
|
+
const executions = [execution1, execution2, execution3];
|
|
132
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
133
|
+
|
|
134
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
135
|
+
const command = createCommand(trigger);
|
|
136
|
+
|
|
137
|
+
const wrapper = shallow(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
138
|
+
|
|
139
|
+
await Promise.resolve();
|
|
140
|
+
wrapper.update();
|
|
141
|
+
|
|
142
|
+
expect(wrapper.find('TetheredSelect').exists()).toBe(true);
|
|
143
|
+
const options = wrapper.find('TetheredSelect').prop('options') as Array<{ value: string }>;
|
|
144
|
+
expect(options.length).toBe(3);
|
|
145
|
+
expect(options.map((o) => o.value)).toEqual(['exec-1', 'exec-2', 'exec-3']);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('Execution selection preservation', () => {
|
|
150
|
+
it('preserves selection when command object reference changes but trigger.pipeline is unchanged', async () => {
|
|
151
|
+
const executions = [execution1, execution2, execution3];
|
|
152
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
153
|
+
|
|
154
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
155
|
+
const command = createCommand(trigger);
|
|
156
|
+
|
|
157
|
+
// mount() needed to access instance methods and component state
|
|
158
|
+
const wrapper = mount(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
159
|
+
|
|
160
|
+
await Promise.resolve();
|
|
161
|
+
wrapper.update();
|
|
162
|
+
|
|
163
|
+
expect(wrapper.state('selectedExecution')).toBe('exec-1');
|
|
164
|
+
|
|
165
|
+
// Calling handleExecutionChanged directly instead of simulating DOM events.
|
|
166
|
+
// Trade-off: faster/simpler tests but doesn't verify event wiring.
|
|
167
|
+
const instance = wrapper.instance() as PipelineTriggerTemplate;
|
|
168
|
+
(instance as any).handleExecutionChanged({ value: 'exec-2' });
|
|
169
|
+
wrapper.update();
|
|
170
|
+
|
|
171
|
+
expect(wrapper.state('selectedExecution')).toBe('exec-2');
|
|
172
|
+
|
|
173
|
+
// Simulating Formik behavior: when user types in any form field,
|
|
174
|
+
// Formik creates a NEW command object via spread operator.
|
|
175
|
+
// The object reference changes but trigger.pipeline value stays same.
|
|
176
|
+
const newCommand = {
|
|
177
|
+
...command,
|
|
178
|
+
parameters: { changeNumber: 'CHG000123' },
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
getExecutionsForConfigIdsSpy.calls.reset();
|
|
182
|
+
wrapper.setProps({ command: newCommand });
|
|
183
|
+
wrapper.update();
|
|
184
|
+
|
|
185
|
+
// Key assertion: API should NOT be called again since pipeline didn't change
|
|
186
|
+
expect(getExecutionsForConfigIdsSpy).not.toHaveBeenCalled();
|
|
187
|
+
expect(wrapper.state('selectedExecution')).toBe('exec-2');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('preserves user selection after initial load completes', async () => {
|
|
191
|
+
const executions = [execution1, execution2, execution3];
|
|
192
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
193
|
+
|
|
194
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
195
|
+
const command = createCommand(trigger);
|
|
196
|
+
|
|
197
|
+
const wrapper = mount(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
198
|
+
|
|
199
|
+
await Promise.resolve();
|
|
200
|
+
wrapper.update();
|
|
201
|
+
|
|
202
|
+
const instance = wrapper.instance() as PipelineTriggerTemplate;
|
|
203
|
+
(instance as any).handleExecutionChanged({ value: 'exec-3' });
|
|
204
|
+
wrapper.update();
|
|
205
|
+
|
|
206
|
+
expect(wrapper.state('selectedExecution')).toBe('exec-3');
|
|
207
|
+
expect(command.extraFields.parentPipelineId).toBe('exec-3');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('Re-initialization behavior', () => {
|
|
212
|
+
it('refetches executions when trigger.pipeline changes', async () => {
|
|
213
|
+
const executions = [execution1, execution2];
|
|
214
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
215
|
+
|
|
216
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
217
|
+
const command = createCommand(trigger);
|
|
218
|
+
|
|
219
|
+
const wrapper = mount(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
220
|
+
|
|
221
|
+
await Promise.resolve();
|
|
222
|
+
wrapper.update();
|
|
223
|
+
|
|
224
|
+
getExecutionsForConfigIdsSpy.calls.reset();
|
|
225
|
+
|
|
226
|
+
const newTrigger = createPipelineTrigger('different-pipeline-id');
|
|
227
|
+
const newCommand = createCommand(newTrigger);
|
|
228
|
+
wrapper.setProps({ command: newCommand });
|
|
229
|
+
|
|
230
|
+
expect(getExecutionsForConfigIdsSpy).toHaveBeenCalledWith(['different-pipeline-id'], { limit: 20 });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('defaults to latest execution when parentPipelineId does not match', async () => {
|
|
234
|
+
const executions = [execution1, execution2, execution3];
|
|
235
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
236
|
+
|
|
237
|
+
const trigger = createPipelineTrigger('source-pipeline-id', 'non-existent-id');
|
|
238
|
+
const command = createCommand(trigger);
|
|
239
|
+
|
|
240
|
+
const wrapper = shallow(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
241
|
+
|
|
242
|
+
await Promise.resolve();
|
|
243
|
+
wrapper.update();
|
|
244
|
+
|
|
245
|
+
expect(wrapper.state('selectedExecution')).toBe('exec-1');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('selects matching execution when parentPipelineId exists in list', async () => {
|
|
249
|
+
const executions = [execution1, execution2, execution3];
|
|
250
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
251
|
+
|
|
252
|
+
const trigger = createPipelineTrigger('source-pipeline-id', 'exec-2');
|
|
253
|
+
const command = createCommand(trigger);
|
|
254
|
+
|
|
255
|
+
const wrapper = shallow(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
256
|
+
|
|
257
|
+
await Promise.resolve();
|
|
258
|
+
wrapper.update();
|
|
259
|
+
|
|
260
|
+
expect(wrapper.state('selectedExecution')).toBe('exec-2');
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Re-run = user clicks "Start execution with same parameters" on an existing execution.
|
|
265
|
+
// In this case, trigger.parentExecution contains the original execution's data,
|
|
266
|
+
// but trigger.pipeline/application may be unset. The component extracts these fields.
|
|
267
|
+
describe('Re-run scenario', () => {
|
|
268
|
+
it('extracts fields from parentExecution', async () => {
|
|
269
|
+
const executions = [execution1, execution2];
|
|
270
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
271
|
+
|
|
272
|
+
const parentExecution: Partial<IExecution> = {
|
|
273
|
+
id: 'parent-exec-id',
|
|
274
|
+
application: 'parent-app',
|
|
275
|
+
pipelineConfigId: 'parent-pipeline-config-id',
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Note: trigger.pipeline and trigger.application are NOT set initially.
|
|
279
|
+
// The component's initialize() method copies them from parentExecution.
|
|
280
|
+
const trigger: IPipelineTrigger = {
|
|
281
|
+
enabled: true,
|
|
282
|
+
type: 'pipeline',
|
|
283
|
+
parentExecution: parentExecution as IExecution,
|
|
284
|
+
status: ['successful'],
|
|
285
|
+
} as IPipelineTrigger;
|
|
286
|
+
|
|
287
|
+
const command = createCommand(trigger);
|
|
288
|
+
|
|
289
|
+
shallow(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
290
|
+
|
|
291
|
+
// The component MUTATES the trigger object to populate these fields
|
|
292
|
+
expect(trigger.application).toBe('parent-app');
|
|
293
|
+
expect(trigger.pipeline).toBe('parent-pipeline-config-id');
|
|
294
|
+
expect(trigger.parentPipelineId).toBe('parent-exec-id');
|
|
295
|
+
expect(getExecutionsForConfigIdsSpy).toHaveBeenCalledWith(['parent-pipeline-config-id'], { limit: 20 });
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe('User interaction', () => {
|
|
300
|
+
it('updates extraFields when user changes execution selection', async () => {
|
|
301
|
+
const executions = [execution1, execution2];
|
|
302
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
303
|
+
|
|
304
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
305
|
+
const command = createCommand(trigger);
|
|
306
|
+
|
|
307
|
+
const wrapper = mount(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
308
|
+
|
|
309
|
+
await Promise.resolve();
|
|
310
|
+
wrapper.update();
|
|
311
|
+
|
|
312
|
+
expect(command.extraFields.parentPipelineId).toBe('exec-1');
|
|
313
|
+
expect(command.triggerInvalid).toBe(false);
|
|
314
|
+
|
|
315
|
+
const instance = wrapper.instance() as PipelineTriggerTemplate;
|
|
316
|
+
(instance as any).handleExecutionChanged({ value: 'exec-2' });
|
|
317
|
+
|
|
318
|
+
expect(command.extraFields.parentPipelineId).toBe('exec-2');
|
|
319
|
+
expect(command.extraFields.parentPipelineApplication).toBe('test-app');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('sets triggerInvalid to false after successful execution selection', async () => {
|
|
323
|
+
const executions = [execution1];
|
|
324
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
325
|
+
|
|
326
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
327
|
+
const command = createCommand(trigger);
|
|
328
|
+
command.triggerInvalid = true; // Start with invalid
|
|
329
|
+
|
|
330
|
+
const wrapper = shallow(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
331
|
+
|
|
332
|
+
await Promise.resolve();
|
|
333
|
+
wrapper.update();
|
|
334
|
+
|
|
335
|
+
// After successful load and selection, trigger should be valid
|
|
336
|
+
expect(command.triggerInvalid).toBe(false);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('handles multiple rapid selection changes correctly', async () => {
|
|
340
|
+
const executions = [execution1, execution2, execution3];
|
|
341
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
342
|
+
|
|
343
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
344
|
+
const command = createCommand(trigger);
|
|
345
|
+
|
|
346
|
+
const wrapper = mount(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
347
|
+
|
|
348
|
+
await Promise.resolve();
|
|
349
|
+
wrapper.update();
|
|
350
|
+
|
|
351
|
+
const instance = wrapper.instance() as PipelineTriggerTemplate;
|
|
352
|
+
|
|
353
|
+
// Rapidly change selections
|
|
354
|
+
(instance as any).handleExecutionChanged({ value: 'exec-2' });
|
|
355
|
+
(instance as any).handleExecutionChanged({ value: 'exec-3' });
|
|
356
|
+
(instance as any).handleExecutionChanged({ value: 'exec-1' });
|
|
357
|
+
|
|
358
|
+
// Final selection should be the last one
|
|
359
|
+
expect(wrapper.state('selectedExecution')).toBe('exec-1');
|
|
360
|
+
expect(command.extraFields.parentPipelineId).toBe('exec-1');
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe('Edge cases', () => {
|
|
365
|
+
it('handles trigger with undefined pipeline gracefully', async () => {
|
|
366
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve([]));
|
|
367
|
+
|
|
368
|
+
const trigger = ({
|
|
369
|
+
enabled: true,
|
|
370
|
+
type: 'pipeline',
|
|
371
|
+
application: 'source-app',
|
|
372
|
+
pipeline: undefined,
|
|
373
|
+
status: ['successful'],
|
|
374
|
+
} as unknown) as IPipelineTrigger;
|
|
375
|
+
|
|
376
|
+
const command = createCommand(trigger);
|
|
377
|
+
|
|
378
|
+
expect(() => {
|
|
379
|
+
shallow(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
380
|
+
}).not.toThrow();
|
|
381
|
+
|
|
382
|
+
expect(getExecutionsForConfigIdsSpy).toHaveBeenCalledWith([undefined], { limit: 20 });
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('handles trigger type change from pipeline to non-pipeline', async () => {
|
|
386
|
+
const executions = [execution1, execution2];
|
|
387
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
388
|
+
|
|
389
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
390
|
+
const command = createCommand(trigger);
|
|
391
|
+
|
|
392
|
+
const wrapper = mount(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
393
|
+
|
|
394
|
+
await Promise.resolve();
|
|
395
|
+
wrapper.update();
|
|
396
|
+
|
|
397
|
+
expect(wrapper.state('selectedExecution')).toBe('exec-1');
|
|
398
|
+
|
|
399
|
+
getExecutionsForConfigIdsSpy.calls.reset();
|
|
400
|
+
|
|
401
|
+
const manualTrigger = { type: 'manual', enabled: true } as any;
|
|
402
|
+
const newCommand = { ...command, trigger: manualTrigger };
|
|
403
|
+
wrapper.setProps({ command: newCommand });
|
|
404
|
+
|
|
405
|
+
expect(getExecutionsForConfigIdsSpy).not.toHaveBeenCalled();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('handles empty pipeline ID string', async () => {
|
|
409
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve([]));
|
|
410
|
+
|
|
411
|
+
const trigger = createPipelineTrigger('');
|
|
412
|
+
const command = createCommand(trigger);
|
|
413
|
+
|
|
414
|
+
const wrapper = shallow(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
415
|
+
|
|
416
|
+
await Promise.resolve();
|
|
417
|
+
wrapper.update();
|
|
418
|
+
|
|
419
|
+
expect(getExecutionsForConfigIdsSpy).toHaveBeenCalledWith([''], { limit: 20 });
|
|
420
|
+
expect(wrapper.text()).toContain('No recent executions found');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('handles executions with various status values', async () => {
|
|
424
|
+
const successExec = createExecution('exec-success', 1, { status: 'SUCCEEDED' });
|
|
425
|
+
const failedExec = createExecution('exec-failed', 2, { status: 'TERMINAL' });
|
|
426
|
+
const runningExec = createExecution('exec-running', 3, { status: 'RUNNING' });
|
|
427
|
+
const canceledExec = createExecution('exec-canceled', 4, { status: 'CANCELED' });
|
|
428
|
+
|
|
429
|
+
const executions = [successExec, failedExec, runningExec, canceledExec];
|
|
430
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
431
|
+
|
|
432
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
433
|
+
const command = createCommand(trigger);
|
|
434
|
+
|
|
435
|
+
const wrapper = shallow(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
436
|
+
|
|
437
|
+
await Promise.resolve();
|
|
438
|
+
wrapper.update();
|
|
439
|
+
|
|
440
|
+
const options = wrapper.find('TetheredSelect').prop('options') as Array<{ value: string }>;
|
|
441
|
+
expect(options.length).toBe(4);
|
|
442
|
+
expect(options.map((o) => o.value)).toEqual(['exec-success', 'exec-failed', 'exec-running', 'exec-canceled']);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe('Async behavior', () => {
|
|
447
|
+
// TODO: Fix potential race condition - if user switches pipelines while a request is loading,
|
|
448
|
+
// the old request can return after the new one and show the wrong executions.
|
|
449
|
+
// Fix would involve something like tracking which request is current and ignoring stale responses.
|
|
450
|
+
it('late-arriving response overwrites current state (known issue)', async () => {
|
|
451
|
+
let resolveFirst: (value: IExecution[]) => void = () => {};
|
|
452
|
+
let resolveSecond: (value: IExecution[]) => void = () => {};
|
|
453
|
+
|
|
454
|
+
const firstPromise = new Promise<IExecution[]>((resolve) => {
|
|
455
|
+
resolveFirst = resolve;
|
|
456
|
+
});
|
|
457
|
+
const secondPromise = new Promise<IExecution[]>((resolve) => {
|
|
458
|
+
resolveSecond = resolve;
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
getExecutionsForConfigIdsSpy.and.returnValues(firstPromise, secondPromise);
|
|
462
|
+
|
|
463
|
+
const trigger = createPipelineTrigger('pipeline-1');
|
|
464
|
+
const command = createCommand(trigger);
|
|
465
|
+
|
|
466
|
+
const wrapper = mount(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
467
|
+
|
|
468
|
+
// User switches to pipeline-2 before pipeline-1 request finishes
|
|
469
|
+
const newTrigger = createPipelineTrigger('pipeline-2');
|
|
470
|
+
const newCommand = createCommand(newTrigger);
|
|
471
|
+
wrapper.setProps({ command: newCommand });
|
|
472
|
+
|
|
473
|
+
// Pipeline-2 response arrives first (as expected)
|
|
474
|
+
const pipeline2Executions = [createExecution('exec-p2-1', 1)];
|
|
475
|
+
resolveSecond(pipeline2Executions);
|
|
476
|
+
await Promise.resolve();
|
|
477
|
+
wrapper.update();
|
|
478
|
+
|
|
479
|
+
expect(wrapper.state('selectedExecution')).toBe('exec-p2-1');
|
|
480
|
+
|
|
481
|
+
// Pipeline-1 response arrives late - this is the problem
|
|
482
|
+
const pipeline1Executions = [createExecution('exec-p1-1', 1)];
|
|
483
|
+
resolveFirst(pipeline1Executions);
|
|
484
|
+
await Promise.resolve();
|
|
485
|
+
wrapper.update();
|
|
486
|
+
|
|
487
|
+
// Current behavior: late response overwrites the correct data
|
|
488
|
+
// Expected behavior (when fixed): should still show exec-p2-1
|
|
489
|
+
expect(wrapper.state('selectedExecution')).toBe('exec-p1-1');
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('maintains loading state until promise resolves', async () => {
|
|
493
|
+
let resolvePromise: (value: IExecution[]) => void = () => {};
|
|
494
|
+
const pendingPromise = new Promise<IExecution[]>((resolve) => {
|
|
495
|
+
resolvePromise = resolve;
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
getExecutionsForConfigIdsSpy.and.returnValue(pendingPromise);
|
|
499
|
+
|
|
500
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
501
|
+
const command = createCommand(trigger);
|
|
502
|
+
|
|
503
|
+
const wrapper = shallow(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
504
|
+
|
|
505
|
+
expect(wrapper.state('executionsLoading')).toBe(true);
|
|
506
|
+
expect(wrapper.find('Spinner').exists()).toBe(true);
|
|
507
|
+
|
|
508
|
+
resolvePromise([execution1]);
|
|
509
|
+
await Promise.resolve();
|
|
510
|
+
wrapper.update();
|
|
511
|
+
|
|
512
|
+
expect(wrapper.state('executionsLoading')).toBe(false);
|
|
513
|
+
expect(wrapper.find('Spinner').exists()).toBe(false);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('calls addBuildInfo for each execution after load', async () => {
|
|
517
|
+
const executions = [execution1, execution2, execution3];
|
|
518
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
519
|
+
|
|
520
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
521
|
+
const command = createCommand(trigger);
|
|
522
|
+
|
|
523
|
+
shallow(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
524
|
+
|
|
525
|
+
await Promise.resolve();
|
|
526
|
+
|
|
527
|
+
expect(addBuildInfoSpy).toHaveBeenCalledTimes(3);
|
|
528
|
+
expect(addBuildInfoSpy).toHaveBeenCalledWith(execution1);
|
|
529
|
+
expect(addBuildInfoSpy).toHaveBeenCalledWith(execution2);
|
|
530
|
+
expect(addBuildInfoSpy).toHaveBeenCalledWith(execution3);
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
describe('State consistency', () => {
|
|
535
|
+
it('maintains state after multiple prop updates without pipeline change', async () => {
|
|
536
|
+
const executions = [execution1, execution2, execution3];
|
|
537
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
538
|
+
|
|
539
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
540
|
+
const command = createCommand(trigger);
|
|
541
|
+
|
|
542
|
+
const wrapper = mount(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
543
|
+
|
|
544
|
+
await Promise.resolve();
|
|
545
|
+
wrapper.update();
|
|
546
|
+
|
|
547
|
+
const instance = wrapper.instance() as PipelineTriggerTemplate;
|
|
548
|
+
(instance as any).handleExecutionChanged({ value: 'exec-2' });
|
|
549
|
+
|
|
550
|
+
for (let i = 0; i < 5; i++) {
|
|
551
|
+
const updatedCommand = {
|
|
552
|
+
...command,
|
|
553
|
+
parameters: { changeNumber: `CHG00${i}` },
|
|
554
|
+
};
|
|
555
|
+
wrapper.setProps({ command: updatedCommand });
|
|
556
|
+
wrapper.update();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
expect(wrapper.state('selectedExecution')).toBe('exec-2');
|
|
560
|
+
expect(wrapper.state('executions')).toEqual(executions);
|
|
561
|
+
expect(wrapper.state('executionsLoading')).toBe(false);
|
|
562
|
+
expect(wrapper.state('loadError')).toBe(false);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('loads new executions when pipeline changes', async () => {
|
|
566
|
+
const pipeline1Executions = [createExecution('p1-exec-1', 1), createExecution('p1-exec-2', 2)];
|
|
567
|
+
|
|
568
|
+
const pipeline2Executions = [
|
|
569
|
+
createExecution('p2-exec-1', 1),
|
|
570
|
+
createExecution('p2-exec-2', 2),
|
|
571
|
+
createExecution('p2-exec-3', 3),
|
|
572
|
+
];
|
|
573
|
+
|
|
574
|
+
getExecutionsForConfigIdsSpy.and.returnValues(
|
|
575
|
+
Promise.resolve(pipeline1Executions),
|
|
576
|
+
Promise.resolve(pipeline2Executions),
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
const trigger = createPipelineTrigger('pipeline-1');
|
|
580
|
+
const command = createCommand(trigger);
|
|
581
|
+
|
|
582
|
+
const wrapper = mount(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
583
|
+
|
|
584
|
+
await Promise.resolve();
|
|
585
|
+
wrapper.update();
|
|
586
|
+
|
|
587
|
+
expect(wrapper.state('selectedExecution')).toBe('p1-exec-1');
|
|
588
|
+
expect((wrapper.state('executions') as IExecution[]).length).toBe(2);
|
|
589
|
+
|
|
590
|
+
const instance = wrapper.instance() as PipelineTriggerTemplate;
|
|
591
|
+
(instance as any).handleExecutionChanged({ value: 'p1-exec-2' });
|
|
592
|
+
expect(wrapper.state('selectedExecution')).toBe('p1-exec-2');
|
|
593
|
+
|
|
594
|
+
const newTrigger = createPipelineTrigger('pipeline-2');
|
|
595
|
+
const newCommand = createCommand(newTrigger);
|
|
596
|
+
wrapper.setProps({ command: newCommand });
|
|
597
|
+
|
|
598
|
+
await Promise.resolve();
|
|
599
|
+
wrapper.update();
|
|
600
|
+
|
|
601
|
+
expect(wrapper.state('selectedExecution')).toBe('p2-exec-1');
|
|
602
|
+
expect((wrapper.state('executions') as IExecution[]).length).toBe(3);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('clears extraFields when pipeline changes', async () => {
|
|
606
|
+
const executions = [execution1, execution2];
|
|
607
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
608
|
+
|
|
609
|
+
const trigger = createPipelineTrigger('pipeline-1');
|
|
610
|
+
const command = createCommand(trigger);
|
|
611
|
+
|
|
612
|
+
const wrapper = mount(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
613
|
+
|
|
614
|
+
await Promise.resolve();
|
|
615
|
+
wrapper.update();
|
|
616
|
+
|
|
617
|
+
expect(command.extraFields.parentPipelineId).toBe('exec-1');
|
|
618
|
+
|
|
619
|
+
const newTrigger = createPipelineTrigger('pipeline-2');
|
|
620
|
+
const newCommand = createCommand(newTrigger);
|
|
621
|
+
wrapper.setProps({ command: newCommand });
|
|
622
|
+
|
|
623
|
+
expect(newCommand.extraFields).toEqual({});
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
describe('Formik integration', () => {
|
|
628
|
+
it('preserves selection through typical form interaction sequence', async () => {
|
|
629
|
+
const executions = [execution1, execution2, execution3];
|
|
630
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
631
|
+
|
|
632
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
633
|
+
const command = createCommand(trigger);
|
|
634
|
+
|
|
635
|
+
const wrapper = mount(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
636
|
+
|
|
637
|
+
await Promise.resolve();
|
|
638
|
+
wrapper.update();
|
|
639
|
+
expect(wrapper.state('selectedExecution')).toBe('exec-1');
|
|
640
|
+
|
|
641
|
+
const instance = wrapper.instance() as PipelineTriggerTemplate;
|
|
642
|
+
(instance as any).handleExecutionChanged({ value: 'exec-3' });
|
|
643
|
+
wrapper.update();
|
|
644
|
+
expect(wrapper.state('selectedExecution')).toBe('exec-3');
|
|
645
|
+
|
|
646
|
+
// Simulate typing "CHG123456" one character at a time.
|
|
647
|
+
// Each keystroke causes Formik to create new objects via spread:
|
|
648
|
+
// { ...command, trigger: { ...trigger }, ... }
|
|
649
|
+
// This is why we spread trigger too - Formik does this internally.
|
|
650
|
+
const changeNumberChars = 'CHG123456';
|
|
651
|
+
for (let i = 1; i <= changeNumberChars.length; i++) {
|
|
652
|
+
const newCommand = {
|
|
653
|
+
...command,
|
|
654
|
+
trigger: { ...trigger }, // new object, but trigger.pipeline value unchanged
|
|
655
|
+
parameters: { changeNumber: changeNumberChars.substring(0, i) },
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
getExecutionsForConfigIdsSpy.calls.reset();
|
|
659
|
+
wrapper.setProps({ command: newCommand });
|
|
660
|
+
wrapper.update();
|
|
661
|
+
|
|
662
|
+
expect(getExecutionsForConfigIdsSpy).not.toHaveBeenCalled();
|
|
663
|
+
expect(wrapper.state('selectedExecution')).toBe('exec-3');
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
expect(wrapper.state('selectedExecution')).toBe('exec-3');
|
|
667
|
+
expect(command.extraFields.parentPipelineId).toBe('exec-3');
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it('handles simultaneous parameter and trigger changes correctly', async () => {
|
|
671
|
+
const executions = [execution1, execution2];
|
|
672
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
673
|
+
|
|
674
|
+
const trigger = createPipelineTrigger('pipeline-1');
|
|
675
|
+
const command = createCommand(trigger);
|
|
676
|
+
|
|
677
|
+
const wrapper = mount(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
678
|
+
|
|
679
|
+
await Promise.resolve();
|
|
680
|
+
wrapper.update();
|
|
681
|
+
|
|
682
|
+
getExecutionsForConfigIdsSpy.calls.reset();
|
|
683
|
+
|
|
684
|
+
const newTrigger = createPipelineTrigger('pipeline-2');
|
|
685
|
+
const newCommand = {
|
|
686
|
+
...createCommand(newTrigger),
|
|
687
|
+
parameters: { newParam: 'value' },
|
|
688
|
+
};
|
|
689
|
+
wrapper.setProps({ command: newCommand });
|
|
690
|
+
|
|
691
|
+
expect(getExecutionsForConfigIdsSpy).toHaveBeenCalledWith(['pipeline-2'], { limit: 20 });
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
describe('Rendering', () => {
|
|
696
|
+
it('renders with form-group structure and label', async () => {
|
|
697
|
+
const executions = [execution1];
|
|
698
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
699
|
+
|
|
700
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
701
|
+
const command = createCommand(trigger);
|
|
702
|
+
|
|
703
|
+
const wrapper = shallow(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
704
|
+
|
|
705
|
+
await Promise.resolve();
|
|
706
|
+
wrapper.update();
|
|
707
|
+
|
|
708
|
+
expect(wrapper.find('.form-group').exists()).toBe(true);
|
|
709
|
+
expect(wrapper.find('label').text()).toBe('Execution');
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('renders all executions as dropdown options', async () => {
|
|
713
|
+
const manyExecutions = Array.from({ length: 15 }, (_, i) => createExecution(`exec-${i}`, i));
|
|
714
|
+
|
|
715
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(manyExecutions));
|
|
716
|
+
|
|
717
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
718
|
+
const command = createCommand(trigger);
|
|
719
|
+
|
|
720
|
+
const wrapper = shallow(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
721
|
+
|
|
722
|
+
await Promise.resolve();
|
|
723
|
+
wrapper.update();
|
|
724
|
+
|
|
725
|
+
const options = wrapper.find('TetheredSelect').prop('options') as Array<{ value: string }>;
|
|
726
|
+
expect(options.length).toBe(15);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it('dropdown is not clearable', async () => {
|
|
730
|
+
const executions = [execution1];
|
|
731
|
+
getExecutionsForConfigIdsSpy.and.returnValue(Promise.resolve(executions));
|
|
732
|
+
|
|
733
|
+
const trigger = createPipelineTrigger('source-pipeline-id');
|
|
734
|
+
const command = createCommand(trigger);
|
|
735
|
+
|
|
736
|
+
const wrapper = shallow(<PipelineTriggerTemplate command={command} updateCommand={updateCommandSpy} />);
|
|
737
|
+
|
|
738
|
+
await Promise.resolve();
|
|
739
|
+
wrapper.update();
|
|
740
|
+
|
|
741
|
+
expect(wrapper.find('TetheredSelect').prop('clearable')).toBe(false);
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
});
|