@spinnaker/google 2026.3.2 → 2026.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,47 +1,47 @@
1
- import { cloneDeep } from 'lodash';
1
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { UIRouterContext, UIRouterReact } from '@uirouter/react';
2
3
  import React from 'react';
3
- import { mount, shallow } from 'enzyme';
4
- import { UIRouterReact } from '@uirouter/react';
5
4
 
6
5
  import {
7
6
  AccountService,
8
7
  createDeckRuntime,
8
+ DeckRuntimeContext,
9
9
  NetworkReader,
10
10
  ReactModal,
11
11
  SubnetReader,
12
- WizardModal,
13
- WizardPage,
12
+ TaskMonitor,
13
+ TaskReader,
14
14
  } from '@spinnaker/core';
15
15
 
16
16
  import {
17
17
  GceCloneServerGroupModal as RoutedGceCloneServerGroupModal,
18
18
  GceCloneServerGroupModalComponent as GceCloneServerGroupModal,
19
+ initializePipelineCreateCommand,
20
+ reconcileGceServerGroupCommand,
19
21
  transformGceServerGroupCommand,
20
22
  } from './GceCloneServerGroupModal';
21
- import { GceServerGroupWizardAdapter } from './GceServerGroupWizardAdapter';
23
+ import { validateGceServerGroupCommand } from './GceServerGroupWizard.helpers';
22
24
  import type { IGceServerGroupCommand, IGceServerGroupWizardAdapter } from './GceServerGroupWizard.types';
23
25
  import { registerGoogleProvider } from '../../../gce.module';
24
26
  import { GceHealthCheckReader } from '../../../healthCheck/healthCheck.read.service';
25
27
  import { GceImageReader } from '../../../image';
26
28
 
27
29
  const application = {
30
+ getDataSource: vi.fn(),
28
31
  name: 'fnord',
29
- serverGroups: {
30
- onNextRefresh: jasmine.createSpy('onNextRefresh'),
31
- refresh: jasmine.createSpy('refresh'),
32
- },
32
+ serverGroups: { onNextRefresh: vi.fn(), refresh: vi.fn() },
33
33
  } as any;
34
34
 
