@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,28 +1,34 @@
1
- import { shallow } from 'enzyme';
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
2
  import React from 'react';
3
3
 
4
- import { StageConfigField } from '@spinnaker/core';
4
+ import { AccountService } from '@spinnaker/core';
5
5
 
6
6
  import { GceFindImageStageConfig } from './googleStageConfig';
7
7
 
8
- describe('GCE find image onlyEnabled control', () => {
9
- function renderStage(stage, updateStage = jasmine.createSpy('updateStage')) {
10
- const wrapper = shallow(React.createElement(GceFindImageStageConfig, { application: {}, stage, updateStage }), {
11
- disableLifecycleMethods: true,
12
- });
13
- const field = wrapper.find(StageConfigField).filterWhere((node) => node.prop('label') === 'Server Group Filters');
8
+ vi.mock('@spinnaker/core', async (importOriginal) => {
9
+ const actual = await importOriginal();
10
+ return { ...actual, AccountRegionClusterSelector: () => null };
11
+ });
14
12
 
15
- return { checkbox: field.find('input[type="checkbox"]'), field, updateStage };
13
+ describe('GCE find image onlyEnabled control', () => {
14
+ beforeEach(() => vi.spyOn(AccountService, 'listAccounts').mockResolvedValue([]));
15
+
16
+ function renderStage(stage, updateStage = vi.fn()) {
17
+ const rendered = render(React.createElement(GceFindImageStageConfig, { application: {}, stage, updateStage }));
18
+ return {
19
+ ...rendered,
20
+ checkbox: screen.getByRole('checkbox', { name: 'Only consider enabled Server Groups' }),
21
+ updateStage,
22
+ };
16
23
  }
17
24
 
18
25
  it('defaults to considering only enabled server groups', () => {
19
26
  const stage = {};
20
- const { checkbox, field } = renderStage(stage);
27
+ const { checkbox } = renderStage(stage);
21
28
 
22
29
  expect(stage.onlyEnabled).toBe(true);
23
- expect(field.exists()).toBe(true);
24
- expect(field.find('label').text().trim()).toBe('Only consider enabled Server Groups');
25
- expect(checkbox.prop('checked')).toBe(true);
30
+ expect(screen.getByText('Server Group Filters')).toBeInTheDocument();
31
+ expect(checkbox).toBeChecked();
26
32
  });
27
33
 
28
34
  it('preserves an explicit false value', () => {
@@ -30,15 +36,15 @@ describe('GCE find image onlyEnabled control', () => {
30
36
  const { checkbox } = renderStage(stage);
31
37
 
32
38
  expect(stage.onlyEnabled).toBe(false);
33
- expect(checkbox.prop('checked')).toBe(false);
39
+ expect(checkbox).not.toBeChecked();
34
40
  });
35
41
 
36
42
  it('updates onlyEnabled with the checkbox value', () => {
37
43
  const stage = { onlyEnabled: true };
38
44
  const { checkbox, updateStage } = renderStage(stage);
39
45
 
40
- checkbox.simulate('change', { target: { checked: false } });
46
+ fireEvent.click(checkbox);
41
47
 
42
- expect(updateStage).toHaveBeenCalledWith(jasmine.objectContaining({ onlyEnabled: false }));
48
+ expect(updateStage).toHaveBeenCalledWith(expect.objectContaining({ onlyEnabled: false }));
43
49
  });
44
50
  });
@@ -1,25 +1,36 @@
1
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { UIRouterContext, UIRouterReact } from '@uirouter/react';
1
3
  import React from 'react';
2
- import { mount as enzymeMount } from 'enzyme';
3
4
 
4
- import { DeckRuntimeContext, SecurityGroupWriter, SubmitButton } from '@spinnaker/core';
5
+ import { DeckRuntimeContext, SecurityGroupWriter, TaskReader } from '@spinnaker/core';
5
6
 
6
- import { GceSecurityGroupModalComponent as GceSecurityGroupModal } from './GceSecurityGroupModal';
7
+ import {
8
+ GceSecurityGroupModalComponent as GceSecurityGroupModal,
9
+ initializeGceSecurityGroupForModal,
10
+ } from './GceSecurityGroupModal';
7
11
 
8
12
  describe('GceSecurityGroupModal', () => {
9
13
  let runtimeServices: any;
10
- const RuntimeWrapper = ({ children }: React.PropsWithChildren<{}>) => (
11
- <DeckRuntimeContext.Provider value={{ services: runtimeServices } as any}>{children}</DeckRuntimeContext.Provider>
12
- );
13
- const mount = (component: React.ReactElement) => enzymeMount(component, { wrappingComponent: RuntimeWrapper });
14
+ const application = {
15
+ getDataSource: vi.fn(),
16
+ name: 'my-app',
17
+ securityGroups: { refresh: vi.fn() },
18
+ };
14
19
 
15
20
  beforeEach(() => {
16
21
  runtimeServices = { securityGroupReader: { getAllSecurityGroups: () => Promise.resolve({}) } };
17
22
  });
18
23
 
19
- const application = {
20
- name: 'my-app',
21
- securityGroups: { refresh: jasmine.createSpy('refresh') },
22
- };
24
+ function renderModal(component: React.ReactElement) {
25
+ const router = new UIRouterReact();
26
+ return render(
27
+ <UIRouterContext.Provider value={router}>
28
+ <DeckRuntimeContext.Provider value={{ services: runtimeServices } as any}>
29
+ {component}
30
+ </DeckRuntimeContext.Provider>
31
+ </UIRouterContext.Provider>,
32
+ );
33
+ }
23
34
 
24
35
  function validSecurityGroup(overrides: any = {}): any {
25
36
  return {
@@ -51,43 +62,43 @@ describe('GceSecurityGroupModal', () => {
51
62
  };
52
63
  }
53
64
 
54
- async function flush(): Promise<void> {
55
- await Promise.resolve();
56
- await Promise.resolve();
65
+ function fillValidSecurityGroup(name = 'new-firewall'): void {
66
+ fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), { target: { value: name } });
67
+ fireEvent.change(screen.getByRole('textbox', { name: 'Source CIDRs' }), {
68
+ target: { value: '10.0.0.0/8' },
69
+ });
70
+ fireEvent.change(screen.getByRole('spinbutton', { name: 'Start port 1' }), { target: { value: '443' } });
71
+ fireEvent.change(screen.getByRole('spinbutton', { name: 'End port 1' }), { target: { value: '443' } });
57
72
  }
58
73
 
59
74
  it('keeps create submit disabled until the global firewall inventory has loaded', async () => {
60
75
  let finishLoading: ((securityGroups: any) => void) | undefined;
61
- const getAllSecurityGroups = jasmine.createSpy('getAllSecurityGroups').and.returnValue(
76
+ const getAllSecurityGroups = vi.fn().mockReturnValue(
62
77
  new Promise((resolve) => {
63
78
  finishLoading = resolve;
64
79
  }),
65
80
  );
66
81
  runtimeServices.securityGroupReader = { getAllSecurityGroups };
67
- const wrapper = mount(
82
+ renderModal(
68
83
  <GceSecurityGroupModal
69
84
  application={{ ...application, securityGroups: { data: [] } } as any}
70
85
  credentials="my-account"
71
86
  />,
72
87
  );
73
- wrapper.setState({ securityGroup: validSecurityGroup({ name: 'new-firewall' }) } as any);
88
+ fillValidSecurityGroup();
74
89
 
75
- expect(wrapper.find(SubmitButton).prop('isDisabled')).toBe(true);
90
+ expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled();
76
91
 
77
- finishLoading?.(globalSecurityGroups());
78
- await flush();
79
- wrapper.update();
92
+ await act(async () => finishLoading?.(globalSecurityGroups()));
80
93
 
81
- expect(wrapper.find(SubmitButton).prop('isDisabled')).toBe(false);
94
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled());
82
95
  });
83
96
 
84
97
  (['create', 'clone'] as const).forEach((mode) => {
85
98
  it(`rejects a ${mode} name used in the same account on another network outside the application`, async () => {
86
- const getAllSecurityGroups = jasmine
87
- .createSpy('getAllSecurityGroups')
88
- .and.returnValue(Promise.resolve(globalSecurityGroups()));
99
+ const getAllSecurityGroups = vi.fn().mockResolvedValue(globalSecurityGroups());
89
100
  runtimeServices.securityGroupReader = { getAllSecurityGroups };
90
- const wrapper = mount(
101
+ renderModal(
91
102
  <GceSecurityGroupModal
92
103
  application={{ ...application, securityGroups: { data: [] } } as any}
93
104
  credentials="my-account"
@@ -95,87 +106,81 @@ describe('GceSecurityGroupModal', () => {
95
106
  securityGroup={mode === 'clone' ? validSecurityGroup({ name: 'source-firewall' }) : undefined}
96
107
  />,
97
108
  );
98
- wrapper.setState({ securityGroup: validSecurityGroup() } as any);
109
+ fillValidSecurityGroup('existing-firewall');
99
110
 
100
- await flush();
101
- wrapper.update();
111
+ await waitFor(() => expect(getAllSecurityGroups).toHaveBeenCalled());
102
112
 
103
- expect(getAllSecurityGroups).toHaveBeenCalled();
104
- expect(wrapper.find(SubmitButton).prop('isDisabled')).toBe(true);
113
+ expect(screen.getByRole('button', { name: mode === 'clone' ? 'Clone' : 'Create' })).toBeDisabled();
105
114
  });
106
115
  });
107
116
 
108
117
  it('permits a name used only in another account and permits editing the current firewall identity', async () => {
109
- const getAllSecurityGroups = jasmine
110
- .createSpy('getAllSecurityGroups')
111
- .and.returnValue(Promise.resolve(globalSecurityGroups()));
118
+ const getAllSecurityGroups = vi.fn().mockResolvedValue(globalSecurityGroups());
112
119
  runtimeServices.securityGroupReader = { getAllSecurityGroups };
113
- const createWrapper = mount(<GceSecurityGroupModal application={application as any} credentials="my-account" />);
114
- createWrapper.setState({ securityGroup: validSecurityGroup({ name: 'cross-account-firewall' }) } as any);
115
- const editWrapper = mount(
120
+ const create = renderModal(<GceSecurityGroupModal application={application as any} credentials="my-account" />);
121
+ fillValidSecurityGroup('cross-account-firewall');
122
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled());
123
+ create.unmount();
124
+
125
+ renderModal(
116
126
  <GceSecurityGroupModal application={application as any} mode="edit" securityGroup={validSecurityGroup()} />,
117
127
  );
118
-
119
- await flush();
120
- createWrapper.update();
121
- editWrapper.update();
122
-
123
- expect(createWrapper.find(SubmitButton).prop('isDisabled')).toBe(false);
124
- expect(editWrapper.find(SubmitButton).prop('isDisabled')).toBe(false);
128
+ expect(screen.getByRole('button', { name: 'Update' })).toBeEnabled();
125
129
  });
126
130
 
127
131
  it('keeps create submit disabled when the global firewall inventory cannot be loaded', async () => {
128
- const getAllSecurityGroups = jasmine
129
- .createSpy('getAllSecurityGroups')
130
- .and.returnValue(Promise.reject(new Error('inventory unavailable')));
132
+ const getAllSecurityGroups = vi.fn().mockRejectedValue(new Error('inventory unavailable'));
131
133
  runtimeServices.securityGroupReader = { getAllSecurityGroups };
132
- const wrapper = mount(<GceSecurityGroupModal application={application as any} credentials="my-account" />);
133
- wrapper.setState({ securityGroup: validSecurityGroup({ name: 'new-firewall' }) } as any);
134
+ renderModal(<GceSecurityGroupModal application={application as any} credentials="my-account" />);
135
+ fillValidSecurityGroup();
134
136
 
135
- await flush();
136
- wrapper.update();
137
-
138
- expect(wrapper.find(SubmitButton).prop('isDisabled')).toBe(true);
139
- expect(wrapper.find('.security-group-validation-error').text()).toContain('Unable to validate firewall name');
137
+ expect(await screen.findByText('Unable to validate firewall name.')).toBeInTheDocument();
138
+ expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled();
140
139
  });
141
140
 
142
141
  (['create', 'clone'] as const).forEach((mode) => {
143
- it(`waits for refreshed security groups before closing and navigating after ${mode}`, () => {
142
+ it(`waits for refreshed security groups before closing and navigating after ${mode}`, async () => {
144
143
  let finishRefresh: (() => void) | undefined;
145
- const refresh = jasmine.createSpy('refresh');
146
- const onNextRefresh = jasmine.createSpy('onNextRefresh').and.callFake((callback: () => void) => {
144
+ const refresh = vi.fn();
145
+ const onNextRefresh = vi.fn().mockImplementation((callback: () => void) => {
147
146
  finishRefresh = callback;
148
147
  return () => undefined;
149
148
  });
150
- const closeModal = jasmine.createSpy('closeModal');
151
- const state = {
152
- go: jasmine.createSpy('go'),
153
- includes: jasmine.createSpy('includes').and.returnValue(mode === 'clone'),
149
+ const closeModal = vi.fn();
150
+ const stateService = {
151
+ go: vi.fn(),
152
+ includes: vi.fn().mockReturnValue(mode === 'clone'),
154
153
  };
155
- const modal = new GceSecurityGroupModal({
156
- application: { name: 'my-app', securityGroups: { onNextRefresh, refresh } },
157
- closeModal,
158
- credentials: 'my-account',
159
- mode,
160
- router: {},
161
- securityGroup: mode === 'clone' ? validSecurityGroup({ name: 'source-firewall' }) : undefined,
162
- stateParams: {},
163
- stateService: state,
164
- } as any);
165
- (modal as any).mounted = true;
166
- (modal.state as any).securityGroup = validSecurityGroup({ name: ' new-firewall ', network: ' default ' });
167
-
168
- (modal as any).onTaskComplete();
169
-
170
- expect(onNextRefresh).toHaveBeenCalled();
171
- expect(onNextRefresh.calls.first().invocationOrder).toBeLessThan(refresh.calls.first().invocationOrder);
154
+ const task = { id: 'task' } as any;
155
+ vi.spyOn(SecurityGroupWriter, 'upsertSecurityGroup').mockResolvedValue(task);
156
+ vi.spyOn(TaskReader, 'waitUntilTaskCompletes').mockResolvedValue(task);
157
+ renderModal(
158
+ <GceSecurityGroupModal
159
+ application={{ getDataSource: vi.fn(), name: 'my-app', securityGroups: { onNextRefresh, refresh } }}
160
+ closeModal={closeModal}
161
+ credentials="my-account"
162
+ mode={mode}
163
+ securityGroup={mode === 'clone' ? validSecurityGroup({ name: 'source-firewall' }) : undefined}
164
+ stateService={stateService as any}
165
+ />,
166
+ );
167
+ fillValidSecurityGroup(' new-firewall ');
168
+ fireEvent.change(screen.getByRole('textbox', { name: 'Network' }), { target: { value: ' default ' } });
169
+
170
+ await waitFor(() =>
171
+ expect(screen.getByRole('button', { name: mode === 'clone' ? 'Clone' : 'Create' })).toBeEnabled(),
172
+ );
173
+ fireEvent.click(screen.getByRole('button', { name: mode === 'clone' ? 'Clone' : 'Create' }));
174
+ await waitFor(() => expect(refresh).toHaveBeenCalled());
175
+
176
+ expect(onNextRefresh.mock.invocationCallOrder[0]).toBeLessThan(refresh.mock.invocationCallOrder[0]);
172
177
  expect(closeModal).not.toHaveBeenCalled();
173
- expect(state.go).not.toHaveBeenCalled();
178
+ expect(stateService.go).not.toHaveBeenCalled();
174
179
 
175
180
  finishRefresh?.();
176
181
 
177
182
  expect(closeModal).toHaveBeenCalled();
178
- expect(state.go).toHaveBeenCalledWith(mode === 'clone' ? '^.firewallDetails' : '.firewallDetails', {
183
+ expect(stateService.go).toHaveBeenCalledWith(mode === 'clone' ? '^.firewallDetails' : '.firewallDetails', {
179
184
  accountId: 'my-account',
180
185
  name: 'new-firewall',
181
186
  provider: 'gce',
@@ -185,65 +190,65 @@ describe('GceSecurityGroupModal', () => {
185
190
  });
186
191
  });
187
192
 
188
- it('owns its refresh subscription across replacement and unmount', () => {
189
- const firstUnsubscribe = jasmine.createSpy('firstUnsubscribe');
190
- const secondUnsubscribe = jasmine.createSpy('secondUnsubscribe');
193
+ it('owns its refresh subscription through unmount', async () => {
194
+ const unsubscribe = vi.fn();
191
195
  const callbacks: Array<() => void> = [];
192
- const onNextRefresh = jasmine.createSpy('onNextRefresh').and.callFake((callback: () => void) => {
196
+ const onNextRefresh = vi.fn().mockImplementation((callback: () => void) => {
193
197
  callbacks.push(callback);
194
- return callbacks.length === 1 ? firstUnsubscribe : secondUnsubscribe;
198
+ return unsubscribe;
195
199
  });
196
- const refresh = jasmine.createSpy('refresh');
197
- const closeModal = jasmine.createSpy('closeModal');
198
- const stateService = { go: jasmine.createSpy('go'), includes: jasmine.createSpy('includes') };
199
- const modal = new GceSecurityGroupModal({
200
- application: { name: 'my-app', securityGroups: { onNextRefresh, refresh } },
201
- closeModal,
202
- credentials: 'my-account',
203
- stateService,
204
- } as any) as any;
205
- modal.mounted = true;
206
-
207
- modal.onTaskComplete();
208
-
209
- expect(onNextRefresh.calls.first().invocationOrder).toBeLessThan(refresh.calls.first().invocationOrder);
210
-
211
- modal.onTaskComplete();
200
+ const refresh = vi.fn();
201
+ const closeModal = vi.fn();
202
+ const stateService = { go: vi.fn(), includes: vi.fn() };
203
+ const task = { id: 'task' } as any;
204
+ vi.spyOn(SecurityGroupWriter, 'upsertSecurityGroup').mockResolvedValue(task);
205
+ vi.spyOn(TaskReader, 'waitUntilTaskCompletes').mockResolvedValue(task);
206
+ const rendered = renderModal(
207
+ <GceSecurityGroupModal
208
+ application={{ getDataSource: vi.fn(), name: 'my-app', securityGroups: { onNextRefresh, refresh } }}
209
+ closeModal={closeModal}
210
+ credentials="my-account"
211
+ stateService={stateService as any}
212
+ />,
213
+ );
214
+ fillValidSecurityGroup();
212
215
 
213
- expect(firstUnsubscribe).toHaveBeenCalledTimes(1);
216
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled());
217
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }));
218
+ await waitFor(() => expect(refresh).toHaveBeenCalled());
219
+ expect(onNextRefresh.mock.invocationCallOrder[0]).toBeLessThan(refresh.mock.invocationCallOrder[0]);
214
220
 
215
- modal.componentWillUnmount();
216
- callbacks[1]();
221
+ rendered.unmount();
222
+ callbacks[0]();
217
223
 
218
- expect(secondUnsubscribe).toHaveBeenCalledTimes(1);
219
- expect(modal.applicationRefreshUnsubscribe).toBeUndefined();
224
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
220
225
  expect(closeModal).not.toHaveBeenCalled();
221
226
  expect(stateService.go).not.toHaveBeenCalled();
222
227
  });
223
228
 
224
- it('submits multiple protocol and port-range rows using the GCE firewall operation contract', () => {
225
- const upsert = spyOn(SecurityGroupWriter, 'upsertSecurityGroup').and.returnValue(new Promise(() => {}));
226
- const modal = new GceSecurityGroupModal({ application: application as any, credentials: 'my-account' } as any);
227
- (modal.state as any).securityGroup = {
228
- ...(modal.state as any).securityGroup,
229
- name: 'my-app-firewall',
230
- network: 'default',
231
- sourceRanges: ['10.0.0.0/8'],
232
- ipIngress: [
233
- { type: 'tcp', startPort: 443, endPort: 443 },
234
- { type: 'udp', startPort: 7001, endPort: 7002 },
235
- { type: 'icmp' },
236
- ],
237
- };
238
-
239
- (modal as any).submit();
240
-
241
- expect(upsert.calls.mostRecent().args[0].allowed).toEqual([
229
+ it('submits multiple protocol and port-range rows using the GCE firewall operation contract', async () => {
230
+ runtimeServices.securityGroupReader = { getAllSecurityGroups: vi.fn().mockResolvedValue({}) };
231
+ const upsert = vi.spyOn(SecurityGroupWriter, 'upsertSecurityGroup').mockReturnValue(new Promise(() => undefined));
232
+ renderModal(<GceSecurityGroupModal application={application as any} credentials="my-account" />);
233
+ fillValidSecurityGroup('my-app-firewall');
234
+ fireEvent.click(screen.getByRole('button', { name: /Add New Protocol and Port Range/ }));
235
+ fireEvent.change(screen.getByRole('combobox', { name: 'Protocol 2' }), { target: { value: 'udp' } });
236
+ fireEvent.change(screen.getByRole('spinbutton', { name: 'Start port 2' }), { target: { value: '7001' } });
237
+ fireEvent.change(screen.getByRole('spinbutton', { name: 'End port 2' }), { target: { value: '7002' } });
238
+ fireEvent.click(screen.getByRole('button', { name: /Add New Protocol and Port Range/ }));
239
+ fireEvent.change(screen.getByRole('combobox', { name: 'Protocol 3' }), { target: { value: 'icmp' } });
240
+ fireEvent.change(screen.getByRole('spinbutton', { name: 'Start port 3' }), { target: { value: '' } });
241
+ fireEvent.change(screen.getByRole('spinbutton', { name: 'End port 3' }), { target: { value: '' } });
242
+
243
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled());
244
+ fireEvent.click(screen.getByRole('button', { name: 'Create' }));
245
+
246
+ expect(upsert.mock.lastCall[0].allowed).toEqual([
242
247
  { ipProtocol: 'tcp', portRanges: ['443-443'] },
243
248
  { ipProtocol: 'udp', portRanges: ['7001-7002'] },
244
249
  { ipProtocol: 'icmp' },
245
250
  ]);
246
- expect(upsert.calls.mostRecent().args[2]).toBe('Create');
251
+ expect(upsert.mock.lastCall[2]).toBe('Create');
247
252
  });
248
253
 
249
254
  it('loads and normalizes all inbound rules for edit while retaining firewall identity', () => {
@@ -269,15 +274,8 @@ describe('GceSecurityGroupModal', () => {
269
274
  sourceTags: '[backend, jobs]',
270
275
  targetTags: '[web, api]',
271
276
  };
272
- const upsert = spyOn(SecurityGroupWriter, 'upsertSecurityGroup').and.returnValue(new Promise(() => {}));
273
- const modal = new GceSecurityGroupModal({
274
- application: application as any,
275
- mode: 'edit',
276
- securityGroup: source,
277
- } as any);
278
-
279
- expect((modal.state as any).securityGroup).toEqual(
280
- jasmine.objectContaining({
277
+ expect(initializeGceSecurityGroupForModal({ mode: 'edit', securityGroup: source }, application.name)).toEqual(
278
+ expect.objectContaining({
281
279
  accountId: 'my-account',
282
280
  credentials: 'my-account',
283
281
  id: 'my-firewall',
@@ -292,90 +290,76 @@ describe('GceSecurityGroupModal', () => {
292
290
  targetTags: ['web', 'api'],
293
291
  }),
294
292
  );
293
+ const upsert = vi.spyOn(SecurityGroupWriter, 'upsertSecurityGroup').mockReturnValue(new Promise(() => undefined));
294
+ renderModal(<GceSecurityGroupModal application={application as any} mode="edit" securityGroup={source} />);
295
295
 
296
- (modal as any).submit();
296
+ fireEvent.click(screen.getByRole('button', { name: 'Update' }));
297
297
 
298
- expect(upsert.calls.mostRecent().args[0]).toEqual(
299
- jasmine.objectContaining({
300
- accountId: 'my-account',
301
- id: 'my-firewall',
302
- name: 'my-firewall',
303
- }),
298
+ expect(upsert.mock.lastCall[0]).toEqual(
299
+ expect.objectContaining({ accountId: 'my-account', id: 'my-firewall', name: 'my-firewall' }),
304
300
  );
305
- expect(upsert.calls.mostRecent().args[0].allowed).toEqual([
301
+ expect(upsert.mock.lastCall[0].allowed).toEqual([
306
302
  { ipProtocol: 'tcp', portRanges: ['80-80'] },
307
303
  { ipProtocol: 'tcp', portRanges: ['443-444'] },
308
304
  { ipProtocol: 'icmp' },
309
305
  ]);
310
- expect(upsert.calls.mostRecent().args[2]).toBe('Update');
306
+ expect(upsert.mock.lastCall[2]).toBe('Update');
311
307
  });
312
308
 
313
309
  it('preserves fetched firewall CIDRs from ipRangeRules without duplicating sourceRanges', () => {
314
- const upsert = spyOn(SecurityGroupWriter, 'upsertSecurityGroup').and.returnValue(new Promise(() => {}));
315
- const modal = new GceSecurityGroupModal({
316
- application: application as any,
317
- mode: 'edit',
318
- securityGroup: {
319
- accountName: 'my-account',
320
- id: 'fetched-firewall',
321
- ipRangeRules: [
322
- {
323
- portRanges: [{ startPort: 443, endPort: 443 }],
324
- protocol: 'tcp',
325
- range: { ip: '10.0.0.0', cidr: '/8' },
326
- },
327
- {
328
- portRanges: [{ startPort: 53, endPort: 53 }],
329
- protocol: 'udp',
330
- range: { ip: '192.168.0.0', cidr: '/24' },
331
- },
332
- ],
333
- name: 'fetched-firewall',
334
- network: 'default',
335
- sourceRanges: ['10.0.0.0/8'],
336
- },
337
- } as any);
338
-
339
- expect((modal.state as any).securityGroup.sourceRanges).toEqual(['10.0.0.0/8', '192.168.0.0/24']);
310
+ const source = {
311
+ accountName: 'my-account',
312
+ id: 'fetched-firewall',
313
+ ipRangeRules: [
314
+ { portRanges: [{ startPort: 443, endPort: 443 }], protocol: 'tcp', range: { ip: '10.0.0.0', cidr: '/8' } },
315
+ { portRanges: [{ startPort: 53, endPort: 53 }], protocol: 'udp', range: { ip: '192.168.0.0', cidr: '/24' } },
316
+ ],
317
+ name: 'fetched-firewall',
318
+ network: 'default',
319
+ sourceRanges: ['10.0.0.0/8'],
320
+ };
321
+ expect(
322
+ initializeGceSecurityGroupForModal({ mode: 'edit', securityGroup: source }, application.name).sourceRanges,
323
+ ).toEqual(['10.0.0.0/8', '192.168.0.0/24']);
324
+ const upsert = vi.spyOn(SecurityGroupWriter, 'upsertSecurityGroup').mockReturnValue(new Promise(() => undefined));
325
+ renderModal(<GceSecurityGroupModal application={application as any} mode="edit" securityGroup={source} />);
340
326
 
341
- (modal as any).submit();
327
+ fireEvent.click(screen.getByRole('button', { name: 'Update' }));
342
328
 
343
- expect(upsert.calls.mostRecent().args[0].sourceRanges).toEqual(['10.0.0.0/8', '192.168.0.0/24']);
329
+ expect(upsert.mock.lastCall[0].sourceRanges).toEqual(['10.0.0.0/8', '192.168.0.0/24']);
344
330
  });
345
331
 
346
- it('clears cloned firewall identity and name but preserves its editable rules', () => {
347
- const upsert = spyOn(SecurityGroupWriter, 'upsertSecurityGroup').and.returnValue(new Promise(() => {}));
348
- const modal = new GceSecurityGroupModal({
349
- application: application as any,
350
- mode: 'clone',
351
- securityGroup: {
352
- accountId: 'my-account',
353
- id: 'source-firewall',
354
- ipIngressRules: [{ protocol: 'sctp', portRanges: [{ startPort: 5000, endPort: 5001 }] }],
355
- name: 'source-firewall',
356
- network: 'default',
357
- sourceRanges: ['10.0.0.0/8'],
358
- },
359
- } as any);
360
-
361
- expect((modal.state as any).securityGroup).toEqual(
362
- jasmine.objectContaining({
332
+ it('clears cloned firewall identity and name but preserves its editable rules', async () => {
333
+ const source = {
334
+ accountId: 'my-account',
335
+ id: 'source-firewall',
336
+ ipIngressRules: [{ protocol: 'sctp', portRanges: [{ startPort: 5000, endPort: 5001 }] }],
337
+ name: 'source-firewall',
338
+ network: 'default',
339
+ sourceRanges: ['10.0.0.0/8'],
340
+ };
341
+ expect(initializeGceSecurityGroupForModal({ mode: 'clone', securityGroup: source }, application.name)).toEqual(
342
+ expect.objectContaining({
363
343
  id: undefined,
364
344
  ipIngress: [{ type: 'sctp', startPort: 5000, endPort: 5001 }],
365
345
  name: '',
366
346
  }),
367
347
  );
348
+ runtimeServices.securityGroupReader = { getAllSecurityGroups: vi.fn().mockResolvedValue({}) };
349
+ const upsert = vi.spyOn(SecurityGroupWriter, 'upsertSecurityGroup').mockReturnValue(new Promise(() => undefined));
350
+ renderModal(<GceSecurityGroupModal application={application as any} mode="clone" securityGroup={source} />);
351
+ fireEvent.change(screen.getByRole('textbox', { name: 'Name' }), { target: { value: 'cloned-firewall' } });
368
352
 
369
- (modal.state as any).securityGroup.name = 'cloned-firewall';
370
- (modal as any).submit();
353
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Clone' })).toBeEnabled());
354
+ fireEvent.click(screen.getByRole('button', { name: 'Clone' }));
371
355
 
372
- expect(upsert.calls.mostRecent().args[0]).toEqual(
373
- jasmine.objectContaining({
356
+ expect(upsert.mock.lastCall[0]).toEqual(
357
+ expect.objectContaining({
374
358
  allowed: [{ ipProtocol: 'sctp', portRanges: ['5000-5001'] }],
375
359
  id: undefined,
376
360
  name: 'cloned-firewall',
377
361
  }),
378
362
  );
379
- expect(upsert.calls.mostRecent().args[2]).toBe('Clone');
363
+ expect(upsert.mock.lastCall[2]).toBe('Clone');
380
364
  });
381
365
  });
@@ -377,12 +377,16 @@ export class GceSecurityGroupModalComponent extends React.Component<
377
377
  onChange: (value: string) => void,
378
378
  readOnly = false,
379
379
  ): JSX.Element {
380
+ const id = `gce-firewall-${label.toLowerCase().replace(/\s+/g, '-')}`;
380
381
  return (
381
382
  <div className="form-group">
382
- <label className="col-md-3 control-label">{label}</label>
383
+ <label className="col-md-3 control-label" htmlFor={id}>
384
+ {label}
385
+ </label>
383
386
  <div className="col-md-7">
384
387
  <input
385
388
  className="form-control input-sm"
389
+ id={id}
386
390
  onChange={(event) => onChange(event.target.value)}
387
391
  readOnly={readOnly}
388
392
  value={value || ''}
@@ -410,6 +414,7 @@ export class GceSecurityGroupModalComponent extends React.Component<
410
414
  <tr key={index}>
411
415
  <td>
412
416
  <select
417
+ aria-label={`Protocol ${index + 1}`}
413
418
  className="form-control input-sm"
414
419
  onChange={(event) => this.updateRule(index, { type: event.target.value })}
415
420
  value={rule.type}
@@ -423,6 +428,7 @@ export class GceSecurityGroupModalComponent extends React.Component<
423
428
  </td>
424
429
  <td>
425
430
  <input
431
+ aria-label={`Start port ${index + 1}`}
426
432
  className="form-control input-sm"
427
433
  min="0"
428
434
  onChange={(event) =>
@@ -436,6 +442,7 @@ export class GceSecurityGroupModalComponent extends React.Component<
436
442
  </td>
437
443
  <td>
438
444
  <input
445
+ aria-label={`End port ${index + 1}`}
439
446
  className="form-control input-sm"
440
447
  min="0"
441
448
  onChange={(event) =>
@@ -448,7 +455,12 @@ export class GceSecurityGroupModalComponent extends React.Component<
448
455
  />
449
456
  </td>
450
457
  <td>
451
- <button className="btn btn-link" onClick={() => this.removeRule(index)} type="button">
458
+ <button
459
+ aria-label={`Remove rule ${index + 1}`}
460
+ className="btn btn-link"
461
+ onClick={() => this.removeRule(index)}
462
+ type="button"
463
+ >
452
464
  <span className="glyphicon glyphicon-trash" />
453
465
  </button>
454
466
  </td>