@spinnaker/google 2026.3.1 → 2026.4.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 (108) hide show
  1. package/dist/autoscalingPolicy/GceAutoScalingFieldLayout.d.ts +2 -2
  2. package/dist/domain/loadBalancer.d.ts +6 -3
  3. package/dist/image/ImageSelect.d.ts +1 -1
  4. package/dist/index.js +159 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/loadBalancer/configure/common/gceLoadBalancerModels.d.ts +13 -2
  7. package/dist/loadBalancer/configure/http/GceHttpLoadBalancerEditor.d.ts +3 -0
  8. package/dist/loadBalancer/configure/http/GceHttpLoadBalancerModal.d.ts +1 -1
  9. package/dist/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerEditor.d.ts +30 -0
  10. package/dist/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerModal.d.ts +53 -0
  11. package/dist/loadBalancer/httpLoadBalancerUtils.service.d.ts +3 -1
  12. package/dist/serverGroup/configure/wizard/GceCloneServerGroupModal.d.ts +3 -0
  13. package/dist/serverGroup/configure/wizard/GceServerGroupWizard.types.d.ts +1 -0
  14. package/dist/serverGroup/configure/wizard/customInstance/CustomInstanceConfigurer.d.ts +1 -1
  15. package/dist/serverGroup/configure/wizard/customInstance/GceCustomInstanceBuilder.d.ts +2 -2
  16. package/dist/serverGroup/configure/wizard/pages/AdvancedSettings.d.ts +3 -0
  17. package/dist/serverGroup/configure/wizard/pages/GceServerGroupLoadBalancers.d.ts +1 -0
  18. package/dist/serverGroup/configure/wizard/pages/Policies.d.ts +21 -3
  19. package/dist/serverGroup/configure/wizard/pages/ServerGroupBasicSettings.d.ts +1 -0
  20. package/dist/serverGroup/configure/wizard/pages/ServerGroupCapacity.d.ts +1 -0
  21. package/dist/serverGroup/configure/wizard/pages/ServerGroupImageSettings.d.ts +1 -0
  22. package/dist/serverGroup/configure/wizard/pages/ServerGroupInstanceType.d.ts +1 -0
  23. package/dist/serverGroup/configure/wizard/zones/GceInstanceFlexibilityConfigurer.d.ts +2 -2
  24. package/dist/serverGroup/details/GceInstanceFlexibilityPolicyDetails.d.ts +2 -2
  25. package/dist/serverGroup/details/stateful/MarkDiskStatefulButton.d.ts +2 -2
  26. package/dist/serverGroup/details/stateful/UpdateBootImageButton.d.ts +2 -2
  27. package/package.json +5 -5
  28. package/src/autoHealingPolicy/GceAutoHealingPolicyEditor.spec.tsx +68 -52
  29. package/src/autoscalingPolicy/GceAutoscalingPolicyEditor.spec.tsx +43 -50
  30. package/src/autoscalingPolicy/GceAutoscalingPolicyEditor.tsx +1 -1
  31. package/src/autoscalingPolicy/GceAutoscalingPolicyWriter.spec.ts +1 -1
  32. package/src/domain/loadBalancer.ts +6 -3
  33. package/src/gce.module.spec.ts +132 -153
  34. package/src/instance/details/GceInstanceDetails.spec.tsx +86 -83
  35. package/src/loadBalancer/configure/choice/GceLoadBalancerChoiceModal.spec.tsx +162 -121
  36. package/src/loadBalancer/configure/choice/GceLoadBalancerChoiceModal.tsx +13 -1
  37. package/src/loadBalancer/configure/common/GceProxyLoadBalancerEditor.spec.tsx +47 -30
  38. package/src/loadBalancer/configure/common/GceProxyLoadBalancerEditor.tsx +2 -1
  39. package/src/loadBalancer/configure/common/GceProxyLoadBalancerModal.spec.tsx +85 -57
  40. package/src/loadBalancer/configure/common/gceLoadBalancerData.spec.ts +17 -16
  41. package/src/loadBalancer/configure/common/gceLoadBalancerModels.spec.ts +230 -5
  42. package/src/loadBalancer/configure/common/gceLoadBalancerModels.ts +186 -22
  43. package/src/loadBalancer/configure/common/gceLoadBalancerSubmission.spec.ts +298 -2
  44. package/src/loadBalancer/configure/common/gceLoadBalancerSubmission.ts +35 -5
  45. package/src/loadBalancer/configure/common/useGceLoadBalancerData.spec.tsx +37 -46
  46. package/src/loadBalancer/configure/http/GceHttpLoadBalancerEditor.spec.tsx +399 -58
  47. package/src/loadBalancer/configure/http/GceHttpLoadBalancerEditor.tsx +148 -41
  48. package/src/loadBalancer/configure/http/GceHttpLoadBalancerListenerEditor.spec.tsx +169 -36
  49. package/src/loadBalancer/configure/http/GceHttpLoadBalancerListenerEditor.tsx +80 -12
  50. package/src/loadBalancer/configure/http/GceHttpLoadBalancerModal.spec.tsx +224 -124
  51. package/src/loadBalancer/configure/http/GceHttpLoadBalancerModal.tsx +8 -2
  52. package/src/loadBalancer/configure/http/GceHttpLoadBalancerResourceEditors.spec.tsx +57 -22
  53. package/src/loadBalancer/configure/http/GceHttpLoadBalancerResourceEditors.tsx +21 -0
  54. package/src/loadBalancer/configure/http/GceHttpLoadBalancerRoutingEditor.spec.tsx +24 -20
  55. package/src/loadBalancer/configure/network/GceNetworkLoadBalancerEditor.spec.tsx +41 -32
  56. package/src/loadBalancer/configure/network/GceNetworkLoadBalancerModal.spec.tsx +35 -30
  57. package/src/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerEditor.spec.tsx +443 -0
  58. package/src/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerEditor.tsx +401 -0
  59. package/src/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerModal.spec.tsx +330 -0
  60. package/src/loadBalancer/configure/network/GceRegionalExternalNetworkLoadBalancerModal.tsx +348 -0
  61. package/src/loadBalancer/details/gceLoadBalancerDetails.spec.tsx +439 -35
  62. package/src/loadBalancer/details/gceLoadBalancerDetails.tsx +104 -21
  63. package/src/loadBalancer/httpLoadBalancerUtils.service.spec.ts +126 -0
  64. package/src/loadBalancer/httpLoadBalancerUtils.service.ts +27 -11
  65. package/src/loadBalancer/loadBalancer.setTransformer.spec.ts +165 -0
  66. package/src/loadBalancer/loadBalancer.setTransformer.ts +15 -2
  67. package/src/pipeline/stages/googleStageConfig.spec.js +22 -16
  68. package/src/securityGroup/configure/GceSecurityGroupModal.spec.tsx +177 -193
  69. package/src/securityGroup/configure/GceSecurityGroupModal.tsx +14 -2
  70. package/src/securityGroup/details/GceSecurityGroupDetails.spec.tsx +69 -78
  71. package/src/serverGroup/configure/serverGroupCommandBuilder.service.spec.js +35 -15
  72. package/src/serverGroup/configure/serverGroupConfiguration.service.js +7 -1
  73. package/src/serverGroup/configure/serverGroupConfiguration.service.spec.js +74 -4
  74. package/src/serverGroup/configure/wizard/GceCloneServerGroupModal.spec.tsx +593 -855
  75. package/src/serverGroup/configure/wizard/GceCloneServerGroupModal.tsx +120 -38
  76. package/src/serverGroup/configure/wizard/GceServerGroupWizard.types.ts +1 -0
  77. package/src/serverGroup/configure/wizard/GceServerGroupWizardPage.spec.tsx +53 -50
  78. package/src/serverGroup/configure/wizard/customInstance/GceCustomInstanceBuilder.spec.tsx +65 -49
  79. package/src/serverGroup/configure/wizard/hiddenMetadataKeys.value.js +1 -0
  80. package/src/serverGroup/configure/wizard/pages/AdvancedSettings.spec.tsx +101 -108
  81. package/src/serverGroup/configure/wizard/pages/AdvancedSettings.tsx +32 -28
  82. package/src/serverGroup/configure/wizard/pages/GceServerGroupFirewalls.spec.tsx +60 -59
  83. package/src/serverGroup/configure/wizard/pages/GceServerGroupLoadBalancers.spec.tsx +240 -114
  84. package/src/serverGroup/configure/wizard/pages/GceServerGroupLoadBalancers.tsx +152 -63
  85. package/src/serverGroup/configure/wizard/pages/Policies.spec.tsx +93 -48
  86. package/src/serverGroup/configure/wizard/pages/Policies.tsx +80 -55
  87. package/src/serverGroup/configure/wizard/pages/ServerGroupBasicSettings.spec.tsx +123 -94
  88. package/src/serverGroup/configure/wizard/pages/ServerGroupBasicSettings.tsx +24 -18
  89. package/src/serverGroup/configure/wizard/pages/ServerGroupCapacity.spec.tsx +152 -133
  90. package/src/serverGroup/configure/wizard/pages/ServerGroupCapacity.tsx +40 -37
  91. package/src/serverGroup/configure/wizard/pages/ServerGroupImageSettings.spec.tsx +66 -60
  92. package/src/serverGroup/configure/wizard/pages/ServerGroupImageSettings.tsx +8 -2
  93. package/src/serverGroup/configure/wizard/pages/ServerGroupInstanceType.spec.tsx +131 -101
  94. package/src/serverGroup/configure/wizard/pages/ServerGroupInstanceType.tsx +29 -28
  95. package/src/serverGroup/configure/wizard/zones/GceInstanceFlexibilityConfigurer.spec.tsx +104 -81
  96. package/src/serverGroup/details/GceInstanceFlexibilityPolicyDetails.spec.tsx +10 -11
  97. package/src/serverGroup/details/autoHealingPolicy/GceAutoHealingPolicyDetails.spec.tsx +51 -59
  98. package/src/serverGroup/details/autoHealingPolicy/modal/GceUpsertAutoHealingPolicyModal.spec.tsx +3 -3
  99. package/src/serverGroup/details/autoscalingPolicy/GceAutoscalingPolicyDetails.spec.tsx +49 -58
  100. package/src/serverGroup/details/autoscalingPolicy/modal/GceUpsertAutoscalingPolicyModal.spec.tsx +3 -3
  101. package/src/serverGroup/details/gceServerGroupDetails.spec.tsx +162 -141
  102. package/src/serverGroup/details/resize/GceResizeServerGroupModal.spec.tsx +102 -86
  103. package/src/serverGroup/details/resize/GceResizeServerGroupModal.tsx +4 -0
  104. package/src/serverGroup/details/rollback/GceRollbackServerGroupModal.spec.tsx +40 -31
  105. package/src/serverGroup/serverGroup.transformer.js +1 -0
  106. package/src/serverGroup/serverGroup.transformer.spec.js +48 -0
  107. package/src/validation/ApplicationNameValidator.spec.ts +1 -1
  108. /package/dist/{gce.logo2ae85d8704b1d6b8.png → gce.logoChI3w_A5.png} +0 -0