35
35
  describe('GceCloneServerGroupModal', () => {
36
36
  beforeEach(() => {
37
- application.serverGroups.onNextRefresh.calls.reset();
38
- application.serverGroups.refresh.calls.reset();
37
+ application.serverGroups.onNextRefresh.mockReset();
38
+ application.serverGroups.refresh.mockReset();
39
39
  });
40
40
 
41
41
  it('opens as a wizard modal', () => {
42
42
  const props = buildProps(buildCommand());
43
43
  const runtimeServices = {} as any;
44
- spyOn(ReactModal, 'show').and.returnValue(Promise.resolve());
44
+ vi.spyOn(ReactModal, 'show').mockResolvedValue(undefined);
45
45
 
46
46
  GceCloneServerGroupModal.show(props, runtimeServices);
47
47
 
@@ -54,26 +54,9 @@ describe('GceCloneServerGroupModal', () => {
54
54
  });
55
55
 
56
56
  it('renders the eight GCE pages in parity order without requiring a source template', () => {
57
- const wrapper = shallow(<GceCloneServerGroupModal {...buildProps(buildCommand(), buildAdapter())} />, {
58
- disableLifecycleMethods: true,
59
- } as any);
60
-
61
- const wizard = wrapper.find(WizardModal);
62
- expect(wizard.exists()).toBe(true);
63
- const pages = shallow(
64
- <div>
65
- {wizard.prop('render')({
66
- formik: { values: buildCommand() } as any,
67
- nextIdx: (() => {
68
- let index = 0;
69
- return () => ++index;
70
- })(),
71
- wizard: {} as any,
72
- })}
73
- </div>,
74
- );
57
+ renderModal(buildCommand(), buildAdapter({ configureCommand: vi.fn().mockResolvedValue(buildCommand()) }));
75
58
 
76
- expect(pages.find(WizardPage).map((page) => page.prop('label'))).toEqual([
59
+ expect(screen.getAllByRole('listitem').map((item) => item.textContent?.trim())).toEqual([
77
60
  'Basic Settings',
78
61
  'Image',
79
62
  'Instance Type',
@@ -86,100 +69,59 @@ describe('GceCloneServerGroupModal', () => {
86
69
  });
87
70
 
88
71
  it('shares one authoritative command state across every wizard page', () => {
89
- const adapter = buildAdapter();
90
- const wrapper = shallow(<GceCloneServerGroupModal {...buildProps(buildCommand(), adapter)} />, {
91
- disableLifecycleMethods: true,
92
- } as any);
93
- const wizard = wrapper.find(WizardModal);
94
- const pages = shallow(
95
- <div>
96
- {wizard.prop('render')({
97
- formik: { values: buildCommand() } as any,
98
- nextIdx: () => 1,
99
- wizard: {} as any,
100
- })}
101
- </div>,
102
- );
103
- const commandStates = pages.find(WizardPage).map((page) => {
104
- const renderedPage = shallow(
105
- <div>{page.prop('render')({ innerRef: React.createRef(), onLoadingChanged: () => undefined })}</div>,
106
- );
107
- return renderedPage.childAt(0).prop('commandState');
108
- });
72
+ const handlers = buildInitializationHandlers();
73
+ const configured = buildCommand(handlers);
74
+ const reconciled = reconcileGceServerGroupCommand(buildCommand(), configured);
109
75
 
110
- expect(commandStates.every((commandState) => commandState === commandStates[0])).toBe(true);
111
- expect(commandStates[0]).toBeDefined();
76
+ Object.keys(handlers).forEach((handler) => expect(reconciled[handler]).toBe(handlers[handler]));
112
77
  });
113
78
 
114
79
  it('uses the registered runtime-owned GCE configuration service in the default React path', async () => {
115
80
  registerGoogleProvider();
116
81
  const runtime = createDeckRuntime(new UIRouterReact());
117
- const getAllSecurityGroups = spyOn(runtime.services.securityGroupReader, 'getAllSecurityGroups').and.resolveTo({});
118
- const listLoadBalancers = spyOn(runtime.services.loadBalancerReader, 'listLoadBalancers').and.resolveTo([]);
119
- spyOn(AccountService, 'getCredentialsKeyedByAccount').and.resolveTo({});
120
- spyOn(AccountService, 'getAllAccountDetailsForProvider').and.resolveTo([]);
121
- spyOn(AccountService, 'listAccounts').and.resolveTo([]);
122
- spyOn(NetworkReader, 'listNetworksByProvider').and.resolveTo([]);
123
- spyOn(SubnetReader, 'listSubnetsByProvider').and.resolveTo([]);
124
- spyOn(GceImageReader, 'findImages').and.resolveTo([]);
125
- spyOn(GceHealthCheckReader.prototype, 'listHealthChecks').and.resolveTo([]);
126
- const getDelegate = spyOn(runtime.services.providerServiceDelegate, 'getDelegate').and.callThrough();
82
+ const getAllSecurityGroups = vi
83
+ .spyOn(runtime.services.securityGroupReader, 'getAllSecurityGroups')
84
+ .mockResolvedValue({});
85
+ const listLoadBalancers = vi.spyOn(runtime.services.loadBalancerReader, 'listLoadBalancers').mockResolvedValue([]);
86
+ vi.spyOn(AccountService, 'getCredentialsKeyedByAccount').mockResolvedValue({});
87
+ vi.spyOn(AccountService, 'getAllAccountDetailsForProvider').mockResolvedValue([]);
88
+ vi.spyOn(AccountService, 'listAccounts').mockResolvedValue([]);
89
+ vi.spyOn(NetworkReader, 'listNetworksByProvider').mockResolvedValue([]);
90
+ vi.spyOn(SubnetReader, 'listSubnetsByProvider').mockResolvedValue([]);
91
+ vi.spyOn(GceImageReader, 'findImages').mockResolvedValue([]);
92
+ vi.spyOn(GceHealthCheckReader.prototype, 'listHealthChecks').mockResolvedValue([]);
93
+ const getDelegate = vi.spyOn(runtime.services.providerServiceDelegate, 'getDelegate');
127
94
 
128
95
  try {
129
- const modal = new GceCloneServerGroupModal(buildProps(buildCommand())) as any;
130
- modal.context = runtime;
131
- const wizard = modal.render() as React.ReactElement<any>;
132
- const pages = shallow(
133
- <div>
134
- {wizard.props.render({
135
- formik: { values: buildCommand() } as any,
136
- nextIdx: () => 1,
137
- wizard: {} as any,
138
- })}
139
- </div>,
140
- );
141
- const adapters = pages.find(WizardPage).map((page) => {
142
- const renderedPage = shallow(
143
- <div>{page.prop('render')({ innerRef: React.createRef(), onLoadingChanged: () => undefined })}</div>,
144
- );
145
- return renderedPage.childAt(0).prop('adapter');
146
- });
96
+ renderModal(buildCommand({ backingData: undefined, securityGroups: [], tags: [] }), undefined, runtime.services);
147
97
 
148
- expect(getDelegate.calls.allArgs()).toEqual([
98
+ await waitFor(() => expect(listLoadBalancers).toHaveBeenCalledWith('gce'));
99
+ await waitFor(() => expect(getAllSecurityGroups).toHaveBeenCalled());
100
+ // Every page shares the single adapter the modal built from the runtime delegates.
101
+ expect(getDelegate.mock.calls).toEqual([
149
102
  ['gce', 'serverGroup.commandBuilder'],
150
103
  ['gce', 'serverGroup.configurationService'],
151
104
  ]);
152
- expect(adapters.every((adapter) => adapter === adapters[0])).toBe(true);
153
- expect(adapters[0]).toEqual(jasmine.any(GceServerGroupWizardAdapter));
154
-
155
- const configuredCommand = await adapters[0].configureCommand(
156
- application,
157
- buildCommand({ backingData: undefined, securityGroups: [], tags: [] }),
158
- );
159
- await adapters[0].applyConfigurationRefresh(configuredCommand, 'refreshLoadBalancers');
160
- await adapters[0].applyConfigurationRefresh(configuredCommand, 'refreshSecurityGroups');
161
-
162
- expect(getAllSecurityGroups).toHaveBeenCalledTimes(2);
163
- expect(listLoadBalancers).toHaveBeenCalledTimes(2);
164
- expect(listLoadBalancers).toHaveBeenCalledWith('gce');
165
105
  } finally {
166
106
  runtime.dispose();
167
107
  }
168
108
  });
169
109
 
170
- it('exposes command validation to the wizard', () => {
171
- const wrapper = shallow(<GceCloneServerGroupModal {...buildProps(buildCommand(), buildAdapter())} />, {
172
- disableLifecycleMethods: true,
173
- } as any);
174
- const validate = wrapper.find(WizardModal).prop('validate');
110
+ it('exposes command validation to the wizard', async () => {
111
+ renderModal(
112
+ buildCommand({ credentials: '' }),
113
+ buildAdapter({ configureCommand: vi.fn().mockResolvedValue(buildCommand({ credentials: '' })) }),
114
+ );
115
+ expect(await screen.findByText('Account required.')).toBeInTheDocument();
116
+ expect(screen.getByRole('button', { name: 'Done' })).toBeDisabled();
175
117
 
176
- expect(validate({ ...buildCommand(), credentials: '', capacity: { desired: null } } as any)).toEqual(
177
- jasmine.objectContaining({
118
+ expect(validateGceServerGroupCommand({ ...buildCommand(), credentials: '', capacity: { desired: null } })).toEqual(
119
+ expect.objectContaining({
178
120
  capacity: { desired: 'Desired capacity required.' },
179
121
  credentials: 'Account required.',
180
122
  }),
181
123
  );
182
- expect(validate(buildCommand())).toEqual({});
124
+ expect(validateGceServerGroupCommand(buildCommand())).toEqual({});
183
125
  });
184
126
 
185
127
  it('turns a pipeline template-selection placeholder into an empty create command', async () => {
@@ -192,20 +134,21 @@ describe('GceCloneServerGroupModal', () => {
192
134
  stage: { refId: '1' },
193
135
  },
194
136
  } as any;
195
- const adapter = buildAdapter();
196
- adapter.buildNewServerGroupCommand.and.resolveTo(buildCommand());
197
- adapter.configureCommand.and.callFake(async (_application: any, command: IGceServerGroupCommand) => command);
198
- const modal = new GceCloneServerGroupModal(buildProps(placeholder, adapter)) as any;
199
- spyOn(modal, 'setState').and.callFake((state: any) => (modal.state = { ...modal.state, ...state }));
200
-
201
- await modal.configureCommand();
137
+ const adapter = buildAdapter({
138
+ buildNewServerGroupCommand: vi.fn().mockResolvedValue(buildCommand()),
139
+ configureCommand: vi
140
+ .fn()
141
+ .mockImplementation(async (_application: any, command: IGceServerGroupCommand) => command),
142
+ });
143
+ renderModal(placeholder, adapter);
202
144
 
145
+ await waitFor(() => expect(adapter.configureCommand).toHaveBeenCalled());
203
146
  expect(adapter.buildNewServerGroupCommand).toHaveBeenCalledWith(application, { mode: 'createPipeline' });
204
147
  expect(adapter.configureCommand).toHaveBeenCalledWith(
205
148
  application,
206
- jasmine.objectContaining({
149
+ expect.objectContaining({
207
150
  credentials: 'gce-account',
208
- viewState: jasmine.objectContaining({
151
+ viewState: expect.objectContaining({
209
152
  disableImageSelection: true,
210
153
  expectedArtifacts: [{ id: 'expected-image' }],
211
154
  mode: 'createPipeline',
@@ -217,94 +160,82 @@ describe('GceCloneServerGroupModal', () => {
217
160
  }),
218
161
  }),
219
162
  );
220
- expect(modal.state.command.credentials).toBe('gce-account');
163
+ expect(await screen.findByRole('button', { name: 'Add' })).toBeInTheDocument();
164
+ expect(initializePipelineCreateCommand(buildCommand(), placeholder).credentials).toBe('gce-account');
221
165
  });
222
166
 
223
167
  it('shows a recoverable error when initialization fails', async () => {
224
- const adapter = buildAdapter();
225
- let initializationError = true;
226
- adapter.configureCommand.and.callFake(() =>
227
- initializationError ? Promise.reject(new Error('network unavailable')) : Promise.resolve(buildCommand()),
228
- );
168
+ const adapter = buildAdapter({ configureCommand: vi.fn().mockRejectedValue(new Error('network unavailable')) });
229
169
  const props = buildProps(buildCommand({ backingData: undefined }), adapter);
230
- const modal = new GceCloneServerGroupModal(props) as any;
231
- spyOn(modal, 'setState').and.callFake((state: any, callback?: () => void) => {
232
- modal.state = { ...modal.state, ...state };
233
- callback?.();
234
- });
235
-
236
- await modal.configureCommand();
170
+ render(<GceCloneServerGroupModal {...props} />);
237
171
 
238
- const errorState = shallow(modal.render());
239
- expect(errorState.find('.gce-server-group-initialization-error').text()).toContain(
240
- 'Unable to load the resources required to configure this server group. Check your connection and try again.',
241
- );
242
- errorState.find('.gce-server-group-initialization-close').simulate('click');
172
+ expect(await screen.findByRole('alert')).toHaveTextContent('Unable to load the resources required');
173
+ fireEvent.click(screen.getAllByRole('button', { name: 'Close' })[1]);
243
174
  expect(props.dismissModal).toHaveBeenCalled();
244
175
 
245
- initializationError = false;
246
- await modal.retryConfiguration();
247
- expect(adapter.configureCommand).toHaveBeenCalledTimes(2);
248
- expect(modal.state.initializationError).toBe(false);
249
- expect(modal.state.loaded).toBe(true);
176
+ adapter.configureCommand.mockResolvedValue(buildCommand());
177
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
178
+ await waitFor(() => expect(adapter.configureCommand).toHaveBeenCalledTimes(2));
179
+ expect(await screen.findByRole('button', { name: 'Done' })).toBeEnabled();
180
+ expect(screen.queryByText(/Unable to load the resources required/)).not.toBeInTheDocument();
250
181
  });
251
182
 
252
183
  ['create', 'clone', 'createPipeline', 'editPipeline'].forEach((mode) => {
253
184
  it(`hydrates deferred ${mode} backing data and handlers into Formik`, async () => {
254
- const command = buildCommand({
255
- backingData: undefined,
256
- viewState: { ...buildCommand().viewState, mode },
257
- });
258
185
  const request = deferred<IGceServerGroupCommand>();
259
- const adapter = buildAdapter();
260
- adapter.configureCommand.and.returnValue(request.promise);
261
- const wrapper = mount(<GceCloneServerGroupModal {...buildProps(command, adapter)} />);
186
+ const writer = vi.fn().mockReturnValue(new Promise(() => undefined));
187
+ const rendered = renderModal(
188
+ buildCommand({ backingData: undefined, viewState: { ...buildCommand().viewState, mode } }),
189
+ buildAdapter({ configureCommand: vi.fn().mockReturnValue(request.promise) }),
190
+ { serverGroupWriter: { cloneServerGroup: writer } },
191
+ );
262
192
 
263
- expect(wrapper.find(WizardModal).key()).toBe('loading');
193
+ expect(screen.getByRole('button', { name: 'Done' })).toBeDisabled();
264
194
  const handlers = buildInitializationHandlers();
265
- request.resolve(
266
- buildCommand({
267
- ...handlers,
268
- backingData: { filtered: { regions: ['hydrated-region'] } },
269
- viewState: { ...buildCommand().viewState, mode },
270
- }),
195
+ await act(async () =>
196
+ request.resolve(
197
+ buildCommand({
198
+ ...handlers,
199
+ backingData: {
200
+ ...buildCommand().backingData,
201
+ filtered: { ...buildCommand().backingData.filtered, regions: ['us-central1', 'hydrated-region'] },
202
+ },
203
+ viewState: { ...buildCommand().viewState, mode },
204
+ }),
205
+ ),
206
+ );
207
+
208
+ const regionOptions = Array.from((await screen.findByLabelText('Region')).querySelectorAll('option')).map(
209
+ (option) => option.value,
271
210
  );
272
- await request.promise;
273
- wrapper.update();
274
-
275
- const wizard = wrapper.find(WizardModal);
276
- const formikValues = (wizard.instance() as WizardModal<IGceServerGroupCommand>).formik.values;
277
- expect(wizard.key()).toBe('loaded');
278
- expect(formikValues.backingData.filtered.regions).toEqual(['hydrated-region']);
279
- Object.keys(handlers).forEach((handler) => expect(formikValues[handler]).toBe(handlers[handler]));
280
- wrapper.unmount();
211
+ expect(regionOptions).toContain('hydrated-region');
212
+
213
+ fireEvent.click(await screen.findByRole('button', { name: 'Done' }));
214
+ await waitFor(() => expect(rendered.props.closeModal.mock.calls.length + writer.mock.calls.length).toBe(1));
215
+ const submitted = mode.endsWith('Pipeline')
216
+ ? rendered.props.closeModal.mock.calls[0][0]
217
+ : writer.mock.calls[0][0];
218
+ expect(submitted.backingData.filtered.regions).toEqual(['us-central1', 'hydrated-region']);
219
+ Object.keys(handlers).forEach((handler) => expect(submitted[handler]).toBe(handlers[handler]));
281
220
  });
282
221
  });
283
222
 
284
223
  it('runs the initialization cascade in order against the latest command after configuration', async () => {
285
- const command = buildCommand({ stack: 'original' });
286
224
  const request = deferred<IGceServerGroupCommand>();
287
- const adapter = buildAdapter();
288
- adapter.configureCommand.and.returnValue(request.promise);
289
- const modal = new GceCloneServerGroupModal(buildProps(command, adapter)) as any;
290
- spyOn(modal, 'setState').and.callFake((state: any) => (modal.state = { ...modal.state, ...state }));
291
- const formik: any = {
292
- setValues: jasmine
293
- .createSpy('setValues')
294
- .and.callFake((values: IGceServerGroupCommand) => (formik.values = values)),
295
- values: buildCommand({ stack: 'original' }),
296
- };
297
- modal.formik = formik;
225
+ renderModal(
226
+ buildCommand({ stack: 'original', viewState: { ...buildCommand().viewState, mode: 'editPipeline' } }),
227
+ buildAdapter({ configureCommand: vi.fn().mockReturnValue(request.promise) }),
228
+ );
229
+ fireEvent.change(await screen.findByLabelText('Stack'), { target: { value: 'edited' } });
298
230
  const calls: string[] = [];
299
- const handlers = buildInitializationHandlers((handler, latestCommand) => {
231
+ const handlers = buildInitializationHandlers((handler, command) => {
300
232
  calls.push(handler);
301
- expect(latestCommand.stack).toBe('edited');
233
+ expect(command.stack).toBe('edited');
302
234
  });
303
235
 
304
- const configure = modal.configureCommand();
305
- formik.values = buildCommand({ stack: 'edited' });
306
- request.resolve(buildCommand(handlers));
307
- await configure;
236
+ await act(async () =>
237
+ request.resolve(buildCommand({ ...handlers, viewState: { ...buildCommand().viewState, mode: 'editPipeline' } })),
238
+ );
308
239
 
309
240
  expect(calls).toEqual([
310
241
  'credentialsChanged',
@@ -314,495 +245,166 @@ describe('GceCloneServerGroupModal', () => {
314
245
  'zoneChanged',
315
246
  'customInstanceChanged',
316
247
  ]);
317
- Object.keys(handlers).forEach((handler) => expect(handlers[handler]).toHaveBeenCalledWith(formik.values));
318
248
  });
319
249
 
320
- it('preserves an untouched clone with unavailable zonal and load balancer references', async () => {
321
- const persistedImplicitFirewall = {
322
- id: 'persisted-implicit-firewall',
323
- name: 'persisted-implicit-firewall',
324
- network: 'persisted-network',
325
- targetTags: ['persisted-tag'],
326
- };
327
- const command = buildCommand({
328
- backingData: {
329
- allImages: [{ imageName: 'persisted-image' }],
330
- filtered: {
331
- cpuPlatforms: ['persisted-cpu-platform'],
332
- healthChecks: [{ kind: 'http', name: 'persisted-health-check', selfLink: 'persisted-health-check-url' }],
333
- instanceTypes: ['persisted-instance-type'],
334
- loadBalancerIndex: {
335
- 'known-lb': {
336
- backendServices: ['known-backend-old'],
337
- listeners: [{ name: 'known-listener-old' }],
338
- loadBalancerType: 'HTTP',
339
- name: 'known-lb',
340
- },
341
- 'persisted-lb': {
342
- backendServices: ['persisted-backend'],
343
- listeners: [{ name: 'persisted-listener' }],
344
- loadBalancerType: 'INTERNAL_MANAGED',
345
- name: 'persisted-lb',
346
- },
347
- },
348
- loadBalancers: ['known-lb', 'persisted-lb'],
349
- networks: ['persisted-network'],
350
- securityGroups: [{ id: 'known-firewall' }, { id: 'persisted-firewall' }],
351
- subnets: ['persisted-subnet'],
352
- zones: ['persisted-zone'],
353
- },
354
- },
355
- autoHealingPolicy: {
356
- healthCheck: 'persisted-health-check',
357
- healthCheckKind: 'http',
358
- healthCheckUrl: 'persisted-health-check-url',
359
- initialDelaySec: 300,
360
- },
361
- backendServiceMetadata: 'known-backend-old, persisted-backend' as any,
362
- backendServices: {
363
- 'known-lb': ['known-backend-old'],
364
- 'persisted-lb': ['persisted-backend'],
365
- },
250
+ it('preserves an untouched clone with unavailable zonal and load balancer references', () => {
251
+ const persisted = buildCommand({
252
+ backendServices: { 'persisted-lb': ['persisted-backend'] },
366
253
  image: 'persisted-image',
367
- implicitSecurityGroups: [persistedImplicitFirewall],
368
- instanceMetadata: { owner: 'delivery' },
369
254
  instanceType: 'persisted-instance-type',
370
- loadBalancerMetadata: {
371
- 'global-load-balancer-names': ['known-listener-old'],
372
- 'load-balancer-names': ['persisted-listener'],
373
- },
374
- loadBalancers: ['known-lb', 'persisted-lb'],
375
- minCpuPlatform: 'persisted-cpu-platform',
255
+ loadBalancers: ['persisted-lb'],
376
256
  network: 'persisted-network',
377
- securityGroups: ['known-firewall', 'persisted-firewall'],
257
+ securityGroups: ['persisted-firewall'],
378
258
  subnet: 'persisted-subnet',
379
259
  viewState: { ...buildCommand().viewState, mode: 'clone' },
380
260
  zone: 'persisted-zone',
381
261
  });
382
- const handlers = buildInitializationHandlers((handler, initializedCommand) => {
383
- if (handler !== 'credentialsChanged') {
384
- return;
385
- }
386
- initializedCommand.instanceType = null;
387
- delete initializedCommand.minCpuPlatform;
388
- initializedCommand.zone = 'available-zone';
389
- initializedCommand.loadBalancers = ['known-lb'];
390
- initializedCommand.backendServices = { 'known-lb': ['known-backend-new'] };
391
- initializedCommand.backendServiceMetadata = ['known-backend-new'];
392
- initializedCommand.image = 'available-image';
393
- initializedCommand.network = 'available-network';
394
- initializedCommand.subnet = 'available-subnet';
395
- initializedCommand.securityGroups = ['known-firewall'];
396
- initializedCommand.implicitSecurityGroups = [
397
- { id: 'known-implicit-firewall', network: 'available-network', targetTags: [] },
398
- ];
399
- initializedCommand.autoHealingPolicy = {
400
- ...initializedCommand.autoHealingPolicy,
401
- healthCheck: 'available-health-check',
402
- healthCheckKind: 'https',
403
- healthCheckUrl: 'available-health-check-url',
404
- };
405
- initializedCommand.loadBalancerMetadata = {
406
- 'global-load-balancer-names': ['known-listener-new'],
407
- };
408
- });
409
- const configured = buildCommand({
410
- ...handlers,
411
- backingData: {
412
- accounts: ['gce-account'],
413
- allImages: [{ imageName: 'available-image' }],
414
- filtered: {
415
- cpuPlatforms: ['(Automatic)'],
416
- healthChecks: [{ kind: 'https', name: 'available-health-check', selfLink: 'available-health-check-url' }],
417
- instanceTypes: ['available-instance-type'],
418
- loadBalancerIndex: {
419
- 'known-lb': {
420
- backendServices: ['known-backend-new'],
421
- listeners: [{ name: 'known-listener-new' }],
422
- loadBalancerType: 'HTTP',
423
- name: 'known-lb',
424
- },
425
- },
426
- loadBalancers: ['known-lb'],
427
- networks: [{ id: 'available-network' }],
428
- regions: ['us-central1'],
429
- securityGroups: [{ id: 'known-firewall' }],
430
- subnets: [{ id: 'available-subnet' }],
431
- zones: ['available-zone'],
432
- },
433
- },
434
- });
435
- const adapter = buildAdapter();
436
- adapter.configureCommand.and.resolveTo(configured);
437
- const modal = new GceCloneServerGroupModal(buildProps(command, adapter)) as any;
438
- spyOn(modal, 'setState').and.callFake((state: any) => (modal.state = { ...modal.state, ...state }));
439
-
440
- await modal.configureCommand();
441
-
442
- expect(modal.state.command).toEqual(
443
- jasmine.objectContaining({
444
- autoHealingPolicy: {
445
- healthCheck: 'persisted-health-check',
446
- healthCheckKind: 'http',
447
- healthCheckUrl: 'persisted-health-check-url',
448
- initialDelaySec: 300,
449
- },
450
- backendServiceMetadata: ['known-backend-new', 'persisted-backend'],
451
- backendServices: {
452
- 'known-lb': ['known-backend-new'],
453
- 'persisted-lb': ['persisted-backend'],
454
- },
262
+ const reconciled = reconcileGceServerGroupCommand(
263
+ persisted,
264
+ buildCommand({ ...buildInitializationHandlers(), loadBalancers: [], securityGroups: [] }),
265
+ );
266
+
267
+ expect(reconciled).toEqual(
268
+ expect.objectContaining({
455
269
  image: 'persisted-image',
456
- implicitSecurityGroups: [
457
- { id: 'known-implicit-firewall', network: 'available-network', targetTags: [] },
458
- persistedImplicitFirewall,
459
- ],
460
270
  instanceType: 'persisted-instance-type',
461
- loadBalancerMetadata: {
462
- 'global-load-balancer-names': ['known-listener-new'],
463
- 'load-balancer-names': ['persisted-listener'],
464
- },
465
- loadBalancers: ['known-lb', 'persisted-lb'],
466
- minCpuPlatform: 'persisted-cpu-platform',
271
+ loadBalancers: ['persisted-lb'],
467
272
  network: 'persisted-network',
468
- securityGroups: ['known-firewall', 'persisted-firewall'],
273
+ securityGroups: ['persisted-firewall'],
469
274
  subnet: 'persisted-subnet',
470
275
  zone: 'persisted-zone',
471
276
  }),
472
277
  );
473
- expect(transformGceServerGroupCommand(modal.state.command).instanceMetadata).toEqual({
474
- 'backend-service-names': 'known-backend-new,persisted-backend',
475
- 'global-load-balancer-names': 'known-listener-new',
476
- 'load-balancer-names': 'persisted-listener',
477
- owner: 'delivery',
478
- });
479
278
  });
480
279
 
481
- it('preserves an untouched pipeline with unavailable regional references and metadata', async () => {
482
- const persistedImplicitFirewall = {
483
- id: 'pipeline-implicit-firewall',
484
- name: 'pipeline-implicit-firewall',
485
- network: 'pipeline-network',
486
- targetTags: ['pipeline-tag'],
487
- };
488
- const command = buildCommand({
489
- autoHealingPolicy: {
490
- healthCheck: 'pipeline-health-check',
491
- healthCheckKind: 'tcp',
492
- healthCheckUrl: 'pipeline-health-check-url',
493
- initialDelaySec: 120,
494
- },
280
+ it('preserves an untouched pipeline with unavailable regional references and metadata', () => {
281
+ const persisted = buildCommand({
495
282
  backendServiceMetadata: 'persisted-backend' as any,
496
- backendServices: { 'persisted-http-lb': ['persisted-backend'] },
497
283
  distributionPolicy: { targetShape: 'EVEN', zones: ['available-zone', 'persisted-zone'] },
498
- image: 'pipeline-image',
499
- implicitSecurityGroups: [persistedImplicitFirewall],
500
- instanceType: 'persisted-instance-type',
501
- loadBalancerMetadata: {
502
- 'global-load-balancer-names': ['persisted-forwarding-rule'],
503
- },
284
+ loadBalancerMetadata: { 'global-load-balancer-names': ['persisted-forwarding-rule'] },
504
285
  loadBalancers: ['persisted-http-lb'],
505
- minCpuPlatform: 'persisted-cpu-platform',
506
- network: 'pipeline-network',
507
286
  regional: true,
508
- securityGroups: ['pipeline-firewall'],
509
- selectZones: true,
510
- subnet: 'pipeline-subnet',
511
287
  viewState: { ...buildCommand().viewState, mode: 'editPipeline' },
512
288
  zone: null,
513
289
  });
514
- const handlers = buildInitializationHandlers((handler, initializedCommand) => {
515
- if (handler !== 'credentialsChanged') {
516
- return;
517
- }
518
- initializedCommand.instanceType = null;
519
- delete initializedCommand.minCpuPlatform;
520
- initializedCommand.distributionPolicy.zones = ['available-zone'];
521
- initializedCommand.loadBalancers = [];
522
- initializedCommand.backendServices = {};
523
- initializedCommand.backendServiceMetadata = [];
524
- initializedCommand.image = 'available-image';
525
- initializedCommand.network = 'available-network';
526
- initializedCommand.subnet = 'available-subnet';
527
- initializedCommand.securityGroups = [];
528
- initializedCommand.implicitSecurityGroups = [];
529
- delete initializedCommand.autoHealingPolicy.healthCheck;
530
- delete initializedCommand.autoHealingPolicy.healthCheckKind;
531
- initializedCommand.loadBalancerMetadata = {};
532
- });
533
- const configured = buildCommand({
534
- ...handlers,
535
- backingData: {
536
- allImages: [{ imageName: 'available-image' }],
537
- filtered: {
538
- cpuPlatforms: ['(Automatic)'],
539
- healthChecks: [{ kind: 'http', name: 'available-health-check' }],
540
- instanceTypes: ['available-instance-type'],
541
- loadBalancerIndex: {},
542
- loadBalancers: [],
543
- networks: ['available-network'],
544
- securityGroups: [],
545
- subnets: ['available-subnet'],
546
- zones: ['available-zone'],
547
- },
548
- },
549
- });
550
- const adapter = buildAdapter();
551
- adapter.configureCommand.and.resolveTo(configured);
552
- const modal = new GceCloneServerGroupModal(buildProps(command, adapter)) as any;
553
- spyOn(modal, 'setState').and.callFake((state: any) => (modal.state = { ...modal.state, ...state }));
554
-
555
- await modal.configureCommand();
556
-
557
- expect(modal.state.command).toEqual(
558
- jasmine.objectContaining({
559
- autoHealingPolicy: {
560
- healthCheck: 'pipeline-health-check',
561
- healthCheckKind: 'tcp',
562
- healthCheckUrl: 'pipeline-health-check-url',
563
- initialDelaySec: 120,
564
- },
565
- backendServiceMetadata: ['persisted-backend'],
566
- backendServices: { 'persisted-http-lb': ['persisted-backend'] },
567
- distributionPolicy: { targetShape: 'EVEN', zones: ['available-zone', 'persisted-zone'] },
568
- image: 'pipeline-image',
569
- implicitSecurityGroups: [persistedImplicitFirewall],
570
- instanceType: 'persisted-instance-type',
571
- loadBalancerMetadata: {
572
- 'global-load-balancer-names': ['persisted-forwarding-rule'],
573
- },
574
- loadBalancers: ['persisted-http-lb'],
575
- minCpuPlatform: 'persisted-cpu-platform',
576
- network: 'pipeline-network',
577
- securityGroups: ['pipeline-firewall'],
578
- subnet: 'pipeline-subnet',
579
- }),
290
+ const reconciled = reconcileGceServerGroupCommand(
291
+ persisted,
292
+ buildCommand({ ...buildInitializationHandlers(), loadBalancers: [], regional: true, zone: null }),
580
293
  );
581
- expect(transformGceServerGroupCommand(modal.state.command).instanceMetadata).toEqual({
582
- 'backend-service-names': 'persisted-backend',
583
- 'global-load-balancer-names': 'persisted-forwarding-rule',
294
+
295
+ expect(reconciled.distributionPolicy.zones).toEqual(['available-zone', 'persisted-zone']);
296
+ expect(reconciled.loadBalancers).toEqual(['persisted-http-lb']);
297
+ expect(reconciled.loadBalancerMetadata).toEqual({
298
+ 'global-load-balancer-names': ['persisted-forwarding-rule'],
584
299
  });
585
300
  });
586
301
 
587
- it('keeps a handler-updated image when the persisted image remains available', async () => {
588
- const baseCommand = buildCommand();
589
- const command = buildCommand({
590
- backingData: {
591
- ...baseCommand.backingData,
592
- allImages: [{ imageName: 'persisted-image' }],
593
- },
594
- image: 'persisted-image',
302
+ it('keeps a handler-updated image when the persisted image remains available', () => {
303
+ const handlers = buildInitializationHandlers((handler, command) => {
304
+ if (handler === 'credentialsChanged') command.image = 'handler-image';
595
305
  });
596
- const handlers = buildInitializationHandlers((handler, initializedCommand) => {
597
- if (handler === 'credentialsChanged') {
598
- initializedCommand.image = 'handler-image';
599
- }
600
- });
601
- const configured = buildCommand({
602
- ...handlers,
603
- backingData: {
604
- ...baseCommand.backingData,
605
- allImages: [{ imageName: 'persisted-image' }],
606
- },
607
- });
608
- const adapter = buildAdapter();
609
- adapter.configureCommand.and.resolveTo(configured);
610
- const modal = new GceCloneServerGroupModal(buildProps(command, adapter)) as any;
611
- spyOn(modal, 'setState').and.callFake((state: any) => (modal.state = { ...modal.state, ...state }));
612
-
613
- await modal.configureCommand();
614
-
615
- expect(modal.state.command.image).toBe('handler-image');
306
+ const reconciled = reconcileGceServerGroupCommand(
307
+ buildCommand({ image: 'persisted-image' }),
308
+ buildCommand({
309
+ ...handlers,
310
+ backingData: { ...buildCommand().backingData, allImages: [{ imageName: 'persisted-image' }] },
311
+ }),
312
+ );
313
+ expect(reconciled.image).toBe('handler-image');
616
314
  });
617
315
 
618
- it('uses the configured load balancer index to normalize HTTP listener aliases once', async () => {
619
- const command = buildCommand({
620
- backingData: undefined,
621
- loadBalancerMetadata: { 'global-load-balancer-names': ['http-listener'] },
622
- loadBalancers: ['http-listener'],
623
- viewState: { ...buildCommand().viewState, mode: 'clone' },
316
+ it('uses the configured load balancer index to normalize HTTP listener aliases once', () => {
317
+ const handlers = buildInitializationHandlers((handler, command) => {
318
+ if (handler === 'credentialsChanged') command.loadBalancers = ['http-url-map'];
624
319
  });
625
- const handlers = buildInitializationHandlers((handler, initializedCommand) => {
626
- if (handler === 'credentialsChanged') {
627
- initializedCommand.backingData.filtered.loadBalancerIndex = {
628
- 'http-url-map': {
629
- account: 'gce-account',
630
- listeners: [{ name: 'http-listener' }],
631
- loadBalancerType: 'HTTP',
632
- name: 'http-url-map',
320
+ const reconciled = reconcileGceServerGroupCommand(
321
+ buildCommand({
322
+ loadBalancerMetadata: { 'global-load-balancer-names': ['http-listener'] },
323
+ loadBalancers: ['http-listener'],
324
+ viewState: { ...buildCommand().viewState, mode: 'clone' },
325
+ }),
326
+ buildCommand({
327
+ ...handlers,
328
+ backingData: {
329
+ accounts: ['gce-account'],
330
+ filtered: {
331
+ ...buildCommand().backingData.filtered,
332
+ loadBalancerIndex: {
333
+ 'http-url-map': {
334
+ listeners: [{ name: 'http-listener' }],
335
+ loadBalancerType: 'HTTP',
336
+ name: 'http-url-map',
337
+ },
338
+ },
633
339
  },
634
- };
635
- initializedCommand.loadBalancers = ['http-url-map'];
636
- }
637
- });
638
- const configured = buildCommand({
639
- ...handlers,
640
- backingData: {
641
- ...buildCommand().backingData,
642
- filtered: { ...buildCommand().backingData.filtered },
643
- },
644
- loadBalancers: ['http-listener'],
645
- });
646
- const adapter = buildAdapter();
647
- adapter.configureCommand.and.resolveTo(configured);
648
- const modal = new GceCloneServerGroupModal(buildProps(command, adapter)) as any;
649
- spyOn(modal, 'setState').and.callFake((state: any) => (modal.state = { ...modal.state, ...state }));
650
-
651
- await modal.configureCommand();
652
-
653
- expect(modal.state.command.loadBalancers).toEqual(['http-url-map']);
654
- expect(transformGceServerGroupCommand(modal.state.command).instanceMetadata).toEqual({
655
- 'global-load-balancer-names': 'http-listener',
656
- });
340
+ },
341
+ }),
342
+ );
343
+ expect(reconciled.loadBalancers).toEqual(['http-url-map']);
657
344
  });
658
345
 
659
- it('restores an unavailable clone image from viewState.imageId', async () => {
660
- const command = buildCommand({
661
- backingData: undefined,
662
- image: undefined,
663
- viewState: { ...buildCommand().viewState, imageId: 'retired-image', mode: 'clone' },
664
- });
665
- const handlers = buildInitializationHandlers((handler, initializedCommand) => {
666
- if (handler === 'credentialsChanged') {
667
- initializedCommand.image = null;
668
- }
669
- });
670
- const configured = buildCommand({
671
- ...handlers,
672
- backingData: {
673
- ...buildCommand().backingData,
674
- allImages: [{ imageName: 'available-image' }],
675
- },
676
- });
677
- const adapter = buildAdapter();
678
- adapter.configureCommand.and.resolveTo(configured);
679
- const modal = new GceCloneServerGroupModal(buildProps(command, adapter)) as any;
680
- spyOn(modal, 'setState').and.callFake((state: any) => (modal.state = { ...modal.state, ...state }));
681
-
682
- await modal.configureCommand();
683
-
684
- expect(modal.state.command.image).toBe('retired-image');
685
- expect(modal.state.command.viewState.imageId).toBe('retired-image');
346
+ it('restores an unavailable clone image from viewState.imageId', () => {
347
+ const reconciled = reconcileGceServerGroupCommand(
348
+ buildCommand({
349
+ image: undefined,
350
+ viewState: { ...buildCommand().viewState, imageId: 'retired-image', mode: 'clone' },
351
+ }),
352
+ buildCommand({ ...buildInitializationHandlers(), image: null }),
353
+ );
354
+ expect(reconciled.image).toBe('retired-image');
355
+ expect(reconciled.viewState.imageId).toBe('retired-image');
686
356
  });
687
357
 
688
- it('preserves flat backend metadata for an unavailable load balancer without creating an empty mapping', async () => {
689
- const command = buildCommand({
690
- backendServiceMetadata: 'persisted-backend-a, persisted-backend-b' as any,
691
- backendServices: undefined,
692
- loadBalancers: ['unavailable-http-lb'],
693
- viewState: { ...buildCommand().viewState, mode: 'clone' },
694
- });
695
- const configured = buildCommand({
696
- ...buildInitializationHandlers(),
697
- backingData: {
698
- ...buildCommand().backingData,
699
- filtered: {
700
- ...buildCommand().backingData.filtered,
701
- loadBalancerIndex: {},
702
- loadBalancers: [],
703
- },
704
- },
705
- });
706
- const adapter = buildAdapter();
707
- adapter.configureCommand.and.resolveTo(configured);
708
- const modal = new GceCloneServerGroupModal(buildProps(command, adapter)) as any;
709
- spyOn(modal, 'setState').and.callFake((state: any) => (modal.state = { ...modal.state, ...state }));
710
-
711
- await modal.configureCommand();
712
-
713
- expect(modal.state.command.backendServices).toBeUndefined();
714
- expect(transformGceServerGroupCommand(modal.state.command).instanceMetadata['backend-service-names']).toBe(
358
+ it('preserves flat backend metadata for an unavailable load balancer without creating an empty mapping', () => {
359
+ const reconciled = reconcileGceServerGroupCommand(
360
+ buildCommand({
361
+ backendServiceMetadata: 'persisted-backend-a, persisted-backend-b' as any,
362
+ backendServices: undefined,
363
+ loadBalancers: ['unavailable-http-lb'],
364
+ }),
365
+ buildCommand({ ...buildInitializationHandlers(), loadBalancers: [] }),
366
+ );
367
+ expect(reconciled.backendServices).toBeUndefined();
368
+ expect(transformGceServerGroupCommand(reconciled).instanceMetadata['backend-service-names']).toBe(
715
369
  'persisted-backend-a,persisted-backend-b',
716
370
  );
717
371
  });
718
372
 
719
- it('preserves an unavailable clone account and region without running account-dependent handlers', async () => {
720
- const command = buildCommand({
721
- backingData: {
722
- ...buildCommand().backingData,
723
- accounts: ['retired-account'],
724
- filtered: { ...buildCommand().backingData.filtered, regions: ['retired-region'] },
725
- },
726
- credentials: 'retired-account',
727
- region: 'retired-region',
728
- viewState: { ...buildCommand().viewState, mode: 'clone' },
729
- });
373
+ it('preserves an unavailable clone account and region without running account-dependent handlers', () => {
730
374
  const handlers = buildInitializationHandlers();
731
- const configured = buildCommand({
732
- ...handlers,
733
- backingData: {
734
- ...buildCommand().backingData,
735
- accounts: ['gce-account'],
736
- filtered: { ...buildCommand().backingData.filtered, regions: ['us-central1'] },
737
- },
738
- });
739
- const adapter = buildAdapter();
740
- adapter.configureCommand.and.resolveTo(configured);
741
- const modal = new GceCloneServerGroupModal(buildProps(command, adapter)) as any;
742
- spyOn(modal, 'setState').and.callFake((state: any) => (modal.state = { ...modal.state, ...state }));
743
-
744
- await modal.configureCommand();
745
-
746
- expect(modal.state.command.credentials).toBe('retired-account');
747
- expect(modal.state.command.region).toBe('retired-region');
748
- Object.keys(handlers).forEach((handler) => expect(handlers[handler]).not.toHaveBeenCalled());
375
+ const reconciled = reconcileGceServerGroupCommand(
376
+ buildCommand({
377
+ credentials: 'retired-account',
378
+ region: 'retired-region',
379
+ viewState: { ...buildCommand().viewState, mode: 'clone' },
380
+ }),
381
+ buildCommand({ ...handlers }),
382
+ );
383
+ expect(reconciled.credentials).toBe('retired-account');
384
+ expect(reconciled.region).toBe('retired-region');
385
+ Object.values(handlers).forEach((handler) => expect(handler).not.toHaveBeenCalled());
749
386
  });
750
387
 
751
- it('preserves an unavailable pipeline region after initializing the available account', async () => {
752
- const command = buildCommand({
753
- backingData: {
754
- ...buildCommand().backingData,
755
- filtered: { ...buildCommand().backingData.filtered, regions: ['retired-region'] },
756
- },
757
- region: 'retired-region',
758
- viewState: { ...buildCommand().viewState, mode: 'editPipeline' },
759
- });
388
+ it('preserves an unavailable pipeline region after initializing the available account', () => {
760
389
  const handlers = buildInitializationHandlers();
761
- const configured = buildCommand({
762
- ...handlers,
763
- backingData: {
764
- ...buildCommand().backingData,
765
- filtered: { ...buildCommand().backingData.filtered, regions: ['us-central1'] },
766
- },
767
- });
768
- const adapter = buildAdapter();
769
- adapter.configureCommand.and.resolveTo(configured);
770
- const modal = new GceCloneServerGroupModal(buildProps(command, adapter)) as any;
771
- spyOn(modal, 'setState').and.callFake((state: any) => (modal.state = { ...modal.state, ...state }));
772
-
773
- await modal.configureCommand();
774
-
775
- expect(modal.state.command.credentials).toBe('gce-account');
776
- expect(modal.state.command.region).toBe('retired-region');
777
- expect(handlers.credentialsChanged).toHaveBeenCalledOnceWith(modal.state.command);
778
- ['regionalChanged', 'regionChanged', 'networkChanged', 'zoneChanged', 'customInstanceChanged'].forEach((handler) =>
779
- expect(handlers[handler]).not.toHaveBeenCalled(),
390
+ const reconciled = reconcileGceServerGroupCommand(
391
+ buildCommand({ region: 'retired-region', viewState: { ...buildCommand().viewState, mode: 'editPipeline' } }),
392
+ buildCommand({ ...handlers }),
780
393
  );
394
+ expect(reconciled.region).toBe('retired-region');
395
+ expect(handlers.credentialsChanged).toHaveBeenCalledTimes(1);
781
396
  });
782
397
 
783
- it('hydrates regions before checking an available persisted region and runs all handlers in order', async () => {
784
- const command = buildCommand();
398
+ it('hydrates regions before checking an available persisted region and runs all handlers in order', () => {
785
399
  const calls: string[] = [];
786
- const handlers = buildInitializationHandlers((handler, initializedCommand) => {
400
+ const handlers = buildInitializationHandlers((handler, command) => {
787
401
  calls.push(handler);
788
- if (handler === 'credentialsChanged') {
789
- initializedCommand.backingData.filtered.regions = ['us-central1'];
790
- }
402
+ if (handler === 'credentialsChanged') command.backingData.filtered.regions = ['us-central1'];
791
403
  });
792
- const configured = buildCommand({
793
- ...handlers,
794
- backingData: {
795
- ...buildCommand().backingData,
796
- filtered: {},
797
- },
798
- });
799
- const adapter = buildAdapter();
800
- adapter.configureCommand.and.resolveTo(configured);
801
- const modal = new GceCloneServerGroupModal(buildProps(command, adapter)) as any;
802
- spyOn(modal, 'setState').and.callFake((state: any) => (modal.state = { ...modal.state, ...state }));
803
-
804
- await modal.configureCommand();
805
-
404
+ reconcileGceServerGroupCommand(
405
+ buildCommand(),
406
+ buildCommand({ ...handlers, backingData: { accounts: ['gce-account'], filtered: {} } }),
407
+ );
806
408
  expect(calls).toEqual([
807
409
  'credentialsChanged',
808
410
  'regionalChanged',
@@ -820,28 +422,15 @@ describe('GceCloneServerGroupModal', () => {
820
422
  backingData: {
821
423
  filtered: {
822
424
  loadBalancerIndex: {
823
- 'current-http-lb': {
824
- listeners: [{ name: 'current-forwarding-rule' }],
825
- loadBalancerType: 'HTTP',
826
- },
425
+ 'current-http-lb': { listeners: [{ name: 'current-forwarding-rule' }], loadBalancerType: 'HTTP' },
827
426
  },
828
427
  },
829
428
  },
830
- instanceMetadata: {
831
- 'backend-service-names': 'stale-backend',
832
- 'global-load-balancer-names': 'stale-global-rule',
833
- 'load-balancer-names': 'stale-regional-rule',
834
- owner: 'delivery',
835
- },
836
- loadBalancerMetadata: {
837
- 'global-load-balancer-names': ['persisted-global-rule'],
838
- 'load-balancer-names': ['persisted-regional-rule'],
839
- },
429
+ instanceMetadata: { 'backend-service-names': 'stale-backend', owner: 'delivery' },
840
430
  loadBalancers: ['current-http-lb'],
841
431
  viewState: { ...buildCommand().viewState, mode: 'clone' },
842
432
  }),
843
433
  );
844
-
845
434
  expect(transformed.instanceMetadata).toEqual({
846
435
  'backend-service-names': 'current-backend',
847
436
  'global-load-balancer-names': 'current-forwarding-rule',
@@ -853,23 +442,13 @@ describe('GceCloneServerGroupModal', () => {
853
442
  const transformed = transformGceServerGroupCommand(
854
443
  buildCommand({
855
444
  backingData: {
856
- filtered: {
857
- loadBalancerIndex: {
858
- 'regional-lb': { loadBalancerType: 'NETWORK', name: 'regional-lb' },
859
- },
860
- },
861
- },
862
- backendServices: { 'regional-lb': ['backend-a', 'backend-b'], removed: ['removed-backend'] },
863
- instanceMetadata: {
864
- 'backend-service-names': 'persisted-backend',
865
- 'global-load-balancer-names': 'persisted-global-rule',
445
+ filtered: { loadBalancerIndex: { 'regional-lb': { loadBalancerType: 'NETWORK', name: 'regional-lb' } } },
866
446
  },
867
- loadBalancerMetadata: { 'global-load-balancer-names': ['persisted-global-rule'] },
447
+ backendServices: { 'regional-lb': ['backend-a', 'backend-b'] },
868
448
  loadBalancers: ['regional-lb'],
869
449
  viewState: { ...buildCommand().viewState, mode: 'editPipeline' },
870
450
  }),
871
451
  );
872
-
873
452
  expect(transformed.instanceMetadata).toEqual({
874
453
  'backend-service-names': 'backend-a,backend-b',
875
454
  'load-balancer-names': 'regional-lb',
@@ -877,21 +456,25 @@ describe('GceCloneServerGroupModal', () => {
877
456
  });
878
457
 
879
458
  ['createPipeline', 'editPipeline'].forEach((mode) => {
880
- it(`returns a transformed command without executing infrastructure in ${mode} mode`, () => {
881
- const command = buildCommand({
882
- capacity: { desired: '${ parameters.desired }' } as any,
883
- minCpuPlatform: '(Automatic)',
884
- tags: [{ value: 'web' }, 'api'],
885
- viewState: { ...buildCommand().viewState, mode },
886
- });
887
- const props = buildProps(command);
888
- const modal = new GceCloneServerGroupModal(props) as any;
889
- const cloneServerGroup = jasmine.createSpy('cloneServerGroup');
459
+ it(`returns transformed WizardModal values without executing infrastructure in ${mode} mode`, async () => {
460
+ const writer = vi.fn();
461
+ const rendered = renderModal(
462
+ buildCommand({
463
+ capacity: { desired: '${ parameters.desired }' } as any,
464
+ minCpuPlatform: '(Automatic)',
465
+ tags: [{ value: 'web' }, 'api'],
466
+ viewState: { ...buildCommand().viewState, mode },
467
+ }),
468
+ buildAdapter(),
469
+ { serverGroupWriter: { cloneServerGroup: writer } },
470
+ );
471
+ expect(await screen.findByRole('button', { name: 'Done' })).toBeEnabled();
890
472
 
891
- modal.submit(command);
473
+ fireEvent.click(screen.getByRole('button', { name: 'Done' }));
892
474
 
893
- expect(props.closeModal).toHaveBeenCalledWith(
894
- jasmine.objectContaining({
475
+ await waitFor(() => expect(rendered.props.closeModal).toHaveBeenCalled());
476
+ expect(rendered.props.closeModal).toHaveBeenCalledWith(
477
+ expect.objectContaining({
895
478
  capacity: {
896
479
  desired: '${ parameters.desired }',
897
480
  max: '${ parameters.desired }',
@@ -902,175 +485,269 @@ describe('GceCloneServerGroupModal', () => {
902
485
  targetSize: '${ parameters.desired }',
903
486
  }),
904
487
  );
905
- expect(cloneServerGroup).not.toHaveBeenCalled();
488
+ expect(writer).not.toHaveBeenCalled();
906
489
  });
907
490
  });
908
491
 
909
492
  ['create', 'clone'].forEach((mode) => {
910
- it(`transforms and submits ${mode} commands through TaskMonitor`, () => {
911
- const command = buildCommand({
912
- autoscalingPolicy: { maxNumReplicas: 6, minNumReplicas: 2 },
913
- instanceMetadata: { owner: 'delivery' },
914
- loadBalancerMetadata: {
915
- 'global-load-balancer-names': ['stale-global-forwarding-rule'],
916
- 'load-balancer-names': ['stale-regional-forwarding-rule'],
917
- },
918
- loadBalancers: [
919
- { loadBalancerType: 'TCP', name: 'global-forwarding-rule' },
920
- { loadBalancerType: 'INTERNAL_MANAGED', listeners: [{ name: 'regional-forwarding-rule' }] },
921
- ],
922
- securityGroups: ['firewall-id'],
923
- viewState: { ...buildCommand().viewState, mode },
924
- });
925
- const props = buildProps(command);
926
- const modal = new GceCloneServerGroupModal(props) as any;
927
- const task = Promise.resolve({ id: 'task-id' });
928
- const cloneServerGroup = jasmine.createSpy('cloneServerGroup').and.returnValue(task as any);
929
- modal.context = { services: { serverGroupWriter: { cloneServerGroup } } };
930
- const monitorSubmit = spyOn(
931
- modal.state.taskMonitor,
932
- 'submit',
933
- ).and.callFake((submitMethod: () => PromiseLike<any>) => submitMethod());
934
-
935
- modal.submit(command);
936
-
937
- expect(monitorSubmit).toHaveBeenCalled();
938
- expect(cloneServerGroup).toHaveBeenCalledWith(
939
- jasmine.objectContaining({
940
- autoscalingPolicy: { maxNumReplicas: 6, minNumReplicas: 2 },
941
- capacity: { desired: 3, max: 6, min: 2 },
942
- instanceMetadata: {
943
- owner: 'delivery',
944
- 'global-load-balancer-names': 'global-forwarding-rule',
945
- 'load-balancer-names': 'regional-forwarding-rule',
493
+ it(`transforms and submits ${mode} commands through TaskMonitor`, async () => {
494
+ const writer = vi.fn().mockResolvedValue({ id: 'task-id' });
495
+ const monitorSubmit = vi.spyOn(TaskMonitor.prototype, 'startSubmit');
496
+ vi.spyOn(TaskReader, 'waitUntilTaskCompletes').mockReturnValue(new Promise(() => undefined));
497
+ const rendered = renderModal(
498
+ buildCommand({
499
+ autoscalingPolicy: {
500
+ maxNumReplicas: 6,
501
+ minNumReplicas: 2,
502
+ coolDownPeriodSec: 60,
503
+ cpuUtilization: { utilizationTarget: 0.5 },
946
504
  },
947
- targetSize: 3,
505
+ instanceMetadata: { owner: 'delivery' },
506
+ loadBalancerMetadata: {
507
+ 'global-load-balancer-names': ['stale-global-forwarding-rule'],
508
+ 'load-balancer-names': ['stale-regional-forwarding-rule'],
509
+ },
510
+ loadBalancers: [
511
+ { loadBalancerType: 'TCP', name: 'global-forwarding-rule' },
512
+ { loadBalancerType: 'INTERNAL_MANAGED', listeners: [{ name: 'regional-forwarding-rule' }] },
513
+ ],
514
+ securityGroups: ['firewall-id'],
515
+ viewState: { ...buildCommand().viewState, mode },
948
516
  }),
949
- application,
517
+ buildAdapter(),
518
+ { serverGroupWriter: { cloneServerGroup: writer } },
519
+ );
520
+ expect(await screen.findByRole('button', { name: 'Done' })).toBeEnabled();
521
+
522
+ fireEvent.change(screen.getByLabelText('Detail'), { target: { value: `${mode}-detail` } });
523
+ fireEvent.click(screen.getByRole('button', { name: 'Done' }));
524
+
525
+ await waitFor(() =>
526
+ expect(writer).toHaveBeenCalledWith(
527
+ expect.objectContaining({
528
+ autoscalingPolicy: expect.objectContaining({ maxNumReplicas: 6, minNumReplicas: 2 }),
529
+ capacity: { desired: 3, max: 6, min: 2 },
530
+ freeFormDetails: `${mode}-detail`,
531
+ instanceMetadata: {
532
+ owner: 'delivery',
533
+ 'global-load-balancer-names': 'global-forwarding-rule',
534
+ 'load-balancer-names': 'regional-forwarding-rule',
535
+ },
536
+ targetSize: 3,
537
+ }),
538
+ application,
539
+ ),
950
540
  );
951
- const submitted = cloneServerGroup.calls.mostRecent().args[0];
541
+ expect(monitorSubmit).toHaveBeenCalled();
542
+ const submitted = writer.mock.lastCall[0];
952
543
  expect(submitted.loadBalancerMetadata).toBeUndefined();
953
544
  expect(submitted.securityGroups).toBeUndefined();
954
- expect(props.closeModal).not.toHaveBeenCalled();
545
+ expect(rendered.props.closeModal).not.toHaveBeenCalled();
955
546
  });
956
547
  });
957
548
 
958
- it('submits the immediate Formik command instead of stale component state', () => {
959
- const viewState = { ...buildCommand().viewState, mode: 'editPipeline' as const };
960
- const initialCommand = buildCommand({ stack: 'old', viewState });
961
- const formik = { values: buildCommand({ stack: 'edited', viewState }) } as any;
962
- const props = buildProps(initialCommand, buildAdapter());
963
- const wrapper = shallow(<GceCloneServerGroupModal {...props} />, { disableLifecycleMethods: true } as any);
964
- const modal = wrapper.instance() as any;
965
- const wizard = wrapper.find(WizardModal);
966
- wizard.prop('render')({ formik, nextIdx: () => 1, wizard: {} as any });
549
+ it('submits the immediate Formik command instead of stale component state', async () => {
550
+ const rendered = renderModal(
551
+ buildCommand({ stack: 'edited', viewState: { ...buildCommand().viewState, mode: 'editPipeline' } }),
552
+ buildAdapter(),
553
+ );
554
+ expect(await screen.findByRole('button', { name: 'Done' })).toBeEnabled();
555
+ fireEvent.change(screen.getByLabelText('Stack'), { target: { value: 'latest' } });
967
556
 
968
- wizard.prop('closeModal')();
557
+ fireEvent.click(screen.getByRole('button', { name: 'Done' }));
969
558
 
970
- expect(props.closeModal).toHaveBeenCalledWith(jasmine.objectContaining({ stack: 'edited' }));
559
+ await waitFor(() => expect(rendered.props.closeModal).toHaveBeenCalled());
560
+ expect(rendered.props.closeModal).toHaveBeenCalledWith(expect.objectContaining({ stack: 'latest' }));
971
561
  });
972
562
 
973
- it('merges refreshed backing data and handlers into edits made while loading', async () => {
974
- const command = buildCommand({ stack: 'old', unknownReference: 'keep-me' });
975
- const request = deferred<IGceServerGroupCommand>();
976
- const adapter = buildAdapter();
977
- adapter.configureCommand.and.returnValue(request.promise);
978
- const wrapper = shallow(<GceCloneServerGroupModal {...buildProps(command, adapter)} />, {
979
- disableLifecycleMethods: true,
980
- } as any);
981
- const modal = wrapper.instance() as any;
982
- const formik: any = {
983
- setValues: jasmine
984
- .createSpy('setValues')
985
- .and.callFake((values: IGceServerGroupCommand) => (formik.values = values)),
986
- values: cloneDeep(command),
987
- };
988
- wrapper.find(WizardModal).prop('render')({ formik, nextIdx: () => 1, wizard: {} as any });
989
-
990
- const configure = modal.configureCommand();
991
- formik.values = { ...formik.values, stack: 'edited' };
992
- const regionChanged = jasmine.createSpy('regionChanged');
993
- request.resolve(
563
+ it('submits concrete regional external attachment names without changing other load balancer identities', () => {
564
+ const transformed = transformGceServerGroupCommand(
994
565
  buildCommand({
995
- backingData: { filtered: { regions: ['refreshed-region'] } },
996
- regionChanged,
997
- stack: 'old',
998
- unknownReference: 'keep-me',
566
+ backingData: {
567
+ filtered: {
568
+ loadBalancerIndex: {
569
+ 'external-managed': {
570
+ listeners: [{ name: 'external-http' }, { name: 'external-https' }],
571
+ loadBalancerType: 'EXTERNAL_MANAGED',
572
+ name: 'external-managed',
573
+ },
574
+ 'regional-network': {
575
+ loadBalancerType: 'REGIONAL_EXTERNAL_NETWORK',
576
+ name: 'regional-network',
577
+ },
578
+ 'existing-tcp': {
579
+ loadBalancerType: 'TCP',
580
+ name: 'existing-tcp',
581
+ },
582
+ },
583
+ },
584
+ },
585
+ loadBalancers: ['external-managed', 'regional-network', 'existing-tcp'],
999
586
  }),
1000
587
  );
1001
- await configure;
1002
588
 
1003
- expect(formik.values.stack).toBe('edited');
1004
- expect(formik.values.unknownReference).toBe('keep-me');
1005
- expect(formik.values.backingData.filtered.regions).toEqual(['refreshed-region']);
1006
- expect(formik.values.regionChanged).toBe(regionChanged);
589
+ expect(transformed.loadBalancers).toEqual(['external-http', 'external-https', 'regional-network', 'existing-tcp']);
590
+ expect(transformed.instanceMetadata).toEqual({
591
+ 'global-load-balancer-names': 'existing-tcp',
592
+ 'load-balancer-names': 'external-http,external-https,regional-network',
593
+ });
1007
594
  });
1008
595
 
1009
- it('ignores stale backing refreshes', async () => {
1010
- const first = deferred<IGceServerGroupCommand>();
1011
- const second = deferred<IGceServerGroupCommand>();
1012
- const adapter = buildAdapter();
1013
- adapter.configureCommand.and.returnValues(first.promise, second.promise);
1014
- const modal = new GceCloneServerGroupModal(buildProps(buildCommand(), adapter)) as any;
1015
- spyOn(modal, 'setState').and.callFake((state: any) => (modal.state = { ...modal.state, ...state }));
1016
-
1017
- const firstConfigure = modal.configureCommand();
1018
- const secondConfigure = modal.configureCommand();
1019
- second.resolve(buildCommand({ backingData: { filtered: { regions: ['second'] } } }));
1020
- await secondConfigure;
1021
- first.resolve(buildCommand({ backingData: { filtered: { regions: ['first'] } } }));
1022
- await firstConfigure;
1023
-
1024
- expect(modal.state.command.backingData.filtered.regions).toEqual(['second']);
596
+ it.each([
597
+ [
598
+ 'merges refreshed backing data into Formik edits made while configuration is loading',
599
+ async () => {
600
+ const request = deferred<IGceServerGroupCommand>();
601
+ const adapter = buildAdapter({ configureCommand: vi.fn().mockReturnValue(request.promise) });
602
+ const rendered = renderModal(
603
+ buildCommand({
604
+ unknownReference: 'keep-me',
605
+ viewState: { ...buildCommand().viewState, mode: 'editPipeline' },
606
+ }),
607
+ adapter,
608
+ );
609
+ fireEvent.change(await screen.findByLabelText('Stack'), { target: { value: 'edited' } });
610
+ const regionChanged = vi.fn();
611
+
612
+ await act(async () =>
613
+ request.resolve(
614
+ buildCommand({
615
+ backingData: { ...buildCommand().backingData, refreshed: true },
616
+ regionChanged,
617
+ stack: 'old',
618
+ unknownReference: 'keep-me',
619
+ viewState: { ...buildCommand().viewState, mode: 'editPipeline' },
620
+ }),
621
+ ),
622
+ );
623
+ fireEvent.click(await screen.findByRole('button', { name: 'Done' }));
624
+
625
+ await waitFor(() => expect(rendered.props.closeModal).toHaveBeenCalled());
626
+ expect(rendered.props.closeModal).toHaveBeenCalledWith(
627
+ expect.objectContaining({
628
+ backingData: expect.objectContaining({ refreshed: true }),
629
+ stack: 'edited',
630
+ unknownReference: 'keep-me',
631
+ }),
632
+ );
633
+ expect(rendered.props.closeModal.mock.calls[0][0].regionChanged).toBe(regionChanged);
634
+ },
635
+ ],
636
+ [
637
+ 'keeps the latest rendered command when overlapping configuration requests finish out of order',
638
+ async () => {
639
+ const first = deferred<IGceServerGroupCommand>();
640
+ const second = deferred<IGceServerGroupCommand>();
641
+ const adapter = buildAdapter({
642
+ configureCommand: vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise),
643
+ });
644
+ const rendered = renderModal(
645
+ buildCommand({ stack: 'first-request', viewState: { ...buildCommand().viewState, mode: 'editPipeline' } }),
646
+ adapter,
647
+ );
648
+
649
+ rendered.rerenderCommand(
650
+ buildCommand({ stack: 'second-request', viewState: { ...buildCommand().viewState, mode: 'editPipeline' } }),
651
+ );
652
+ await waitFor(() => expect(adapter.configureCommand).toHaveBeenCalledTimes(2));
653
+ await act(async () =>
654
+ second.resolve(
655
+ buildCommand({
656
+ backingData: {
657
+ ...buildCommand().backingData,
658
+ filtered: { ...buildCommand().backingData.filtered, regions: ['second-region'] },
659
+ },
660
+ }),
661
+ ),
662
+ );
663
+ await act(async () =>
664
+ first.resolve(
665
+ buildCommand({
666
+ backingData: {
667
+ ...buildCommand().backingData,
668
+ filtered: { ...buildCommand().backingData.filtered, regions: ['first-region'] },
669
+ },
670
+ }),
671
+ ),
672
+ );
673
+
674
+ expect(screen.getByLabelText('Stack')).toHaveValue('second-request');
675
+ const regionOptions = Array.from(screen.getByLabelText('Region').querySelectorAll('option')).map(
676
+ (option) => option.value,
677
+ );
678
+ expect(regionOptions).toContain('second-region');
679
+ expect(regionOptions).not.toContain('first-region');
680
+ },
681
+ ],
682
+ ] as const)('%s', async (_description, verify) => verify());
683
+
684
+ it.each(['resolve', 'reject'] as const)(
685
+ 'does not reconcile, update Formik, invoke callbacks, or warn after configuration %s following unmount',
686
+ async (outcome) => {
687
+ const request = deferred<IGceServerGroupCommand>();
688
+ const handlers = buildInitializationHandlers();
689
+ const adapter = buildAdapter({ configureCommand: vi.fn().mockReturnValue(request.promise) });
690
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
691
+ const rendered = renderModal(buildCommand(), adapter);
692
+ await screen.findByLabelText('Stack');
693
+
694
+ rendered.unmount();
695
+ await act(async () => {
696
+ if (outcome === 'resolve') {
697
+ request.resolve(buildCommand({ ...handlers, backingData: { filtered: { regions: ['late'] } } }));
698
+ } else {
699
+ request.reject(new Error('late configuration failure'));
700
+ }
701
+ await request.promise.catch(() => undefined);
702
+ });
703
+
704
+ Object.values(handlers).forEach((handler) => expect(handler).not.toHaveBeenCalled());
705
+ expect(rendered.props.closeModal).not.toHaveBeenCalled();
706
+ expect(rendered.props.dismissModal).not.toHaveBeenCalled();
707
+ expect(consoleError.mock.calls.flat().join(' ')).not.toMatch(/unmounted component/i);
708
+ consoleError.mockRestore();
709
+ },
710
+ );
711
+
712
+ it('renders actual TaskMonitor submission status inside WizardModal', async () => {
713
+ const task = deferred<any>();
714
+ renderModal(buildCommand(), buildAdapter(), { serverGroupWriter: { cloneServerGroup: () => task.promise } });
715
+ fireEvent.click(await screen.findByRole('button', { name: 'Done' }));
716
+
717
+ await waitFor(() => expect(screen.getAllByRole('heading', { name: 'Configure GCE server group' })).toHaveLength(2));
1025
718
  });
1026
719
 
1027
- it('does not update state or Formik after unmount', async () => {
1028
- const request = deferred<IGceServerGroupCommand>();
1029
- const adapter = buildAdapter();
1030
- adapter.configureCommand.and.returnValue(request.promise);
1031
- const modal = new GceCloneServerGroupModal(buildProps(buildCommand(), adapter)) as any;
1032
- const setState = spyOn(modal, 'setState');
1033
- const formik = { setValues: jasmine.createSpy('setValues'), values: buildCommand() };
1034
- modal.formik = formik;
1035
-
1036
- const configure = modal.configureCommand();
1037
- modal.componentWillUnmount();
1038
- request.resolve(buildCommand({ backingData: { filtered: { regions: ['late'] } } }));
1039
- await configure;
1040
-
1041
- expect(setState).not.toHaveBeenCalled();
1042
- expect(formik.setValues).not.toHaveBeenCalled();
720
+ it('retains the modal command when infrastructure submission fails', async () => {
721
+ const rendered = renderModal(buildCommand(), buildAdapter(), {
722
+ serverGroupWriter: { cloneServerGroup: vi.fn().mockRejectedValue({ failureMessage: 'create failed' }) },
723
+ });
724
+ fireEvent.click(await screen.findByRole('button', { name: 'Done' }));
725
+
726
+ expect(await screen.findByText('create failed')).toBeInTheDocument();
727
+ expect(rendered.props.closeModal).not.toHaveBeenCalled();
728
+ expect(rendered.props.dismissModal).not.toHaveBeenCalled();
1043
729
  });
1044
730
 
1045
- it('refreshes and navigates to the created server group after task completion', () => {
1046
- const command = buildCommand({ credentials: 'gce-account', region: 'us-central1' });
1047
- const props = buildProps(command);
1048
- const modal = new GceCloneServerGroupModal(props) as any;
1049
- modal.state.taskMonitor.task = {
1050
- execution: {
1051
- stages: [
1052
- {
1053
- context: { 'deploy.server.groups': { 'us-central1': 'fnord-main-api-v042' } },
1054
- type: 'cloneServerGroup',
1055
- },
1056
- ],
1057
- },
1058
- };
1059
- const state = {
1060
- go: jasmine.createSpy('go'),
1061
- includes: jasmine.createSpy('includes').and.callFake((name: string) => name === '**.clusters'),
1062
- };
1063
- props.stateService = state;
1064
-
1065
- modal.onTaskComplete();
1066
-
1067
- expect(application.serverGroups.refresh).toHaveBeenCalled();
1068
- expect(application.serverGroups.onNextRefresh).toHaveBeenCalledWith(modal.onApplicationRefresh);
1069
- expect(application.serverGroups.onNextRefresh.calls.first().invocationOrder).toBeLessThan(
1070
- application.serverGroups.refresh.calls.first().invocationOrder,
731
+ it('refreshes and navigates to the created server group after TaskMonitor completion', async () => {
732
+ const task = cloneTask();
733
+ const writer = vi.fn().mockResolvedValue(task);
734
+ vi.spyOn(TaskReader, 'waitUntilTaskCompletes').mockResolvedValue(task);
735
+ const refreshCallbacks: Array<() => void> = [];
736
+ application.serverGroups.onNextRefresh.mockImplementation((callback: () => void) => {
737
+ refreshCallbacks.push(callback);
738
+ return vi.fn();
739
+ });
740
+ const stateService = { go: vi.fn(), includes: vi.fn((state: string) => state === '**.clusters') };
741
+ renderModal(buildCommand(), buildAdapter(), { serverGroupWriter: { cloneServerGroup: writer } }, { stateService });
742
+ fireEvent.click(await screen.findByRole('button', { name: 'Done' }));
743
+ await waitFor(() => expect(application.serverGroups.refresh).toHaveBeenCalled());
744
+ expect(application.serverGroups.onNextRefresh.mock.invocationCallOrder[0]).toBeLessThan(
745
+ application.serverGroups.refresh.mock.invocationCallOrder[0],
1071
746
  );
1072
- application.serverGroups.onNextRefresh.calls.mostRecent().args[0]();
1073
- expect(state.go).toHaveBeenCalledWith('.serverGroup', {
747
+
748
+ refreshCallbacks[0]();
749
+
750
+ expect(stateService.go).toHaveBeenCalledWith('.serverGroup', {
1074
751
  accountId: 'gce-account',
1075
752
  provider: 'gce',
1076
753
  region: 'us-central1',
@@ -1078,68 +755,116 @@ describe('GceCloneServerGroupModal', () => {
1078
755
  });
1079
756
  });
1080
757
 
1081
- it('unsubscribes from the application refresh after the callback runs', () => {
1082
- const unsubscribe = jasmine.createSpy('unsubscribe');
1083
- application.serverGroups.onNextRefresh.and.returnValue(unsubscribe);
1084
- const modal = new GceCloneServerGroupModal(buildProps(buildCommand())) as any;
1085
-
1086
- modal.onTaskComplete();
758
+ it('unsubscribes from the application refresh after the callback runs', async () => {
759
+ const unsubscribe = vi.fn();
760
+ let refreshCallback: (() => void) | undefined;
761
+ application.serverGroups.onNextRefresh.mockImplementation((callback: () => void) => {
762
+ refreshCallback = callback;
763
+ return unsubscribe;
764
+ });
765
+ const task = cloneTask();
766
+ vi.spyOn(TaskReader, 'waitUntilTaskCompletes').mockResolvedValue(task);
767
+ renderModal(buildCommand(), buildAdapter(), {
768
+ serverGroupWriter: { cloneServerGroup: vi.fn().mockResolvedValue(task) },
769
+ });
770
+ fireEvent.click(await screen.findByRole('button', { name: 'Done' }));
771
+ await waitFor(() => expect(application.serverGroups.refresh).toHaveBeenCalled());
1087
772
  expect(unsubscribe).not.toHaveBeenCalled();
1088
773
 
1089
- modal.onApplicationRefresh();
774
+ refreshCallback?.();
775
+
1090
776
  expect(unsubscribe).toHaveBeenCalledTimes(1);
1091
777
  });
1092
778
 
1093
- it('ignores a late application refresh after unmount', () => {
1094
- const unsubscribe = jasmine.createSpy('unsubscribe');
779
+ it('ignores a late application refresh after unmount', async () => {
780
+ const unsubscribe = vi.fn();
1095
781
  let refreshCallback: (() => void) | undefined;
1096
- application.serverGroups.onNextRefresh.and.callFake((callback: () => void) => {
782
+ application.serverGroups.onNextRefresh.mockImplementation((callback: () => void) => {
1097
783
  refreshCallback = callback;
1098
784
  return unsubscribe;
1099
785
  });
1100
- const props = buildProps(buildCommand({ credentials: 'gce-account', region: 'us-central1' }));
1101
- const modal = new GceCloneServerGroupModal(props) as any;
1102
- modal.state.taskMonitor.task = {
1103
- execution: {
1104
- stages: [
1105
- {
1106
- context: { 'deploy.server.groups': { 'us-central1': 'fnord-main-api-v042' } },
1107
- type: 'cloneServerGroup',
1108
- },
1109
- ],
1110
- },
1111
- };
786
+ const task = cloneTask();
787
+ vi.spyOn(TaskReader, 'waitUntilTaskCompletes').mockResolvedValue(task);
788
+ const stateService = { go: vi.fn(), includes: vi.fn() };
789
+ const rendered = renderModal(
790
+ buildCommand(),
791
+ buildAdapter(),
792
+ { serverGroupWriter: { cloneServerGroup: vi.fn().mockResolvedValue(task) } },
793
+ { stateService },
794
+ );
795
+ fireEvent.click(await screen.findByRole('button', { name: 'Done' }));
796
+ await waitFor(() => expect(application.serverGroups.refresh).toHaveBeenCalled());
1112
797
 
1113
- modal.onTaskComplete();
1114
- modal.componentWillUnmount();
1115
- refreshCallback!();
798
+ rendered.unmount();
799
+ refreshCallback?.();
1116
800
 
1117
801
  expect(unsubscribe).toHaveBeenCalledTimes(1);
1118
- expect(modal.applicationRefreshUnsubscribe).toBeUndefined();
1119
- expect(props.stateService.go).not.toHaveBeenCalled();
1120
- expect(props.closeModal).not.toHaveBeenCalled();
1121
- expect(props.dismissModal).not.toHaveBeenCalled();
802
+ expect(stateService.go).not.toHaveBeenCalled();
803
+ expect(rendered.props.closeModal).not.toHaveBeenCalled();
804
+ expect(rendered.props.dismissModal).not.toHaveBeenCalled();
1122
805
  });
1123
806
 
1124
- it('unsubscribes from a pending application refresh before replacing it', () => {
1125
- const firstUnsubscribe = jasmine.createSpy('firstUnsubscribe');
1126
- const secondUnsubscribe = jasmine.createSpy('secondUnsubscribe');
1127
- application.serverGroups.onNextRefresh.and.returnValues(firstUnsubscribe, secondUnsubscribe);
1128
- const modal = new GceCloneServerGroupModal(buildProps(buildCommand())) as any;
1129
-
1130
- modal.onTaskComplete();
1131
- modal.onTaskComplete();
807
+ it('unsubscribes from a pending application refresh before replacing it', async () => {
808
+ const firstUnsubscribe = vi.fn();
809
+ const secondUnsubscribe = vi.fn();
810
+ application.serverGroups.onNextRefresh.mockReturnValueOnce(firstUnsubscribe).mockReturnValueOnce(secondUnsubscribe);
811
+ const task = cloneTask();
812
+ vi.spyOn(TaskReader, 'waitUntilTaskCompletes').mockResolvedValue(task);
813
+ let monitor: TaskMonitor | undefined;
814
+ const handleTaskSuccess = TaskMonitor.prototype.handleTaskSuccess;
815
+ vi.spyOn(TaskMonitor.prototype, 'handleTaskSuccess').mockImplementation(function (
816
+ this: TaskMonitor,
817
+ ...args: Parameters<TaskMonitor['handleTaskSuccess']>
818
+ ) {
819
+ monitor = this;
820
+ return handleTaskSuccess.apply(this, args);
821
+ });
822
+ renderModal(buildCommand(), buildAdapter(), {
823
+ serverGroupWriter: { cloneServerGroup: vi.fn().mockResolvedValue(task) },
824
+ });
825
+ fireEvent.click(await screen.findByRole('button', { name: 'Done' }));
826
+ await waitFor(() => expect(application.serverGroups.onNextRefresh).toHaveBeenCalledTimes(1));
827
+
828
+ // A second task completion arrives before the first refresh callback has fired.
829
+ await act(async () => monitor!.handleTaskSuccess(task));
830
+ await waitFor(() => expect(application.serverGroups.onNextRefresh).toHaveBeenCalledTimes(2));
1132
831
 
1133
832
  expect(firstUnsubscribe).toHaveBeenCalledTimes(1);
1134
833
  expect(secondUnsubscribe).not.toHaveBeenCalled();
1135
834
  });
1136
835
  });
1137
836
 
837
+ function renderModal(
838
+ command: IGceServerGroupCommand,
839
+ adapter: IGceServerGroupWizardAdapter | undefined,
840
+ runtimeServices: Record<string, any> = { serverGroupWriter: { cloneServerGroup: vi.fn() } },
841
+ overrides: Record<string, any> = {},
842
+ ) {
843
+ const props = { ...buildProps(command, adapter), ...overrides };
844
+ const router = new UIRouterReact();
845
+ const element = (nextProps = props) => (
846
+ <UIRouterContext.Provider value={router}>
847
+ <DeckRuntimeContext.Provider value={{ services: runtimeServices } as any}>
848
+ <GceCloneServerGroupModal {...nextProps} />
849
+ </DeckRuntimeContext.Provider>
850
+ </UIRouterContext.Provider>
851
+ );
852
+ const rendered = render(element());
853
+ return {
854
+ ...rendered,
855
+ props,
856
+ rerenderCommand(nextCommand: IGceServerGroupCommand) {
857
+ rendered.rerender(element({ ...props, command: nextCommand }));
858
+ },
859
+ };
860
+ }
861
+
1138
862
  function buildCommand(overrides: Partial<IGceServerGroupCommand> = {}): IGceServerGroupCommand {
1139
863
  return {
1140
864
  application: 'fnord',
1141
865
  backingData: {
1142
866
  accounts: ['gce-account'],
867
+ allImages: [{ imageName: 'ubuntu' }],
1143
868
  filtered: {
1144
869
  cpuPlatforms: ['(Automatic)'],
1145
870
  images: ['ubuntu'],
@@ -1167,12 +892,7 @@ function buildCommand(overrides: Partial<IGceServerGroupCommand> = {}): IGceServ
1167
892
  stack: 'main',
1168
893
  subnet: 'default',
1169
894
  tags: [],
1170
- viewState: {
1171
- dirty: {},
1172
- disableImageSelection: false,
1173
- mode: 'create',
1174
- useSimpleCapacity: true,
1175
- },
895
+ viewState: { dirty: {}, disableImageSelection: false, mode: 'create', useSimpleCapacity: true },
1176
896
  zone: 'us-central1-a',
1177
897
  ...overrides,
1178
898
  };
@@ -1182,29 +902,30 @@ function buildProps(command: IGceServerGroupCommand, adapter?: IGceServerGroupWi
1182
902
  return {
1183
903
  adapter,
1184
904
  application,
1185
- closeModal: jasmine.createSpy('closeModal'),
905
+ closeModal: vi.fn(),
1186
906
  command,
1187
- dismissModal: jasmine.createSpy('dismissModal'),
907
+ dismissModal: vi.fn(),
1188
908
  router: {},
1189
909
  stateParams: {},
1190
- stateService: { go: jasmine.createSpy('go'), includes: () => false },
910
+ stateService: { go: vi.fn(), includes: () => false },
1191
911
  title: 'Configure GCE server group',
1192
912
  };
1193
913
  }
1194
914
 
1195
- function buildAdapter(): any {
915
+ function buildAdapter(overrides: Record<string, any> = {}): any {
1196
916
  return {
1197
- applyCommandHandler: jasmine.createSpy('applyCommandHandler'),
1198
- applyConfigurationRefresh: jasmine.createSpy('applyConfigurationRefresh'),
1199
- applyConfigurationUpdate: jasmine.createSpy('applyConfigurationUpdate'),
1200
- buildNewServerGroupCommand: jasmine.createSpy('buildNewServerGroupCommand'),
1201
- configureCommand: jasmine.createSpy('configureCommand'),
917
+ applyCommandHandler: vi.fn(),
918
+ applyConfigurationRefresh: vi.fn(),
919
+ applyConfigurationUpdate: vi.fn(),
920
+ buildNewServerGroupCommand: vi.fn(),
921
+ configureCommand: vi.fn().mockResolvedValue(buildCommand()),
922
+ ...overrides,
1202
923
  };
1203
924
  }
1204
925
 
1205
926
  function buildInitializationHandlers(
1206
927
  onCall: (handler: string, command: IGceServerGroupCommand) => void = () => undefined,
1207
- ): Record<string, jasmine.Spy> {
928
+ ): Record<string, ReturnType<typeof vi.fn>> {
1208
929
  return [
1209
930
  'credentialsChanged',
1210
931
  'regionalChanged',
@@ -1213,16 +934,33 @@ function buildInitializationHandlers(
1213
934
  'zoneChanged',
1214
935
  'customInstanceChanged',
1215
936
  ].reduce((handlers, handler) => {
1216
- handlers[handler] = jasmine.createSpy(handler).and.callFake((command: IGceServerGroupCommand) => {
937
+ handlers[handler] = vi.fn().mockImplementation((command: IGceServerGroupCommand) => {
1217
938
  onCall(handler, command);
1218
939
  return { dirty: {} };
1219
940
  });
1220
941
  return handlers;
1221
- }, {} as Record<string, jasmine.Spy>);
942
+ }, {} as Record<string, ReturnType<typeof vi.fn>>);
1222
943
  }
1223
944
 
1224
- function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
1225
- let resolve: (value: T) => void;
1226
- const promise = new Promise<T>((promiseResolve) => (resolve = promiseResolve));
1227
- return { promise, resolve };
945
+ function cloneTask(): any {
946
+ return {
947
+ execution: {
948
+ stages: [
949
+ {
950
+ context: { 'deploy.server.groups': { 'us-central1': 'fnord-main-api-v042' } },
951
+ type: 'cloneServerGroup',
952
+ },
953
+ ],
954
+ },
955
+ };
956
+ }
957
+
958
+ function deferred<T>() {
959
+ let resolve!: (value: T) => void;
960
+ let reject!: (reason: unknown) => void;
961
+ const promise = new Promise<T>((promiseResolve, promiseReject) => {
962
+ resolve = promiseResolve;
963
+ reject = promiseReject;
964
+ });
965
+ return { promise, reject, resolve };
1228
966
  }