@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
@@ -12,6 +12,27 @@ import { GCEProviderSettings } from '../../../../gce.settings';
12
12
  const MAX_INT = 2147483647;
13
13
  const MAX_SCHEDULE_DURATION_SECONDS = 14 * 24 * 60 * 60;
14
14
 
15
+ interface IAutoscalingPolicyValidationErrors {
16
+ coolDownPeriodSec?: string;
17
+ maxNumReplicas?: string;
18
+ metric?: string;
19
+ minNumReplicas?: string;
20
+ predictiveAutoscaling?: string;
21
+ scaleInControl?: string;
22
+ scalingSchedules?: string;
23
+ }
24
+
25
+ interface IAutoHealingPolicyValidationErrors {
26
+ healthCheck?: string;
27
+ healthCheckKind?: string;
28
+ initialDelaySec?: string;
29
+ }
30
+
31
+ interface IPoliciesValidationErrors {
32
+ autoHealingPolicy?: IAutoHealingPolicyValidationErrors;
33
+ autoscalingPolicy?: IAutoscalingPolicyValidationErrors;
34
+ }
35
+
15
36
  function isIntegerInRange(value: unknown, minimum: number, maximum = MAX_INT): value is number {
16
37
  return (
17
38
  typeof value === 'number' &&
@@ -92,61 +113,8 @@ function hasValidScaleInControl(policy: IGceAutoscalingPolicy): boolean {
92
113
  }
93
114
 
94
115
  export class Policies extends GceServerGroupWizardPage {
95
- public validate(values: IGceServerGroupCommand): { [key: string]: any } {
96
- const errors: { [key: string]: any } = {};
97
-
98
- if (values.autoscalingPolicy) {
99
- const policy = values.autoscalingPolicy as IGceAutoscalingPolicy;
100
- const policyErrors: { [key: string]: string } = {};
101
- const validMinimum = isIntegerInRange(policy.minNumReplicas, 0);
102
- const validMaximum = isIntegerInRange(policy.maxNumReplicas, 0);
103
- if (!validMinimum) {
104
- policyErrors.minNumReplicas = 'Minimum capacity must be a nonnegative integer.';
105
- }
106
- if (!validMaximum) {
107
- policyErrors.maxNumReplicas = 'Maximum capacity must be a nonnegative integer.';
108
- } else if (validMinimum && policy.maxNumReplicas < policy.minNumReplicas) {
109
- policyErrors.maxNumReplicas = 'Maximum capacity must be at least the minimum capacity.';
110
- }
111
- if (!isIntegerInRange(policy.coolDownPeriodSec, 15)) {
112
- policyErrors.coolDownPeriodSec = 'Cool-down period must be an integer of at least 15 seconds.';
113
- }
114
- if (!hasValidMetrics(policy)) {
115
- policyErrors.metric = 'At least one complete autoscaling metric required.';
116
- }
117
- if (!hasValidSchedules(policy)) {
118
- policyErrors.scalingSchedules = 'Every scaling schedule must be complete and within supported bounds.';
119
- }
120
- if (!hasValidScaleInControl(policy)) {
121
- policyErrors.scaleInControl = 'Scale-in control values are outside supported bounds.';
122
- }
123
- const predictiveEnabled = policy.cpuUtilization?.predictiveMethod === GcePredictiveMethod.STANDARD;
124
- if (predictiveEnabled && !GCEProviderSettings.feature.predictiveAutoscaling) {
125
- policyErrors.predictiveAutoscaling = 'Predictive autoscaling is not enabled.';
126
- }
127
- if (Object.keys(policyErrors).length) {
128
- errors.autoscalingPolicy = policyErrors;
129
- }
130
- }
131
-
132
- if (values.enableAutoHealing) {
133
- const policy = (values.autoHealingPolicy || {}) as IGceAutoHealingPolicy;
134
- const policyErrors: { [key: string]: string } = {};
135
- if (!policy.healthCheck?.trim()) {
136
- policyErrors.healthCheck = 'Health check required.';
137
- }
138
- if (!policy.healthCheckKind) {
139
- policyErrors.healthCheckKind = 'Health check kind required.';
140
- }
141
- if (!isIntegerInRange(policy.initialDelaySec, 0)) {
142
- policyErrors.initialDelaySec = 'Initial delay must be an integer between 0 and 2147483647 seconds.';
143
- }
144
- if (Object.keys(policyErrors).length) {
145
- errors.autoHealingPolicy = policyErrors;
146
- }
147
- }
148
-
149
- return errors;
116
+ public validate(values: IGceServerGroupCommand): IPoliciesValidationErrors {
117
+ return validateGceServerGroupPolicies(values);
150
118
  }
151
119
 
152
120
  private setField = (field: string, value: any): void => {
@@ -285,6 +253,63 @@ export class Policies extends GceServerGroupWizardPage {
285
253
  }
286
254
  }
287
255
 
256
+ export function validateGceServerGroupPolicies(values: IGceServerGroupCommand): IPoliciesValidationErrors {
257
+ const errors: IPoliciesValidationErrors = {};
258
+
259
+ if (values.autoscalingPolicy) {
260
+ const policy = values.autoscalingPolicy as IGceAutoscalingPolicy;
261
+ const policyErrors: IAutoscalingPolicyValidationErrors = {};
262
+ const validMinimum = isIntegerInRange(policy.minNumReplicas, 0);
263
+ const validMaximum = isIntegerInRange(policy.maxNumReplicas, 0);
264
+ if (!validMinimum) {
265
+ policyErrors.minNumReplicas = 'Minimum capacity must be a nonnegative integer.';
266
+ }
267
+ if (!validMaximum) {
268
+ policyErrors.maxNumReplicas = 'Maximum capacity must be a nonnegative integer.';
269
+ } else if (validMinimum && policy.maxNumReplicas < policy.minNumReplicas) {
270
+ policyErrors.maxNumReplicas = 'Maximum capacity must be at least the minimum capacity.';
271
+ }
272
+ if (!isIntegerInRange(policy.coolDownPeriodSec, 15)) {
273
+ policyErrors.coolDownPeriodSec = 'Cool-down period must be an integer of at least 15 seconds.';
274
+ }
275
+ if (!hasValidMetrics(policy)) {
276
+ policyErrors.metric = 'At least one complete autoscaling metric required.';
277
+ }
278
+ if (!hasValidSchedules(policy)) {
279
+ policyErrors.scalingSchedules = 'Every scaling schedule must be complete and within supported bounds.';
280
+ }
281
+ if (!hasValidScaleInControl(policy)) {
282
+ policyErrors.scaleInControl = 'Scale-in control values are outside supported bounds.';
283
+ }
284
+ const predictiveEnabled = policy.cpuUtilization?.predictiveMethod === GcePredictiveMethod.STANDARD;
285
+ if (predictiveEnabled && !GCEProviderSettings.feature.predictiveAutoscaling) {
286
+ policyErrors.predictiveAutoscaling = 'Predictive autoscaling is not enabled.';
287
+ }
288
+ if (Object.keys(policyErrors).length) {
289
+ errors.autoscalingPolicy = policyErrors;
290
+ }
291
+ }
292
+
293
+ if (values.enableAutoHealing) {
294
+ const policy = (values.autoHealingPolicy || {}) as IGceAutoHealingPolicy;
295
+ const policyErrors: IAutoHealingPolicyValidationErrors = {};
296
+ if (!policy.healthCheck?.trim()) {
297
+ policyErrors.healthCheck = 'Health check required.';
298
+ }
299
+ if (!policy.healthCheckKind) {
300
+ policyErrors.healthCheckKind = 'Health check kind required.';
301
+ }
302
+ if (!isIntegerInRange(policy.initialDelaySec, 0)) {
303
+ policyErrors.initialDelaySec = 'Initial delay must be an integer between 0 and 2147483647 seconds.';
304
+ }
305
+ if (Object.keys(policyErrors).length) {
306
+ errors.autoHealingPolicy = policyErrors;
307
+ }
308
+ }
309
+
310
+ return errors;
311
+ }
312
+
288
313
  function coherentDesiredCapacity(desired: any, min: any, max: any): any {
289
314
  if (typeof desired === 'number' && typeof min === 'number' && typeof max === 'number') {
290
315
  return Math.min(max, Math.max(min, desired));
@@ -1,30 +1,67 @@
1
+ import type { Mock, Mocked } from 'vitest';
1
2
  import type { FormikProps } from 'formik';
2
3
  import React from 'react';
3
- import { shallow } from 'enzyme';
4
-
5
- import { DeploymentStrategySelector, TaskReason } from '@spinnaker/core';
4
+ import { fireEvent, render } from '@testing-library/react';
5
+ import type { RenderResult } from '@testing-library/react';
6
6
 
7
7
  import { ServerGroupBasicSettings } from './ServerGroupBasicSettings';
8
8
  import { GceImageReader } from '../../../../image';
9
9
  import type { IGceServerGroupCommand, IGceServerGroupWizardAdapter } from '../GceServerGroupWizard.types';
10
+ import { validateGceServerGroupBasicSettings } from './ServerGroupBasicSettings';
11
+
12
+ vi.mock('@spinnaker/core', async (importOriginal) => {
13
+ const actual = await importOriginal<typeof import('@spinnaker/core')>();
14
+ const MockTaskReason = ({ onChange, reason }: any) => (
15
+ <input
16
+ aria-label="Task reason"
17
+ data-component="MockTaskReason"
18
+ onChange={(event) => onChange(event.target.value)}
19
+ value={reason || ''}
20
+ />
21
+ );
22
+ const MockDeploymentStrategySelector = ({ command, onFieldChange, onStrategyChange }: any) => (
23
+ <div data-component="MockDeploymentStrategySelector">
24
+ <output aria-label="Selected strategy">{command.strategy}</output>
25
+ <button
26
+ onClick={() =>
27
+ onStrategyChange(
28
+ { ...command, strategy: 'redblack', maxRemainingAsgs: 2, scaleDown: false },
29
+ { key: 'redblack', label: 'Red/Black' },
30
+ )
31
+ }
32
+ type="button"
33
+ >
34
+ Select red black
35
+ </button>
36
+ <button onClick={() => onFieldChange('maxRemainingAsgs', 3)} type="button">
37
+ Update strategy field
38
+ </button>
39
+ </div>
40
+ );
41
+ return {
42
+ ...actual,
43
+ DeploymentStrategySelector: MockDeploymentStrategySelector,
44
+ TaskReason: MockTaskReason,
45
+ };
46
+ });
10
47
 
11
48
  describe('ServerGroupBasicSettings', () => {
12
49
  it('renders accessible location fields and preserves persisted unavailable references', () => {
13
50
  const { formik } = testProps();
14
- const wrapper = shallow(<ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} />);
51
+ const wrapper = renderPage(<ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} />);
15
52
 
16
53
  expect(selectOptions(wrapper, 'Account')).toEqual([
17
54
  ['', 'Select...'],
18
55
  ['known-account', 'known-account'],
19
56
  ['persisted-account', 'persisted-account (unavailable)'],
20
57
  ]);
21
- expect(selectOptions(wrapper, 'Region')).toContain(['persisted-region', 'persisted-region (unavailable)']);
22
- expect(selectOptions(wrapper, 'Zone')).toContain(['persisted-zone', 'persisted-zone (unavailable)']);
23
- expect(selectOptions(wrapper, 'Network')).toContain(['persisted-network', 'persisted-network (unavailable)']);
24
- expect(selectOptions(wrapper, 'Subnet')).toContain(['persisted-subnet', 'persisted-subnet (unavailable)']);
25
- expect(wrapper.find('[aria-label="Location mode"]').prop('value')).toBe('zonal');
26
- expect(wrapper.find('input[aria-label="Stack"]').prop('value')).toBe('main');
27
- expect(wrapper.find('input[aria-label="Detail"]').prop('value')).toBe('detail');
58
+ expect(selectOptions(wrapper, 'Region')).toContainEqual(['persisted-region', 'persisted-region (unavailable)']);
59
+ expect(selectOptions(wrapper, 'Zone')).toContainEqual(['persisted-zone', 'persisted-zone (unavailable)']);
60
+ expect(selectOptions(wrapper, 'Network')).toContainEqual(['persisted-network', 'persisted-network (unavailable)']);
61
+ expect(selectOptions(wrapper, 'Subnet')).toContainEqual(['persisted-subnet', 'persisted-subnet (unavailable)']);
62
+ expect(wrapper.getByLabelText('Location mode')).toHaveValue('zonal');
63
+ expect(wrapper.getByLabelText('Stack')).toHaveValue('main');
64
+ expect(wrapper.getByLabelText('Detail')).toHaveValue('detail');
28
65
  expect(formik.setValues).not.toHaveBeenCalled();
29
66
  expect(formik.setFieldValue).not.toHaveBeenCalled();
30
67
  });
@@ -44,14 +81,14 @@ describe('ServerGroupBasicSettings', () => {
44
81
  subnet: '',
45
82
  });
46
83
  const { adapter, formik } = testProps(command(), reconciled);
47
- const wrapper = shallow(
84
+ const wrapper = renderPage(
48
85
  <ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} adapter={adapter} />,
49
86
  );
50
87
 
51
- wrapper.find(`[aria-label="${label}"]`).simulate('change', { target: { value: selected } });
88
+ fireEvent.change(wrapper.getByLabelText(label), { target: { value: selected } });
52
89
  await flush();
53
90
 
54
- const changedCommand = adapter.applyCommandHandler.calls.mostRecent().args[0];
91
+ const changedCommand = adapter.applyCommandHandler.mock.lastCall[0];
55
92
  expect(changedCommand[field]).toBe(field === 'regional' ? true : selected);
56
93
  expect(adapter.applyCommandHandler).toHaveBeenCalledWith(changedCommand, handler);
57
94
  expect(formik.setValues).toHaveBeenCalledWith(reconciled);
@@ -60,7 +97,7 @@ describe('ServerGroupBasicSettings', () => {
60
97
 
61
98
  it('reloads account-scoped images in addition to reconciling account-dependent fields', async () => {
62
99
  const images = [{ imageName: 'new-account-image' }];
63
- spyOn(GceImageReader, 'findImages').and.resolveTo(images);
100
+ vi.spyOn(GceImageReader, 'findImages').mockResolvedValue(images);
64
101
  const values = command({
65
102
  backingData: {
66
103
  ...command().backingData,
@@ -68,14 +105,14 @@ describe('ServerGroupBasicSettings', () => {
68
105
  },
69
106
  });
70
107
  const { adapter, formik } = testProps(values);
71
- adapter.applyCommandHandler.and.callFake((working: IGceServerGroupCommand) =>
108
+ adapter.applyCommandHandler.mockImplementation((working: IGceServerGroupCommand) =>
72
109
  Promise.resolve({ command: { ...working, region: null }, result: { dirty: {} } }),
73
110
  );
74
- const wrapper = shallow(
111
+ const wrapper = renderPage(
75
112
  <ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} adapter={adapter} />,
76
113
  );
77
114
 
78
- wrapper.find('[aria-label="Account"]').simulate('change', { target: { value: 'known-account' } });
115
+ fireEvent.change(wrapper.getByLabelText('Account'), { target: { value: 'known-account' } });
79
116
  await flush();
80
117
 
81
118
  expect(GceImageReader.findImages).toHaveBeenCalledWith({
@@ -84,10 +121,10 @@ describe('ServerGroupBasicSettings', () => {
84
121
  q: '*',
85
122
  });
86
123
  expect(adapter.applyCommandHandler).toHaveBeenCalledWith(
87
- jasmine.objectContaining({ credentials: 'known-account' }),
124
+ expect.objectContaining({ credentials: 'known-account' }),
88
125
  'credentialsChanged',
89
126
  );
90
- expect(formik.setValues.calls.mostRecent().args[0].backingData.allImages).toEqual(images);
127
+ expect(formik.setValues.mock.lastCall[0].backingData.allImages).toEqual(images);
91
128
  });
92
129
 
93
130
  ([
@@ -105,7 +142,7 @@ describe('ServerGroupBasicSettings', () => {
105
142
  },
106
143
  ] as const).forEach(({ description, expectedImage, image, images }) => {
107
144
  it(description, async () => {
108
- spyOn(GceImageReader, 'findImages').and.resolveTo(images as any);
145
+ vi.spyOn(GceImageReader, 'findImages').mockResolvedValue(images as any);
109
146
  const values = command({
110
147
  image,
111
148
  backingData: {
@@ -115,16 +152,14 @@ describe('ServerGroupBasicSettings', () => {
115
152
  });
116
153
  const formik = publishingFormik(values);
117
154
  const adapter = imageValidatingAdapter();
118
- const wrapper = shallow(
155
+ const wrapper = renderPage(
119
156
  <ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} adapter={adapter} />,
120
157
  );
121
158
 
122
- const request = (wrapper.find('[aria-label="Account"]').prop('onChange') as any)({
123
- target: { value: 'known-account' },
124
- });
125
- await request;
159
+ fireEvent.change(wrapper.getByLabelText('Account'), { target: { value: 'known-account' } });
160
+ await flush();
126
161
 
127
- expect(adapter.applyCommandHandler.calls.mostRecent().args[0].backingData.allImages).toEqual(images);
162
+ expect(adapter.applyCommandHandler.mock.lastCall[0].backingData.allImages).toEqual(images);
128
163
  expect(formik.values.image).toBe(expectedImage);
129
164
  });
130
165
  });
@@ -132,7 +167,7 @@ describe('ServerGroupBasicSettings', () => {
132
167
  it('keeps the latest account images and user edits when account reloads finish out of order', async () => {
133
168
  const firstImages = deferred<Array<{ imageName: string }>>();
134
169
  const secondImages = deferred<Array<{ imageName: string }>>();
135
- spyOn(GceImageReader, 'findImages').and.callFake(({ account }) =>
170
+ vi.spyOn(GceImageReader, 'findImages').mockImplementation(({ account }) =>
136
171
  account === 'first-account' ? firstImages.promise : secondImages.promise,
137
172
  );
138
173
  const values = command({
@@ -144,24 +179,22 @@ describe('ServerGroupBasicSettings', () => {
144
179
  });
145
180
  const formik = publishingFormik(values);
146
181
  const adapter = ({
147
- applyCommandHandler: jasmine
148
- .createSpy('applyCommandHandler')
149
- .and.callFake((working: IGceServerGroupCommand) =>
182
+ applyCommandHandler: vi
183
+ .fn()
184
+ .mockImplementation((working: IGceServerGroupCommand) =>
150
185
  Promise.resolve({ command: working, result: { dirty: {} } }),
151
186
  ),
152
- } as unknown) as jasmine.SpyObj<IGceServerGroupWizardAdapter>;
153
- const wrapper = shallow(
187
+ } as unknown) as Mocked<IGceServerGroupWizardAdapter>;
188
+ const wrapper = renderPage(
154
189
  <ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} adapter={adapter} />,
155
190
  );
156
- const changeAccount = wrapper.find('[aria-label="Account"]').prop('onChange') as any;
157
-
158
- const firstRequest = changeAccount({ target: { value: 'first-account' } });
159
- const secondRequest = changeAccount({ target: { value: 'second-account' } });
191
+ fireEvent.change(wrapper.getByLabelText('Account'), { target: { value: 'first-account' } });
192
+ fireEvent.change(wrapper.getByLabelText('Account'), { target: { value: 'second-account' } });
160
193
  secondImages.resolve([{ imageName: 'second-account-image' }]);
161
- await secondRequest;
194
+ await flush();
162
195
  formik.values = { ...formik.values, freeFormDetails: 'edited-while-loading' };
163
196
  firstImages.resolve([{ imageName: 'first-account-image' }]);
164
- await firstRequest;
197
+ await flush();
165
198
 
166
199
  expect(formik.values.credentials).toBe('second-account');
167
200
  expect(formik.values.backingData.allImages).toEqual([{ imageName: 'second-account-image' }]);
@@ -170,15 +203,15 @@ describe('ServerGroupBasicSettings', () => {
170
203
 
171
204
  it('updates subnet, stack, and detail without invoking a parent handler', () => {
172
205
  const { adapter, formik } = testProps();
173
- const wrapper = shallow(
206
+ const wrapper = renderPage(
174
207
  <ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} adapter={adapter} />,
175
208
  );
176
209
 
177
- wrapper.find('[aria-label="Subnet"]').simulate('change', { target: { value: 'known-subnet' } });
178
- wrapper.find('input[aria-label="Stack"]').simulate('change', { target: { value: 'new-stack' } });
179
- wrapper.find('input[aria-label="Detail"]').simulate('change', { target: { value: 'new-detail' } });
210
+ fireEvent.change(wrapper.getByLabelText('Subnet'), { target: { value: 'known-subnet' } });
211
+ fireEvent.change(wrapper.getByLabelText('Stack'), { target: { value: 'new-stack' } });
212
+ fireEvent.change(wrapper.getByLabelText('Detail'), { target: { value: 'new-detail' } });
180
213
 
181
- expect(formik.setFieldValue.calls.allArgs()).toEqual([
214
+ expect(formik.setFieldValue.mock.calls).toEqual([
182
215
  ['subnet', 'known-subnet'],
183
216
  ['stack', 'new-stack'],
184
217
  ['freeFormDetails', 'new-detail'],
@@ -189,20 +222,18 @@ describe('ServerGroupBasicSettings', () => {
189
222
  (['create', 'clone'] as const).forEach((mode) => {
190
223
  it(`binds the optional task reason to the command in ${mode} mode`, () => {
191
224
  const { formik } = testProps(command({ reason: 'existing reason', viewState: { mode } }));
192
- const wrapper = shallow(<ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} />);
193
- const taskReason = wrapper.find(TaskReason);
194
-
195
- expect(taskReason.prop('reason')).toBe('existing reason');
196
- taskReason.prop('onChange')('updated reason');
225
+ const wrapper = renderPage(<ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} />);
226
+ expect(wrapper.getByLabelText('Task reason')).toHaveValue('existing reason');
227
+ fireEvent.change(wrapper.getByLabelText('Task reason'), { target: { value: 'updated reason' } });
197
228
  expect(formik.setFieldValue).toHaveBeenCalledWith('reason', 'updated reason');
198
229
  });
199
230
  });
200
231
 
201
232
  it('hides the zonal field in regional mode', () => {
202
233
  const { formik } = testProps(command({ regional: true, zone: null }));
203
- const wrapper = shallow(<ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} />);
234
+ const wrapper = renderPage(<ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} />);
204
235
 
205
- expect(wrapper.find('[aria-label="Zone"]').exists()).toBe(false);
236
+ expect(wrapper.container.querySelector('[aria-label="Zone"]')).not.toBeInTheDocument();
206
237
  });
207
238
 
208
239
  ([
@@ -216,33 +247,32 @@ describe('ServerGroupBasicSettings', () => {
216
247
  viewState: { mode, disableStrategySelection: false },
217
248
  });
218
249
  const { formik } = testProps(values);
219
- const wrapper = shallow(<ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} />);
220
- const traffic = wrapper.find('[aria-label="Send client requests to new instances"]');
250
+ const wrapper = renderPage(<ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} />);
251
+ const traffic = wrapper.getByLabelText('Send client requests to new instances');
221
252
 
222
- expect(traffic.prop('checked')).toBe(enableTraffic);
223
- expect(wrapper.find('[aria-label="Deployment strategy"]').prop('role')).toBe('group');
224
- expect(wrapper.find(DeploymentStrategySelector).prop('command')).toBe(values as any);
253
+ expect(traffic).toHaveProperty('checked', enableTraffic);
254
+ expect(wrapper.getByRole('group', { name: 'Deployment strategy' })).toBeInTheDocument();
255
+ expect(wrapper.getByLabelText('Selected strategy')).toHaveTextContent('custom');
225
256
 
226
- traffic.simulate('change', { target: { checked: !enableTraffic } });
257
+ fireEvent.click(traffic);
227
258
  expect(formik.setFieldValue).toHaveBeenCalledWith('enableTraffic', !enableTraffic);
228
259
  });
229
260
  });
230
261
 
231
262
  it('forwards strategy commands and strategy-specific fields through Formik and the command callback', () => {
232
- const onStrategyChange = jasmine.createSpy('onStrategyChange');
263
+ const onStrategyChange = vi.fn();
233
264
  const values = command({
234
265
  onStrategyChange,
235
266
  strategy: '',
236
267
  viewState: { mode: 'editPipeline', disableStrategySelection: false },
237
268
  });
238
269
  const { formik } = testProps(values);
239
- const wrapper = shallow(<ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} />);
240
- const selector = wrapper.find(DeploymentStrategySelector);
270
+ const wrapper = renderPage(<ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} />);
241
271
  const strategy = { key: 'redblack', label: 'Red/Black' } as any;
242
272
  const updatedCommand = { ...values, strategy: 'redblack', maxRemainingAsgs: 2, scaleDown: false };
243
273
 
244
- selector.prop('onStrategyChange')(updatedCommand as any, strategy);
245
- selector.prop('onFieldChange')('maxRemainingAsgs', 3);
274
+ fireEvent.click(wrapper.getByRole('button', { name: 'Select red black' }));
275
+ fireEvent.click(wrapper.getByRole('button', { name: 'Update strategy field' }));
246
276
 
247
277
  expect(onStrategyChange).toHaveBeenCalledWith(updatedCommand, strategy);
248
278
  expect(formik.setValues).toHaveBeenCalledWith(updatedCommand);
@@ -257,10 +287,9 @@ describe('ServerGroupBasicSettings', () => {
257
287
  viewState: { mode: 'create', disableStrategySelection: true },
258
288
  });
259
289
  const { formik } = testProps(values);
260
- const wrapper = shallow(<ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} />);
290
+ const wrapper = renderPage(<ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} />);
261
291
 
262
- expect(wrapper.find(DeploymentStrategySelector).exists()).toBe(false);
263
- expect(wrapper.find('[aria-label="Deployment strategy"]').exists()).toBe(false);
292
+ expect(wrapper.container.querySelector('[aria-label="Deployment strategy"]')).not.toBeInTheDocument();
264
293
  expect(values.strategy).toBe('custom');
265
294
  expect(values.strategyApplication).toBe('strategy-app');
266
295
  expect(values.strategyPipeline).toBe('strategy-pipeline');
@@ -270,10 +299,8 @@ describe('ServerGroupBasicSettings', () => {
270
299
 
271
300
  it('validates required fields and naming rules owned by the page', () => {
272
301
  const { formik } = testProps();
273
- const page = new ServerGroupBasicSettings({ app: { name: 'app' } as any, formik } as any);
274
-
275
302
  expect(
276
- page.validate(
303
+ validateGceServerGroupBasicSettings(
277
304
  command({ credentials: '', region: '', zone: '', stack: 'invalid stack', freeFormDetails: 'bad_detail' }),
278
305
  ),
279
306
  ).toEqual({
@@ -294,7 +321,7 @@ describe('ServerGroupBasicSettings', () => {
294
321
  freeFormDetails: 'bad_detail',
295
322
  });
296
323
  const { formik } = testProps(values);
297
- const wrapper = shallow(<ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} />);
324
+ const wrapper = renderPage(<ServerGroupBasicSettings app={{ name: 'app' } as any} formik={formik} />);
298
325
 
299
326
  [
300
327
  ['Account', 'credentials', 'Account required.'],
@@ -303,14 +330,14 @@ describe('ServerGroupBasicSettings', () => {
303
330
  ['Stack', 'stack', 'Stack can only contain letters and numbers.'],
304
331
  ['Detail', 'freeFormDetails', 'Detail can only contain letters, numbers, and dashes.'],
305
332
  ].forEach(([label, field, message]) => {
306
- const control = wrapper.find(`[aria-label="${label}"]`);
333
+ const control = wrapper.getByLabelText(label);
307
334
  const errorId = `gce-server-group-${field}-error`;
308
- const alert = wrapper.find(`[id="${errorId}"][role="alert"]`);
335
+ const alert = wrapper.container.querySelector(`[id="${errorId}"][role="alert"]`);
309
336
 
310
- expect(control.prop('aria-invalid')).withContext(label).toBe(true);
311
- expect(control.prop('aria-describedby')).withContext(label).toBe(errorId);
312
- expect(alert.length).withContext(label).toBe(1);
313
- expect(alert.text()).withContext(label).toBe(message);
337
+ expect(control, label).toHaveAttribute('aria-invalid', 'true');
338
+ expect(control, label).toHaveAttribute('aria-describedby', errorId);
339
+ expect(alert, label).toBeInTheDocument();
340
+ expect(alert, label).toHaveTextContent(message);
314
341
  });
315
342
  });
316
343
 
@@ -321,47 +348,44 @@ describe('ServerGroupBasicSettings', () => {
321
348
  viewState: { mode: 'editPipeline', templatingEnabled: true },
322
349
  });
323
350
  const { formik } = testProps(values);
324
- const page = new ServerGroupBasicSettings({ app: { name: 'app' } as any, formik } as any);
325
-
326
- expect(page.validate(values)).toEqual({});
351
+ expect(validateGceServerGroupBasicSettings(values)).toEqual({});
327
352
  });
328
353
  });
329
354
 
330
- function selectOptions(wrapper: ReturnType<typeof shallow>, label: string): string[][] {
331
- return wrapper
332
- .find(`[aria-label="${label}"] option`)
333
- .map((option) => [option.prop('value') as string, option.text()]);
355
+ function selectOptions(wrapper: RenderResult, label: string): string[][] {
356
+ return Array.from(wrapper.getByLabelText(label).querySelectorAll('option')).map((option) => [
357
+ option.value,
358
+ option.textContent || '',
359
+ ]);
334
360
  }
335
361
 
336
362
  function testProps(values = command(), adapterResult = values) {
337
363
  const formik = ({
338
364
  values,
339
- setFieldValue: jasmine.createSpy('setFieldValue'),
340
- setValues: jasmine.createSpy('setValues'),
365
+ setFieldValue: vi.fn(),
366
+ setValues: vi.fn(),
341
367
  } as unknown) as FormikProps<IGceServerGroupCommand>;
342
368
  const adapter = ({
343
- applyCommandHandler: jasmine
344
- .createSpy('applyCommandHandler')
345
- .and.resolveTo({ command: adapterResult, result: { dirty: {} } }),
346
- } as unknown) as jasmine.SpyObj<IGceServerGroupWizardAdapter>;
369
+ applyCommandHandler: vi.fn().mockResolvedValue({ command: adapterResult, result: { dirty: {} } }),
370
+ } as unknown) as Mocked<IGceServerGroupWizardAdapter>;
347
371
  return { adapter, formik };
348
372
  }
349
373
 
350
374
  function publishingFormik(values = command()): FormikProps<IGceServerGroupCommand> {
351
375
  const formik = ({
352
376
  values,
353
- setFieldValue: jasmine.createSpy('setFieldValue'),
354
- setValues: jasmine.createSpy('setValues'),
377
+ setFieldValue: vi.fn(),
378
+ setValues: vi.fn(),
355
379
  } as unknown) as FormikProps<IGceServerGroupCommand>;
356
- (formik.setValues as jasmine.Spy).and.callFake((nextValues: IGceServerGroupCommand) => {
380
+ (formik.setValues as Mock).mockImplementation((nextValues: IGceServerGroupCommand) => {
357
381
  formik.values = nextValues;
358
382
  });
359
383
  return formik;
360
384
  }
361
385
 
362
- function imageValidatingAdapter(): jasmine.SpyObj<IGceServerGroupWizardAdapter> {
386
+ function imageValidatingAdapter(): Mocked<IGceServerGroupWizardAdapter> {
363
387
  return ({
364
- applyCommandHandler: jasmine.createSpy('applyCommandHandler').and.callFake((working: IGceServerGroupCommand) => {
388
+ applyCommandHandler: vi.fn().mockImplementation((working: IGceServerGroupCommand) => {
365
389
  const imageAvailable = (working.backingData.allImages || []).some(
366
390
  ({ imageName }: { imageName: string }) => imageName === working.image,
367
391
  );
@@ -370,7 +394,7 @@ function imageValidatingAdapter(): jasmine.SpyObj<IGceServerGroupWizardAdapter>
370
394
  result: { dirty: {} },
371
395
  });
372
396
  }),
373
- } as unknown) as jasmine.SpyObj<IGceServerGroupWizardAdapter>;
397
+ } as unknown) as Mocked<IGceServerGroupWizardAdapter>;
374
398
  }
375
399
 
376
400
  function command(overrides: Partial<IGceServerGroupCommand> = {}): IGceServerGroupCommand {
@@ -416,3 +440,8 @@ function deferred<T>() {
416
440
  });
417
441
  return { promise, resolve };
418
442
  }
443
+
444
+ function renderPage(component: React.ReactElement): RenderResult {
445
+ const container = document.body.appendChild(document.createElement('div'));
446
+ return render(component, { baseElement: container, container });
447
+ }
@@ -17,26 +17,32 @@ interface ISelectOption {
17
17
  value: string;
18
18
  }
19
19
 
20
+ export function validateGceServerGroupBasicSettings(
21
+ values: IGceServerGroupCommand,
22
+ ): IGceServerGroupCommandValidationErrors {
23
+ const sharedErrors = validateGceServerGroupCommand(values);
24
+ const errors: IGceServerGroupCommandValidationErrors = {};
25
+ if (sharedErrors.credentials) errors.credentials = sharedErrors.credentials;
26
+ if (sharedErrors.region) errors.region = sharedErrors.region;
27
+ if (sharedErrors.zone) errors.zone = sharedErrors.zone;
28
+ if (sharedErrors.stack) errors.stack = sharedErrors.stack;
29
+ if (sharedErrors.freeFormDetails) errors.freeFormDetails = sharedErrors.freeFormDetails;
30
+ const stackPattern = values.viewState.templatingEnabled ? /^([a-zA-Z0-9]*(\${.+})*)*$/ : /^[a-zA-Z0-9]*$/;
31
+ const detailPattern = values.viewState.templatingEnabled ? /^([a-zA-Z0-9-]*(\${.+})*)*$/ : /^[a-zA-Z0-9-]*$/;
32
+
33
+ if (values.stack && !stackPattern.test(values.stack)) {
34
+ errors.stack = 'Stack can only contain letters and numbers.';
35
+ }
36
+ if (values.freeFormDetails && !detailPattern.test(values.freeFormDetails)) {
37
+ errors.freeFormDetails = 'Detail can only contain letters, numbers, and dashes.';
38
+ }
39
+
40
+ return errors;
41
+ }
42
+
20
43
  export class ServerGroupBasicSettings extends GceServerGroupWizardPage {
21
44
  public validate(values: IGceServerGroupCommand): IGceServerGroupCommandValidationErrors {
22
- const sharedErrors = validateGceServerGroupCommand(values);
23
- const errors: IGceServerGroupCommandValidationErrors = {};
24
- if (sharedErrors.credentials) errors.credentials = sharedErrors.credentials;
25
- if (sharedErrors.region) errors.region = sharedErrors.region;
26
- if (sharedErrors.zone) errors.zone = sharedErrors.zone;
27
- if (sharedErrors.stack) errors.stack = sharedErrors.stack;
28
- if (sharedErrors.freeFormDetails) errors.freeFormDetails = sharedErrors.freeFormDetails;
29
- const stackPattern = values.viewState.templatingEnabled ? /^([a-zA-Z0-9]*(\${.+})*)*$/ : /^[a-zA-Z0-9]*$/;
30
- const detailPattern = values.viewState.templatingEnabled ? /^([a-zA-Z0-9-]*(\${.+})*)*$/ : /^[a-zA-Z0-9-]*$/;
31
-
32
- if (values.stack && !stackPattern.test(values.stack)) {
33
- errors.stack = 'Stack can only contain letters and numbers.';
34
- }
35
- if (values.freeFormDetails && !detailPattern.test(values.freeFormDetails)) {
36
- errors.freeFormDetails = 'Detail can only contain letters, numbers, and dashes.';
37
- }
38
-
39
- return errors;
45
+ return validateGceServerGroupBasicSettings(values);
40
46
  }
41
47
 
42
48
  private parentChanged = (