@@ -1,20 +1,28 @@
1
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
1
2
  import React from 'react';
2
- import { shallow } from 'enzyme';
3
3
 
4
4
  import { ReactModal } from '@spinnaker/core';
5
5
 
6
- import { GceProxyLoadBalancerModal } from '../common/GceProxyLoadBalancerModal';
7
- import { GceHttpLoadBalancerModal } from '../http/GceHttpLoadBalancerModal';
8
- import { GceNetworkLoadBalancerModal } from '../network/GceNetworkLoadBalancerModal';
9
6
  import {
10
7
  GCE_LOAD_BALANCER_CHOICES,
11
8
  GceLoadBalancerChoiceModal,
12
9
  getGceLoadBalancerModal,
13
10
  } from './GceLoadBalancerChoiceModal';
11
+ import { GceProxyLoadBalancerModal } from '../common/GceProxyLoadBalancerModal';
12
+ import { GceHttpLoadBalancerModal } from '../http/GceHttpLoadBalancerModal';
13
+ import { GceNetworkLoadBalancerModal } from '../network/GceNetworkLoadBalancerModal';
14
+ import { GceRegionalExternalNetworkLoadBalancerModal } from '../network/GceRegionalExternalNetworkLoadBalancerModal';
14
15
 
15
16
  describe('GceLoadBalancerChoiceModal', () => {
16
17
  const application = { name: 'fnord' } as any;
17
18
 
19
+ const spyOnAllGceLoadBalancerModals = (impl: (props: any) => Promise<any> = () => Promise.resolve() as any): void => {
20
+ vi.spyOn(GceNetworkLoadBalancerModal, 'show').mockImplementation(impl);
21
+ vi.spyOn(GceProxyLoadBalancerModal, 'show').mockImplementation(impl);
22
+ vi.spyOn(GceHttpLoadBalancerModal, 'show').mockImplementation(impl);
23
+ vi.spyOn(GceRegionalExternalNetworkLoadBalancerModal, 'show').mockImplementation(impl);
24
+ };
25
+
18
26
  it('routes every exposed GCE load balancer type to its React modal', () => {
19
27
  expect(GCE_LOAD_BALANCER_CHOICES.map(({ type }) => type)).toEqual([
20
28
  'NETWORK',
@@ -23,14 +31,17 @@ describe('GceLoadBalancerChoiceModal', () => {
23
31
  'SSL',
24
32
  'HTTP',
25
33
  'INTERNAL_MANAGED',
34
+ 'EXTERNAL_MANAGED',
35
+ 'REGIONAL_EXTERNAL_NETWORK',
26
36
  ]);
27
37
  expect(getGceLoadBalancerModal('NETWORK')).toBe(GceNetworkLoadBalancerModal);
28
38
  (['INTERNAL', 'TCP', 'SSL'] as const).forEach((type) => {
29
- expect(getGceLoadBalancerModal(type)).withContext(type).toBe(GceProxyLoadBalancerModal);
39
+ expect(getGceLoadBalancerModal(type), type).toBe(GceProxyLoadBalancerModal);
30
40
  });
31
- (['HTTP', 'INTERNAL_MANAGED'] as const).forEach((type) => {
32
- expect(getGceLoadBalancerModal(type)).withContext(type).toBe(GceHttpLoadBalancerModal);
41
+ (['HTTP', 'INTERNAL_MANAGED', 'EXTERNAL_MANAGED'] as const).forEach((type) => {
42
+ expect(getGceLoadBalancerModal(type), type).toBe(GceHttpLoadBalancerModal);
33
43
  });
44
+ expect(getGceLoadBalancerModal('REGIONAL_EXTERNAL_NETWORK')).toBe(GceRegionalExternalNetworkLoadBalancerModal);
34
45
  });
35
46
 
36
47
  it('advertises pipeline support only at the fully routed choice entry point', () => {
@@ -39,12 +50,12 @@ describe('GceLoadBalancerChoiceModal', () => {
39
50
 
40
51
  it('passes modal sizing as ReactModal dialog options instead of component props', () => {
41
52
  const result = Promise.resolve();
42
- const show = spyOn(ReactModal, 'show').and.returnValue(result);
53
+ const show = vi.spyOn(ReactModal, 'show').mockReturnValue(result);
43
54
 
44
55
  const opened = GceLoadBalancerChoiceModal.show({ application } as any);
45
56
 
46
57
  expect(opened).toBe(result);
47
- expect(show).toHaveBeenCalledOnceWith(
58
+ expect(show).toHaveBeenCalledExactlyOnceWith(
48
59
  GceLoadBalancerChoiceModal,
49
60
  { application },
50
61
  { dialogClassName: 'create-pipeline-modal-overflow-visible modal-lg' },
@@ -52,73 +63,69 @@ describe('GceLoadBalancerChoiceModal', () => {
52
63
  });
53
64
 
54
65
  it('renders native modal sections and controls', () => {
55
- const wrapper = shallow(<GceLoadBalancerChoiceModal application={application} />);
66
+ const { container } = render(<GceLoadBalancerChoiceModal application={application} />);
56
67
 
57
- expect(wrapper.find('.modal-header')).toHaveSize(1);
58
- expect(wrapper.find('.modal-body')).toHaveSize(1);
59
- expect(wrapper.find('.modal-footer')).toHaveSize(1);
60
- expect(wrapper.find('button.btn.btn-primary').text()).toContain('Configure Load Balancer');
68
+ expect(container.querySelectorAll('.modal-header')).toHaveLength(1);
69
+ expect(container.querySelectorAll('.modal-body')).toHaveLength(1);
70
+ expect(container.querySelectorAll('.modal-footer')).toHaveLength(1);
71
+ expect(screen.getByRole('button', { name: /Configure Load Balancer/ })).toBeInTheDocument();
61
72
  });
62
73
 
63
74
  it('renders each type card as a native pressed-state button', () => {
64
- const wrapper = shallow(<GceLoadBalancerChoiceModal application={application} />);
65
- const choices = wrapper.find('button.card');
75
+ const { container } = render(<GceLoadBalancerChoiceModal application={application} />);
76
+ const choices = Array.from(container.querySelectorAll<HTMLButtonElement>('button.card'));
66
77
 
67
- expect(choices).toHaveSize(GCE_LOAD_BALANCER_CHOICES.length);
78
+ expect(choices).toHaveLength(GCE_LOAD_BALANCER_CHOICES.length);
68
79
  choices.forEach((choice, index) => {
69
- expect(choice.prop('type')).withContext(GCE_LOAD_BALANCER_CHOICES[index].type).toBe('button');
70
- expect(choice.prop('aria-pressed'))
71
- .withContext(GCE_LOAD_BALANCER_CHOICES[index].type)
72
- .toBe(index === 0);
73
- expect(choice.prop('disabled')).withContext(GCE_LOAD_BALANCER_CHOICES[index].type).toBe(false);
80
+ expect(choice.type, GCE_LOAD_BALANCER_CHOICES[index].type).toBe('button');
81
+ expect(choice, GCE_LOAD_BALANCER_CHOICES[index].type).toHaveAttribute('aria-pressed', String(index === 0));
82
+ expect(choice, GCE_LOAD_BALANCER_CHOICES[index].type).not.toBeDisabled();
74
83
  });
75
84
 
76
- choices.at(2).simulate('click');
77
- wrapper.update();
85
+ fireEvent.click(choices[2]);
78
86
 
79
- expect(wrapper.find('button.card').at(0).prop('aria-pressed')).toBe(false);
80
- expect(wrapper.find('button.card').at(2).prop('aria-pressed')).toBe(true);
81
- expect(wrapper.find('button.card').at(2).hasClass('active')).toBe(true);
87
+ expect(choices[0]).toHaveAttribute('aria-pressed', 'false');
88
+ expect(choices[2]).toHaveAttribute('aria-pressed', 'true');
89
+ expect(choices[2]).toHaveClass('active');
82
90
  });
83
91
 
84
92
  it('opens every exposed type in create and pipeline modes', () => {
85
- spyOn(GceNetworkLoadBalancerModal, 'show').and.returnValue(Promise.resolve() as any);
86
- spyOn(GceProxyLoadBalancerModal, 'show').and.returnValue(Promise.resolve() as any);
87
- spyOn(GceHttpLoadBalancerModal, 'show').and.returnValue(Promise.resolve() as any);
93
+ spyOnAllGceLoadBalancerModals();
88
94
 
89
95
  ([false, true] as const).forEach((forPipelineConfig) => {
90
96
  GCE_LOAD_BALANCER_CHOICES.forEach((choice) => {
91
- const modal = new GceLoadBalancerChoiceModal({ application, forPipelineConfig } as any);
92
- modal.state = { ...modal.state, selectedChoice: choice };
93
-
94
- (modal as any).choose();
95
-
96
- expect(getGceLoadBalancerModal(choice.type).show)
97
- .withContext(`${choice.type} ${forPipelineConfig ? 'pipeline' : 'create'}`)
98
- .toHaveBeenCalledWith(
99
- jasmine.objectContaining({
100
- forPipelineConfig,
101
- isNew: !forPipelineConfig,
102
- loadBalancer: null,
103
- loadBalancerType: choice.type,
104
- mode: forPipelineConfig ? 'pipeline' : 'create',
105
- }),
106
- );
97
+ render(<GceLoadBalancerChoiceModal application={application} forPipelineConfig={forPipelineConfig} />);
98
+ fireEvent.click(choiceButton(choice.label));
99
+ fireEvent.click(screen.getByRole('button', { name: /Configure Load Balancer/ }));
100
+
101
+ expect(
102
+ getGceLoadBalancerModal(choice.type).show,
103
+ `${choice.type} ${forPipelineConfig ? 'pipeline' : 'create'}`,
104
+ ).toHaveBeenCalledWith(
105
+ expect.objectContaining({
106
+ forPipelineConfig,
107
+ isNew: !forPipelineConfig,
108
+ loadBalancer: null,
109
+ loadBalancerType: choice.type,
110
+ mode: forPipelineConfig ? 'pipeline' : 'create',
111
+ }),
112
+ );
113
+ cleanup();
107
114
  });
108
115
  });
109
116
  });
110
117
 
111
118
  it('opens a selected type in create mode without persisted data', () => {
112
- const closeModal = jasmine.createSpy('closeModal');
119
+ const closeModal = vi.fn();
113
120
  const result = Promise.resolve({ loadBalancerType: 'SSL' });
114
- const show = spyOn(GceProxyLoadBalancerModal, 'show').and.returnValue(result as any);
115
- const modal = new GceLoadBalancerChoiceModal({ application, closeModal } as any);
121
+ const show = vi.spyOn(GceProxyLoadBalancerModal, 'show').mockReturnValue(result as any);
122
+ render(<GceLoadBalancerChoiceModal application={application} closeModal={closeModal} />);
116
123
 
117
- modal.state = { ...modal.state, selectedChoice: GCE_LOAD_BALANCER_CHOICES[3] };
118
- (modal as any).choose();
124
+ fireEvent.click(choiceButton('SSL'));
125
+ fireEvent.click(screen.getByRole('button', { name: /Configure Load Balancer/ }));
119
126
 
120
- expect(show).toHaveBeenCalledOnceWith(
121
- jasmine.objectContaining({
127
+ expect(show).toHaveBeenCalledExactlyOnceWith(
128
+ expect.objectContaining({
122
129
  app: application,
123
130
  application,
124
131
  forPipelineConfig: false,
@@ -128,64 +135,58 @@ describe('GceLoadBalancerChoiceModal', () => {
128
135
  mode: 'create',
129
136
  }),
130
137
  );
131
- expect(closeModal).toHaveBeenCalledOnceWith(result);
138
+ expect(closeModal).toHaveBeenCalledExactlyOnceWith(result);
132
139
  });
133
140
 
134
141
  it('opens a selected type in pipeline mode and propagates its command promise', async () => {
135
- const closeModal = jasmine.createSpy('closeModal');
142
+ const closeModal = vi.fn();
136
143
  const command = { loadBalancerType: 'INTERNAL_MANAGED', type: 'upsertLoadBalancer' };
137
144
  const result = Promise.resolve(command);
138
- const show = spyOn(GceHttpLoadBalancerModal, 'show').and.returnValue(result);
139
- const modal = new GceLoadBalancerChoiceModal({ application, closeModal, forPipelineConfig: true } as any);
145
+ const show = vi.spyOn(GceHttpLoadBalancerModal, 'show').mockReturnValue(result);
146
+ render(<GceLoadBalancerChoiceModal application={application} closeModal={closeModal} forPipelineConfig={true} />);
140
147
 
141
- modal.state = { ...modal.state, selectedChoice: GCE_LOAD_BALANCER_CHOICES[5] };
142
- (modal as any).choose();
148
+ fireEvent.click(choiceButton('Internal HTTP(S)'));
149
+ fireEvent.click(screen.getByRole('button', { name: /Configure Load Balancer/ }));
143
150
 
144
- expect(show).toHaveBeenCalledOnceWith(
145
- jasmine.objectContaining({
151
+ expect(show).toHaveBeenCalledExactlyOnceWith(
152
+ expect.objectContaining({
146
153
  forPipelineConfig: true,
147
154
  loadBalancer: null,
148
155
  loadBalancerType: 'INTERNAL_MANAGED',
149
156
  mode: 'pipeline',
150
157
  }),
151
158
  );
152
- expect(closeModal).toHaveBeenCalledOnceWith(result);
153
- await expectAsync(closeModal.calls.mostRecent().args[0]).toBeResolvedTo(command);
159
+ expect(closeModal).toHaveBeenCalledExactlyOnceWith(result);
160
+ await expectAsync(closeModal.mock.lastCall[0]).toBeResolvedTo(command);
154
161
  });
155
162
 
156
163
  it('routes every exposed type directly to edit mode with the current load balancer', () => {
157
- spyOn(GceNetworkLoadBalancerModal, 'show').and.callFake((props: any) => Promise.resolve(props.loadBalancer) as any);
158
- spyOn(GceProxyLoadBalancerModal, 'show').and.callFake((props: any) => Promise.resolve(props.loadBalancer) as any);
159
- spyOn(GceHttpLoadBalancerModal, 'show').and.callFake((props: any) => Promise.resolve(props.loadBalancer) as any);
160
- const reactModalShow = spyOn(ReactModal, 'show');
164
+ spyOnAllGceLoadBalancerModals((props: any) => Promise.resolve(props.loadBalancer) as any);
165
+ const reactModalShow = vi.spyOn(ReactModal, 'show').mockReturnValue(undefined);
161
166
 
162
167
  GCE_LOAD_BALANCER_CHOICES.forEach((choice) => {
163
168
  const current = { account: 'account-a', loadBalancerType: choice.type, name: `fnord-${choice.type}` };
164
169
  GceLoadBalancerChoiceModal.show({ application, isNew: false, loadBalancer: current } as any);
165
170
 
166
- expect(getGceLoadBalancerModal(choice.type).show)
167
- .withContext(choice.type)
168
- .toHaveBeenCalledWith(
169
- jasmine.objectContaining({
170
- app: application,
171
- application,
172
- forPipelineConfig: false,
173
- isNew: false,
174
- loadBalancer: current,
175
- loadBalancerType: choice.type,
176
- mode: 'edit',
177
- }),
178
- );
171
+ expect(getGceLoadBalancerModal(choice.type).show, choice.type).toHaveBeenCalledWith(
172
+ expect.objectContaining({
173
+ app: application,
174
+ application,
175
+ forPipelineConfig: false,
176
+ isNew: false,
177
+ loadBalancer: current,
178
+ loadBalancerType: choice.type,
179
+ mode: 'edit',
180
+ }),
181
+ );
179
182
  });
180
183
 
181
184
  expect(reactModalShow).not.toHaveBeenCalled();
182
185
  });
183
186
 
184
187
  it('routes every existing type to pipeline mode when editing pipeline configuration', () => {
185
- spyOn(GceNetworkLoadBalancerModal, 'show').and.callFake((props: any) => Promise.resolve(props.loadBalancer) as any);
186
- spyOn(GceProxyLoadBalancerModal, 'show').and.callFake((props: any) => Promise.resolve(props.loadBalancer) as any);
187
- spyOn(GceHttpLoadBalancerModal, 'show').and.callFake((props: any) => Promise.resolve(props.loadBalancer) as any);
188
- const reactModalShow = spyOn(ReactModal, 'show');
188
+ spyOnAllGceLoadBalancerModals((props: any) => Promise.resolve(props.loadBalancer) as any);
189
+ const reactModalShow = vi.spyOn(ReactModal, 'show').mockReturnValue(undefined);
189
190
 
190
191
  GCE_LOAD_BALANCER_CHOICES.forEach((choice) => {
191
192
  const current = { account: 'account-a', loadBalancerType: choice.type, name: `fnord-${choice.type}` };
@@ -196,19 +197,17 @@ describe('GceLoadBalancerChoiceModal', () => {
196
197
  loadBalancer: current,
197
198
  } as any);
198
199
 
199
- expect(getGceLoadBalancerModal(choice.type).show)
200
- .withContext(choice.type)
201
- .toHaveBeenCalledWith(
202
- jasmine.objectContaining({
203
- app: application,
204
- application,
205
- forPipelineConfig: true,
206
- isNew: false,
207
- loadBalancer: current,
208
- loadBalancerType: choice.type,
209
- mode: 'pipeline',
210
- }),
211
- );
200
+ expect(getGceLoadBalancerModal(choice.type).show, choice.type).toHaveBeenCalledWith(
201
+ expect.objectContaining({
202
+ app: application,
203
+ application,
204
+ forPipelineConfig: true,
205
+ isNew: false,
206
+ loadBalancer: current,
207
+ loadBalancerType: choice.type,
208
+ mode: 'pipeline',
209
+ }),
210
+ );
212
211
  });
213
212
 
214
213
  expect(reactModalShow).not.toHaveBeenCalled();
@@ -216,17 +215,20 @@ describe('GceLoadBalancerChoiceModal', () => {
216
215
 
217
216
  ([undefined, '', 'http', 'UNKNOWN'] as const).forEach((persistedType) => {
218
217
  it(`blocks edit routing for unsupported persisted type ${String(persistedType)}`, () => {
219
- const networkShow = spyOn(GceNetworkLoadBalancerModal, 'show');
220
- const proxyShow = spyOn(GceProxyLoadBalancerModal, 'show');
221
- const httpShow = spyOn(GceHttpLoadBalancerModal, 'show');
218
+ const networkShow = vi.spyOn(GceNetworkLoadBalancerModal, 'show').mockReturnValue(undefined);
219
+ const proxyShow = vi.spyOn(GceProxyLoadBalancerModal, 'show').mockReturnValue(undefined);
220
+ const httpShow = vi.spyOn(GceHttpLoadBalancerModal, 'show').mockReturnValue(undefined);
221
+ const regionalExternalNetworkShow = vi
222
+ .spyOn(GceRegionalExternalNetworkLoadBalancerModal, 'show')
223
+ .mockReturnValue(undefined);
222
224
  const blockedResult = Promise.resolve();
223
- const reactModalShow = spyOn(ReactModal, 'show').and.returnValue(blockedResult);
225
+ const reactModalShow = vi.spyOn(ReactModal, 'show').mockReturnValue(blockedResult);
224
226
  const loadBalancer = { name: 'fnord', loadBalancerType: persistedType };
225
227
 
226
228
  const result = GceLoadBalancerChoiceModal.show({ application, isNew: false, loadBalancer } as any);
227
229
 
228
230
  expect(result).toBe(blockedResult);
229
- expect(reactModalShow).toHaveBeenCalledOnceWith(
231
+ expect(reactModalShow).toHaveBeenCalledExactlyOnceWith(
230
232
  GceLoadBalancerChoiceModal,
231
233
  { application, isNew: false, loadBalancer },
232
234
  { dialogClassName: 'create-pipeline-modal-overflow-visible modal-lg' },
@@ -234,12 +236,13 @@ describe('GceLoadBalancerChoiceModal', () => {
234
236
  expect(networkShow).not.toHaveBeenCalled();
235
237
  expect(proxyShow).not.toHaveBeenCalled();
236
238
  expect(httpShow).not.toHaveBeenCalled();
239
+ expect(regionalExternalNetworkShow).not.toHaveBeenCalled();
237
240
  });
238
241
  });
239
242
 
240
243
  ([undefined, 'http', 'UNKNOWN'] as const).forEach((persistedType) => {
241
244
  it(`renders a non-submittable blocked pipeline-edit state for ${String(persistedType)}`, () => {
242
- const wrapper = shallow(
245
+ const { container } = render(
243
246
  <GceLoadBalancerChoiceModal
244
247
  application={application}
245
248
  forPipelineConfig={true}
@@ -248,32 +251,70 @@ describe('GceLoadBalancerChoiceModal', () => {
248
251
  />,
249
252
  );
250
253
 
251
- expect(wrapper.find('[role="alert"]').text()).toContain('cannot be edited');
252
- expect(wrapper.find('[role="alert"]').text()).toContain(persistedType || 'missing');
253
- expect(wrapper.find('button.card')).toHaveSize(GCE_LOAD_BALANCER_CHOICES.length);
254
- wrapper.find('button.card').forEach((choice) => {
255
- expect(choice.prop('disabled')).toBe(true);
256
- expect(choice.prop('aria-pressed')).toBe(false);
254
+ expect(screen.getByRole('alert')).toHaveTextContent('cannot be edited');
255
+ expect(screen.getByRole('alert')).toHaveTextContent(persistedType || 'missing');
256
+ const choices = Array.from(container.querySelectorAll<HTMLButtonElement>('button.card'));
257
+ expect(choices).toHaveLength(GCE_LOAD_BALANCER_CHOICES.length);
258
+ choices.forEach((choice) => {
259
+ expect(choice).toBeDisabled();
260
+ expect(choice).toHaveAttribute('aria-pressed', 'false');
257
261
  });
258
- expect(wrapper.find('button.btn.btn-primary').prop('disabled')).toBe(true);
262
+ expect(screen.getByRole('button', { name: /Configure Load Balancer/ })).toBeDisabled();
259
263
  });
260
264
  });
261
265
 
262
266
  it('does not open a type modal when blocked submission is invoked programmatically', () => {
263
- const networkShow = spyOn(GceNetworkLoadBalancerModal, 'show');
264
- const proxyShow = spyOn(GceProxyLoadBalancerModal, 'show');
265
- const httpShow = spyOn(GceHttpLoadBalancerModal, 'show');
266
- const modal = new GceLoadBalancerChoiceModal({
267
- application,
268
- forPipelineConfig: true,
269
- isNew: false,
270
- loadBalancer: { name: 'fnord', loadBalancerType: 'http' },
271
- } as any);
272
-
273
- (modal as any).choose();
267
+ const networkShow = vi.spyOn(GceNetworkLoadBalancerModal, 'show').mockReturnValue(undefined);
268
+ const proxyShow = vi.spyOn(GceProxyLoadBalancerModal, 'show').mockReturnValue(undefined);
269
+ const httpShow = vi.spyOn(GceHttpLoadBalancerModal, 'show').mockReturnValue(undefined);
270
+ const regionalExternalNetworkShow = vi
271
+ .spyOn(GceRegionalExternalNetworkLoadBalancerModal, 'show')
272
+ .mockReturnValue(undefined);
273
+ render(
274
+ <GceLoadBalancerChoiceModal
275
+ application={application}
276
+ forPipelineConfig={true}
277
+ isNew={false}
278
+ loadBalancer={{ name: 'fnord', loadBalancerType: 'http' }}
279
+ />,
280
+ );
281
+
282
+ fireEvent.click(screen.getByRole('button', { name: /Configure Load Balancer/ }));
274
283
 
275
284
  expect(networkShow).not.toHaveBeenCalled();
276
285
  expect(proxyShow).not.toHaveBeenCalled();
277
286
  expect(httpShow).not.toHaveBeenCalled();
287
+ expect(regionalExternalNetworkShow).not.toHaveBeenCalled();
288
+ });
289
+
290
+ it('exposes EXTERNAL_MANAGED and REGIONAL_EXTERNAL_NETWORK choices', () => {
291
+ expect(GCE_LOAD_BALANCER_CHOICES.map(({ type }) => type)).toEqual([
292
+ 'NETWORK',
293
+ 'INTERNAL',
294
+ 'TCP',
295
+ 'SSL',
296
+ 'HTTP',
297
+ 'INTERNAL_MANAGED',
298
+ 'EXTERNAL_MANAGED',
299
+ 'REGIONAL_EXTERNAL_NETWORK',
300
+ ]);
301
+ });
302
+
303
+ it('routes EXTERNAL_MANAGED to the HTTP modal', () => {
304
+ expect(getGceLoadBalancerModal('EXTERNAL_MANAGED' as any)).toBe(GceHttpLoadBalancerModal);
305
+ });
306
+
307
+ it('routes REGIONAL_EXTERNAL_NETWORK to its dedicated modal', () => {
308
+ expect(getGceLoadBalancerModal('REGIONAL_EXTERNAL_NETWORK' as any)).toBe(
309
+ GceRegionalExternalNetworkLoadBalancerModal,
310
+ );
278
311
  });
279
312
  });
313
+
314
+ function choiceButton(label: string): HTMLButtonElement {
315
+ const button = screen.getByText(label, { selector: '.load-balancer-label' }).closest('button');
316
+ if (!(button instanceof HTMLButtonElement)) {
317
+ throw new Error(`No choice button found for ${label}`);
318
+ }
319
+ return button;
320
+ }
@@ -7,6 +7,7 @@ import type { GceLoadBalancerEditorMode, GceLoadBalancerType } from '../common';
7
7
  import { GceProxyLoadBalancerModal } from '../common/GceProxyLoadBalancerModal';
8
8
  import { GceHttpLoadBalancerModal } from '../http/GceHttpLoadBalancerModal';
9
9
  import { GceNetworkLoadBalancerModal } from '../network/GceNetworkLoadBalancerModal';
10
+ import { GceRegionalExternalNetworkLoadBalancerModal } from '../network/GceRegionalExternalNetworkLoadBalancerModal';
10
11
 
11
12
  import './GceLoadBalancerChoiceModal.less';
12
13
 
@@ -31,6 +32,16 @@ export const GCE_LOAD_BALANCER_CHOICES: IGceLoadBalancerChoice[] = [
31
32
  label: 'Internal HTTP(S)',
32
33
  description: 'Regional internal managed HTTP(S) load balancer.',
33
34
  },
35
+ {
36
+ type: 'EXTERNAL_MANAGED',
37
+ label: 'External managed HTTP(S)',
38
+ description: 'Regional external managed HTTP(S) load balancer.',
39
+ },
40
+ {
41
+ type: 'REGIONAL_EXTERNAL_NETWORK',
42
+ label: 'Regional external network',
43
+ description: 'Regional external pass-through network load balancer.',
44
+ },
34
45
  ];
35
46
 
36
47
  export interface IGceLoadBalancerChoiceModalProps extends IModalComponentProps {
@@ -47,7 +58,8 @@ interface IGceLoadBalancerChoiceModalState {
47
58
 
48
59
  export function getGceLoadBalancerModal(type: GceLoadBalancerType): IGceLoadBalancerModal {
49
60
  if (type === 'NETWORK') return GceNetworkLoadBalancerModal;
50
- if (type === 'HTTP' || type === 'INTERNAL_MANAGED') return GceHttpLoadBalancerModal;
61
+ if (type === 'HTTP' || type === 'INTERNAL_MANAGED' || type === 'EXTERNAL_MANAGED') return GceHttpLoadBalancerModal;
62
+ if (type === 'REGIONAL_EXTERNAL_NETWORK') return GceRegionalExternalNetworkLoadBalancerModal;
51
63
  return GceProxyLoadBalancerModal;
52
64
  }
53
65
 
@@ -1,7 +1,6 @@
1
+ import { fireEvent, render } from '@testing-library/react';
1
2
  import React from 'react';
2
- import { shallow } from 'enzyme';
3
3
 
4
- import { normalizeGceLoadBalancerCommand } from './gceLoadBalancerModels';
5
4
  import {
6
5
  applyGceProxyTypeConstraints,
7
6
  GCE_PROXY_TYPE_CONFIG,
@@ -9,6 +8,7 @@ import {
9
8
  getGceProxyResourceOptions,
10
9
  validateGceProxyLoadBalancerCommand,
11
10
  } from './GceProxyLoadBalancerEditor';
11
+ import { normalizeGceLoadBalancerCommand } from './gceLoadBalancerModels';
12
12
 
13
13
  describe('GceProxyLoadBalancerEditor', () => {
14
14
  it('defines frontend and backend protocols that match each Clouddriver proxy contract', () => {
@@ -50,7 +50,7 @@ describe('GceProxyLoadBalancerEditor', () => {
50
50
  expect(constrained.listeners[0].protocol).toBe('SSL');
51
51
  expect(constrained.listeners[0].certificate).toEqual({ name: 'cert-a' });
52
52
  expect(constrained.backendServices[0]).toEqual(
53
- jasmine.objectContaining({
53
+ expect.objectContaining({
54
54
  affinityCookieTtlSec: 300,
55
55
  connectionDrainingTimeoutSec: 45,
56
56
  portName: 'https',
@@ -107,7 +107,10 @@ describe('GceProxyLoadBalancerEditor', () => {
107
107
  { name: 'address-b', account: 'account-b', region: 'global' },
108
108
  ],
109
109
  backendServices: [{ name: 'backend-a', account: 'account-a', region: 'global' }],
110
- certificates: [{ name: 'cert-a', account: 'account-a' }],
110
+ certificates: [
111
+ { name: 'cert-a', account: 'account-a' },
112
+ { name: 'regional-cert', account: 'account-a', region: 'us-central1' },
113
+ ],
111
114
  healthChecks: [{ name: 'check-a', account: 'account-a' }],
112
115
  networks: [],
113
116
  regions: [{ name: 'global' }],
@@ -203,15 +206,15 @@ describe('GceProxyLoadBalancerEditor', () => {
203
206
  'create',
204
207
  ),
205
208
  );
206
- const wrapper = shallow(
207
- <GceProxyLoadBalancerEditor command={command} data={emptyData()} onChange={jasmine.createSpy('onChange')} />,
209
+ const { container } = render(
210
+ <GceProxyLoadBalancerEditor command={command} data={emptyData()} onChange={vi.fn()} />,
208
211
  );
209
212
 
210
- expect(wrapper.find('[data-field="certificate"]').exists()).toBe(true);
211
- expect(wrapper.find('[data-field="portName"]').exists()).toBe(true);
212
- expect(wrapper.find('[data-field="connectionDrainingTimeoutSec"]').exists()).toBe(true);
213
- expect(wrapper.find('[data-field="network"]').exists()).toBe(false);
214
- expect(wrapper.find('[data-field="subnet"]').exists()).toBe(false);
213
+ expect(field(container, 'certificate')).toBeInTheDocument();
214
+ expect(field(container, 'portName')).toBeInTheDocument();
215
+ expect(field(container, 'connectionDrainingTimeoutSec')).toBeInTheDocument();
216
+ expect(field(container, 'network')).not.toBeInTheDocument();
217
+ expect(field(container, 'subnet')).not.toBeInTheDocument();
215
218
  });
216
219
 
217
220
  it('renders regional network and subnet fields for INTERNAL without unsupported draining controls', () => {
@@ -221,14 +224,14 @@ describe('GceProxyLoadBalancerEditor', () => {
221
224
  'create',
222
225
  ),
223
226
  );
224
- const wrapper = shallow(
225
- <GceProxyLoadBalancerEditor command={command} data={emptyData()} onChange={jasmine.createSpy('onChange')} />,
227
+ const { container } = render(
228
+ <GceProxyLoadBalancerEditor command={command} data={emptyData()} onChange={vi.fn()} />,
226
229
  );
227
230
 
228
- expect(wrapper.find('[data-field="network"]').exists()).toBe(true);
229
- expect(wrapper.find('[data-field="subnet"]').exists()).toBe(true);
230
- expect(wrapper.find('[data-field="connectionDrainingTimeoutSec"]').exists()).toBe(false);
231
- expect(wrapper.find('[data-field="certificate"]').exists()).toBe(false);
231
+ expect(field(container, 'network')).toBeInTheDocument();
232
+ expect(field(container, 'subnet')).toBeInTheDocument();
233
+ expect(field(container, 'connectionDrainingTimeoutSec')).not.toBeInTheDocument();
234
+ expect(field(container, 'certificate')).not.toBeInTheDocument();
232
235
  });
233
236
 
234
237
  it('keeps generated listener and backend service names aligned when the load balancer name changes', () => {
@@ -238,12 +241,14 @@ describe('GceProxyLoadBalancerEditor', () => {
238
241
  'create',
239
242
  ),
240
243
  );
241
- const onChange = jasmine.createSpy('onChange');
242
- const wrapper = shallow(<GceProxyLoadBalancerEditor command={command} data={emptyData()} onChange={onChange} />);
244
+ const onChange = vi.fn();
245
+ const { container } = render(
246
+ <GceProxyLoadBalancerEditor command={command} data={emptyData()} onChange={onChange} />,
247
+ );
243
248
 
244
- wrapper.find('[data-field="name"] input').simulate('change', { target: { value: 'app-main' } });
249
+ fireEvent.change(fieldControl(container, 'name'), { target: { value: 'app-main' } });
245
250
 
246
- const updated = onChange.calls.mostRecent().args[0];
251
+ const updated = onChange.mock.lastCall[0];
247
252
  expect(updated.name).toBe('app-main');
248
253
  expect(updated.listeners[0].name).toBe('app-main');
249
254
  expect(updated.backendServices[0].name).toBe('app-main');
@@ -256,7 +261,7 @@ describe('GceProxyLoadBalancerEditor', () => {
256
261
  'create',
257
262
  ),
258
263
  );
259
- const onChange = jasmine.createSpy('onChange');
264
+ const onChange = vi.fn();
260
265
  const data = {
261
266
  ...emptyData(),
262
267
  addresses: [
@@ -269,11 +274,11 @@ describe('GceProxyLoadBalancerEditor', () => {
269
274
  },
270
275
  ],
271
276
  };
272
- const wrapper = shallow(<GceProxyLoadBalancerEditor command={command} data={data} onChange={onChange} />);
277
+ const { container } = render(<GceProxyLoadBalancerEditor command={command} data={data} onChange={onChange} />);
273
278
 
274
- wrapper.find('[data-field="address"] select').simulate('change', { target: { value: 'reserved-address' } });
279
+ fireEvent.change(fieldControl(container, 'address'), { target: { value: 'reserved-address' } });
275
280
 
276
- expect(onChange.calls.mostRecent().args[0].listeners[0].address).toEqual(data.addresses[0]);
281
+ expect(onChange.mock.lastCall[0].listeners[0].address).toEqual(data.addresses[0]);
277
282
  });
278
283
 
279
284
  it('replaces stale inline state with the complete normalized reader health check', () => {
@@ -290,7 +295,7 @@ describe('GceProxyLoadBalancerEditor', () => {
290
295
  'edit',
291
296
  ),
292
297
  );
293
- const onChange = jasmine.createSpy('onChange');
298
+ const onChange = vi.fn();
294
299
  const data = {
295
300
  ...emptyData(),
296
301
  healthChecks: [
@@ -312,17 +317,17 @@ describe('GceProxyLoadBalancerEditor', () => {
312
317
  },
313
318
  ],
314
319
  };
315
- const wrapper = shallow(<GceProxyLoadBalancerEditor command={command} data={data} onChange={onChange} />);
320
+ const { container } = render(<GceProxyLoadBalancerEditor command={command} data={data} onChange={onChange} />);
316
321
 
317
- wrapper.find('[data-field="healthCheck"] select').simulate('change', { target: { value: 'new-check' } });
322
+ fireEvent.change(fieldControl(container, 'healthCheck'), { target: { value: 'new-check' } });
318
323
 
319
- const updated = onChange.calls.mostRecent().args[0];
324
+ const updated = onChange.mock.lastCall[0];
320
325
  expect(updated.backendServices[0].healthCheck).toEqual({
321
326
  name: 'new-check',
322
327
  selfLink: 'projects/test/global/healthChecks/new-check',
323
328
  });
324
329
  expect(updated.healthChecks).toEqual([
325
- jasmine.objectContaining({
330
+ expect.objectContaining({
326
331
  checkIntervalSec: 15,
327
332
  healthCheckType: 'HTTP',
328
333
  healthyThreshold: 2,
@@ -351,3 +356,15 @@ function emptyData(): any {
351
356
  subnets: [],
352
357
  };
353
358
  }
359
+
360
+ function field(container: HTMLElement, name: string): HTMLElement | null {
361
+ return container.querySelector(`[data-field="${name}"]`);
362
+ }
363
+
364
+ function fieldControl(container: HTMLElement, name: string): HTMLInputElement | HTMLSelectElement {
365
+ const control = field(container, name)?.querySelector('input, select');
366
+ if (!(control instanceof HTMLInputElement || control instanceof HTMLSelectElement)) {
367
+ throw new Error(`No control found for ${name}`);
368
+ }
369
+ return control;
370
+ }
@@ -133,7 +133,8 @@ export function getGceProxyResourceOptions(
133
133
  references(backend),
134
134
  ),
135
135
  certificates: mergeGceResourceOptions(
136
- accountScoped(data.certificates as IGceProxyDataItem[]),
136
+ // Global SSL proxies cannot use the regional certificates cached for regional HTTPS proxies.
137
+ accountScoped(data.certificates as IGceProxyDataItem[]).filter((item) => !item.region),
137
138
  references(listener?.certificate),
138
139
  ),
139
140
  healthChecks: mergeGceResourceOptions(