@skyhook-io/k8s-ui 1.18.2 → 1.20.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 (152) hide show
  1. package/package.json +9 -2
  2. package/src/components/applications/ApplicationDetail.tsx +8 -2
  3. package/src/components/charts/AreaChart.test.tsx +39 -1
  4. package/src/components/charts/AreaChart.tsx +32 -13
  5. package/src/components/charts/axis.test.ts +17 -1
  6. package/src/components/charts/axis.ts +13 -0
  7. package/src/components/charts/format.test.ts +159 -2
  8. package/src/components/charts/format.ts +118 -0
  9. package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
  10. package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
  11. package/src/components/compare/ResourceCompareView.test.tsx +54 -0
  12. package/src/components/compare/ResourceCompareView.tsx +35 -12
  13. package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
  14. package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
  15. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
  16. package/src/components/gitops/GitOpsTableView.tsx +26 -7
  17. package/src/components/gitops/RollbackDialog.test.tsx +80 -0
  18. package/src/components/gitops/RollbackDialog.tsx +25 -6
  19. package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
  20. package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
  21. package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
  22. package/src/components/gitops/applicationset-row.test.ts +12 -0
  23. package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
  24. package/src/components/gitops/detail-helpers.test.ts +8 -2
  25. package/src/components/gitops/detail-helpers.ts +9 -11
  26. package/src/components/gitops/index.ts +1 -1
  27. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
  28. package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
  29. package/src/components/gitops/insights/GitOpsInsightViews.tsx +94 -46
  30. package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
  31. package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
  32. package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
  33. package/src/components/gitops/insights/insights-helpers.test.ts +65 -0
  34. package/src/components/gitops/insights/insights-helpers.ts +17 -3
  35. package/src/components/issues/IssueGuidance.test.tsx +190 -0
  36. package/src/components/issues/IssueGuidance.tsx +48 -0
  37. package/src/components/issues/IssuesView.tsx +15 -3
  38. package/src/components/issues/ResourceIssuesSection.tsx +3 -1
  39. package/src/components/issues/diagnostic.ts +14 -1
  40. package/src/components/issues/index.ts +1 -0
  41. package/src/components/issues/severity.ts +15 -0
  42. package/src/components/issues/types.ts +10 -0
  43. package/src/components/kueue/AdmissionPath.tsx +223 -0
  44. package/src/components/kueue/QueueEvidence.tsx +148 -0
  45. package/src/components/kueue/constants.ts +1 -0
  46. package/src/components/kueue/eligibility.ts +43 -0
  47. package/src/components/kueue/flavors-tried.ts +79 -0
  48. package/src/components/kueue/helpers.test.ts +105 -0
  49. package/src/components/kueue/policy.ts +100 -0
  50. package/src/components/kueue/queue-detail.test.tsx +317 -0
  51. package/src/components/kueue/queue-status.ts +42 -0
  52. package/src/components/kueue/related-read.ts +48 -0
  53. package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
  54. package/src/components/logs/StructuredLogLine.test.tsx +22 -0
  55. package/src/components/logs/StructuredLogLine.tsx +8 -3
  56. package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
  57. package/src/components/resources/KueueAdmissionSection.tsx +51 -12
  58. package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
  59. package/src/components/resources/KueuePendingPosition.tsx +91 -0
  60. package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
  61. package/src/components/resources/ResourcesSidebar.tsx +3 -2
  62. package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
  63. package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
  64. package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
  65. package/src/components/resources/ResourcesView.tsx +50 -3
  66. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  67. package/src/components/resources/generic-status.ts +7 -0
  68. package/src/components/resources/get-pod-phase-display.test.ts +70 -0
  69. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
  70. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
  71. package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
  72. package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
  73. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
  74. package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
  75. package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
  76. package/src/components/resources/renderers/JobRenderer.tsx +7 -2
  77. package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
  78. package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
  79. package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
  80. package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
  81. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
  82. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
  83. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
  84. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
  85. package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
  86. package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
  87. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
  88. package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
  89. package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
  90. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
  91. package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
  92. package/src/components/resources/renderers/crd-cells.tsx +55 -0
  93. package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
  94. package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
  95. package/src/components/resources/renderers/index.ts +6 -0
  96. package/src/components/resources/renderers/kueue-cells.tsx +5 -0
  97. package/src/components/resources/resource-utils-argo.test.ts +94 -1
  98. package/src/components/resources/resource-utils-argo.ts +84 -5
  99. package/src/components/resources/resource-utils-crd.test.ts +32 -0
  100. package/src/components/resources/resource-utils-crd.ts +26 -0
  101. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
  102. package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
  103. package/src/components/resources/resource-utils-kueue.test.ts +15 -0
  104. package/src/components/resources/resource-utils-kueue.ts +137 -4
  105. package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
  106. package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
  107. package/src/components/resources/resource-utils-ray.test.ts +42 -1
  108. package/src/components/resources/resource-utils-ray.ts +38 -0
  109. package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
  110. package/src/components/resources/resource-utils-strimzi.ts +35 -0
  111. package/src/components/resources/resource-utils.ts +45 -1
  112. package/src/components/shared/ResourceActionsBar.tsx +83 -25
  113. package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
  114. package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
  115. package/src/components/topology/K8sResourceNode.test.ts +14 -0
  116. package/src/components/topology/K8sResourceNode.tsx +18 -1
  117. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  118. package/src/components/topology/TopologyGraph.tsx +96 -94
  119. package/src/components/topology/layout.ts +3 -1
  120. package/src/components/topology/pod-group-expansion.test.ts +38 -0
  121. package/src/components/ui/Badge.tsx +1 -0
  122. package/src/components/ui/RadarUpgradeNote.tsx +8 -0
  123. package/src/components/ui/drawer-components.tsx +1 -1
  124. package/src/components/ui/index.ts +1 -0
  125. package/src/components/workload/PodList.tsx +35 -9
  126. package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
  127. package/src/components/workload/WorkloadView.tsx +61 -8
  128. package/src/components/workload/workload-logs-availability.test.ts +7 -0
  129. package/src/types/core.ts +41 -0
  130. package/src/types/gitops-insights.ts +19 -2
  131. package/src/types/gitops-tree.ts +2 -2
  132. package/src/types/kueue-queues.ts +84 -0
  133. package/src/types/read-coverage.ts +2 -1
  134. package/src/types/scheduling.ts +72 -0
  135. package/src/utils/animation.test.ts +38 -0
  136. package/src/utils/animation.ts +34 -0
  137. package/src/utils/api-resources-crd.test.ts +11 -0
  138. package/src/utils/api-resources.ts +5 -1
  139. package/src/utils/applications.test.ts +15 -1
  140. package/src/utils/applications.ts +26 -1
  141. package/src/utils/crd-schema.test.ts +107 -0
  142. package/src/utils/crd-schema.ts +201 -0
  143. package/src/utils/format.test.ts +37 -1
  144. package/src/utils/format.ts +28 -80
  145. package/src/utils/gitops-owner.test.ts +28 -2
  146. package/src/utils/gitops-owner.ts +19 -0
  147. package/src/utils/kueue-queue-workloads.ts +19 -0
  148. package/src/utils/resource-icons.ts +2 -0
  149. package/src/utils/topology-neighborhood.test.ts +90 -1
  150. package/src/utils/topology-neighborhood.ts +55 -3
  151. package/src/utils/workload-history-notes.test.ts +76 -0
  152. package/src/utils/workload-history-notes.ts +93 -0
@@ -0,0 +1,374 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import { SyncOptionsDialog, type ArgoSyncOpts } from './SyncOptionsDialog'
6
+
7
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
8
+
9
+ const APP_OPTIONS = ['CreateNamespace=true', 'ServerSideApply=true']
10
+ const STALE_OPTIONS_NOTICE = "The Application's sync options changed while this dialog was open. This sync uses the options shown here. Cancel and reopen to load the latest."
11
+ let host: HTMLDivElement
12
+ let root: ReturnType<typeof createRoot>
13
+ let confirmed: ArgoSyncOpts[]
14
+ let validated: ArgoSyncOpts[]
15
+
16
+ beforeEach(() => {
17
+ confirmed = []
18
+ validated = []
19
+ host = document.createElement('div')
20
+ document.body.append(host)
21
+ root = createRoot(host)
22
+ })
23
+
24
+ afterEach(async () => {
25
+ await act(async () => root.unmount())
26
+ host.remove()
27
+ document.body.innerHTML = ''
28
+ })
29
+
30
+ async function render(props: Partial<Parameters<typeof SyncOptionsDialog>[0]> = {}) {
31
+ await act(async () => root.render(
32
+ <SyncOptionsDialog
33
+ open
34
+ appLabel="argocd/guestbook"
35
+ applicationSyncOptions={APP_OPTIONS}
36
+ onCancel={() => {}}
37
+ onConfirm={(opts) => confirmed.push(opts)}
38
+ {...props}
39
+ />,
40
+ ))
41
+ }
42
+
43
+ // A sync using Replace or Force is labelled "Sync with replace" etc.; tests
44
+ // about other behavior find it by its plain label.
45
+ const button = (label: string) => [...document.querySelectorAll('button')].find((b) => {
46
+ const text = b.textContent?.trim() ?? ''
47
+ return text === label || text.startsWith(`${label === 'Sync now' ? 'Sync' : label} with `)
48
+ })!
49
+ const toggle = (label: string) => [...document.querySelectorAll('label')].find((l) => l.textContent?.startsWith(label))!.querySelector('input')!
50
+ const click = async (el: HTMLElement) => { await act(async () => el.click()) }
51
+
52
+ it('starts with prune off and submits nothing for the options, so the Application keeps its own', async () => {
53
+ await render()
54
+ expect(toggle('Prune').checked).toBe(false)
55
+ expect(toggle('Server-side apply').checked).toBe(true)
56
+ expect(toggle('Server-side apply').disabled).toBe(false)
57
+ expect(toggle('Replace').checked).toBe(false)
58
+ expect(toggle('Replace').disabled).toBe(false)
59
+ expect(document.body.textContent).not.toContain(STALE_OPTIONS_NOTICE)
60
+ await click(button('Sync now'))
61
+ expect(confirmed).toEqual([{ revision: undefined, prune: false, dryRun: false, force: false, applyOnly: false }])
62
+ })
63
+
64
+ it('hides the revision for multi-source Applications and explains comparison pinning', async () => {
65
+ await render({ multiSource: true, pinsComparedRevision: true })
66
+ expect(document.body.textContent).not.toContain('Revision (optional)')
67
+ expect(document.body.textContent).toContain('Syncs each source at the revision Argo last compared.')
68
+ await click(button('Sync now'))
69
+ expect(confirmed[0].revision).toBeUndefined()
70
+ })
71
+
72
+ // A Radar without argoSyncInheritance resolves the target when the sync runs,
73
+ // so the dialog must not promise the compared revision.
74
+ it('promises no compared revision when the serving Radar does not pin it', async () => {
75
+ await render({ comparedRevision: 'abcdef0123456789abcdef0123456789abcdef01' })
76
+ expect(document.body.textContent).toContain('Leave empty for the target revision, resolved when the sync runs.')
77
+ expect(document.body.textContent).not.toContain('last compared')
78
+ await render({ multiSource: true })
79
+ expect(document.body.textContent).toContain("Syncs each source's target revision, resolved when the sync runs.")
80
+ await render({ resource: { kind: 'Deployment', group: 'apps', namespace: 'guestbook', name: 'web' } })
81
+ expect(document.body.textContent).toContain('Uses the target revision, resolved when the sync runs.')
82
+ expect(document.body.textContent).not.toContain('last compared')
83
+ })
84
+
85
+ it('omits an entered revision if the Application becomes multi-source while open', async () => {
86
+ await render()
87
+ const revision = document.querySelector('input:not([type="checkbox"])') as HTMLInputElement
88
+ await act(async () => {
89
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(revision, 'release-1')
90
+ revision.dispatchEvent(new Event('input', { bubbles: true }))
91
+ })
92
+ await render({ multiSource: true })
93
+ await click(button('Sync now'))
94
+ expect(confirmed[0].revision).toBeUndefined()
95
+ })
96
+
97
+ it('explains selective sync hooks, dependencies and namespace management without denying wave ordering', async () => {
98
+ await render({ resource: { kind: 'Deployment', group: 'apps', namespace: 'guestbook', name: 'web' }, pinsComparedRevision: true })
99
+ expect(document.body.textContent).toContain("Syncs only this resource, without hooks. Resources it depends on aren't synced; namespace creation still applies. Uses the revision Argo last compared.")
100
+ expect(document.body.textContent).not.toContain('Only this resource will be applied')
101
+ expect(document.body.textContent).not.toContain('bypasses hooks and normal sync-wave ordering')
102
+ })
103
+
104
+ it('disables unknown sync options and omits them when confirming', async () => {
105
+ await render({ applicationSyncOptions: undefined })
106
+ expect(toggle('Replace').disabled).toBe(true)
107
+ expect(toggle('Server-side apply').disabled).toBe(true)
108
+ expect(document.body.textContent).toContain("This view can't show or change the Application's sync options, so Replace and Server-side apply are unavailable here.")
109
+ await click(toggle('Replace'))
110
+ await click(toggle('Server-side apply'))
111
+ await click(toggle('Prune'))
112
+ await click(button('Sync now'))
113
+ expect(confirmed).toEqual([{ revision: undefined, prune: true, dryRun: false, force: false, applyOnly: false }])
114
+ })
115
+
116
+ it('omits unknown sync options for selective sync and its dry-run, even after earlier edits', async () => {
117
+ const props = {
118
+ resource: { kind: 'Deployment', group: 'apps', namespace: 'guestbook', name: 'web' },
119
+ onValidate: (opts: ArgoSyncOpts) => validated.push(opts),
120
+ }
121
+ await render(props)
122
+ await click(toggle('Replace'))
123
+ await render({ ...props, applicationSyncOptions: undefined })
124
+ expect(toggle('Replace').disabled).toBe(true)
125
+ expect(toggle('Server-side apply').disabled).toBe(true)
126
+ await click(button('Dry-run'))
127
+ await click(button('Sync resource'))
128
+ expect(validated).toEqual([{ revision: undefined, prune: false, dryRun: true, force: false, applyOnly: false }])
129
+ expect(confirmed).toEqual([{ revision: undefined, prune: false, dryRun: false, force: false, applyOnly: false }])
130
+ })
131
+
132
+ it('keeps toggles editable when the Application has an explicitly empty option list', async () => {
133
+ await render({ applicationSyncOptions: [] })
134
+ expect(toggle('Replace').disabled).toBe(false)
135
+ expect(toggle('Replace').checked).toBe(false)
136
+ expect(toggle('Server-side apply').disabled).toBe(false)
137
+ expect(toggle('Server-side apply').checked).toBe(false)
138
+ expect(document.body.textContent).not.toContain("This view can't show or change")
139
+ await click(button('Sync now'))
140
+ expect(confirmed[0]).not.toHaveProperty('syncOptions')
141
+ await click(toggle('Replace'))
142
+ await click(toggle('Server-side apply'))
143
+ await click(button('Sync now'))
144
+ expect(confirmed[1].syncOptions).toEqual(['Replace=true', 'ServerSideApply=true'])
145
+ await click(toggle('Replace'))
146
+ await click(toggle('Server-side apply'))
147
+ await click(button('Sync now'))
148
+ expect(confirmed[2]).not.toHaveProperty('syncOptions')
149
+ })
150
+
151
+ it("sends the Application's options with one edit when a toggle changes", async () => {
152
+ await render()
153
+ await click(toggle('Server-side apply'))
154
+ await click(toggle('Prune'))
155
+ await click(button('Sync now'))
156
+ expect(confirmed[0]).toMatchObject({ prune: true, syncOptions: ['CreateNamespace=true'] })
157
+ })
158
+
159
+ it('lists all other options read-only and preserves them when either toggle changes', async () => {
160
+ const otherOptions = ['CreateNamespace=true', 'FailOnSharedResource=true', 'PrunePropagationPolicy=orphan', 'Validate=false', 'Custom=opaque']
161
+ await render({ applicationSyncOptions: [...otherOptions, 'Replace=false', 'ServerSideApply=true'] })
162
+ expect(document.body.textContent).toContain('Also used for this sync')
163
+ expect([...document.querySelectorAll('li')].map((li) => li.textContent)).toEqual(otherOptions)
164
+ expect(document.body.textContent).not.toContain("Keep the Application's other sync options")
165
+ expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(6)
166
+ await click(button('Sync now'))
167
+ expect(confirmed[0]).not.toHaveProperty('syncOptions')
168
+ await click(toggle('Replace'))
169
+ await click(button('Sync now'))
170
+ expect(confirmed[1].syncOptions).toEqual([...otherOptions, 'Replace=true', 'ServerSideApply=true'])
171
+ await click(toggle('Server-side apply'))
172
+ await click(button('Sync now'))
173
+ expect(confirmed[2].syncOptions).toEqual([...otherOptions, 'Replace=true'])
174
+ })
175
+
176
+ it('builds the selective sync and its dry-run from the same options', async () => {
177
+ await render({
178
+ resource: { kind: 'Deployment', group: 'apps', namespace: 'guestbook', name: 'web' },
179
+ onValidate: (opts) => validated.push(opts),
180
+ })
181
+ await click(button('Dry-run'))
182
+ await click(button('Sync resource'))
183
+ expect(validated).toEqual([{ revision: undefined, prune: false, dryRun: true, force: false, applyOnly: false }])
184
+ expect(confirmed).toEqual([{ revision: undefined, prune: false, dryRun: false, force: false, applyOnly: false }])
185
+
186
+ await click(toggle('Replace'))
187
+ await click(button('Dry-run'))
188
+ await click(button('Sync resource'))
189
+ expect(validated[1].syncOptions).toEqual(['CreateNamespace=true', 'Replace=true', 'ServerSideApply=true'])
190
+ expect(confirmed[1].syncOptions).toEqual(['CreateNamespace=true', 'Replace=true', 'ServerSideApply=true'])
191
+ })
192
+
193
+ it('reopening resets edits to the Application’s options', async () => {
194
+ await render()
195
+ await click(toggle('Server-side apply'))
196
+ await render({ open: false })
197
+ await render()
198
+ expect(toggle('Server-side apply').checked).toBe(true)
199
+ await click(button('Sync now'))
200
+ expect(confirmed[0]).not.toHaveProperty('syncOptions')
201
+ })
202
+
203
+ it("keeps the user's dry-run and revision when the Application's options change while open", async () => {
204
+ const onValidationReset = vi.fn()
205
+ await render({ onValidationReset })
206
+ await click(toggle('Dry run'))
207
+ const revision = document.querySelector('input:not([type="checkbox"])') as HTMLInputElement
208
+ await act(async () => {
209
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(revision, 'release-1')
210
+ revision.dispatchEvent(new Event('input', { bubbles: true }))
211
+ })
212
+ await render({ onValidationReset, applicationSyncOptions: ['CreateNamespace=true'] })
213
+ expect(toggle('Dry run').checked).toBe(true)
214
+ expect(toggle('Server-side apply').checked).toBe(false)
215
+ expect(onValidationReset).toHaveBeenCalledTimes(1)
216
+ expect(document.body.textContent).not.toContain(STALE_OPTIONS_NOTICE)
217
+ expect(document.body.textContent).toContain('Also used for this sync')
218
+ await click(button('Run dry-run'))
219
+ expect(confirmed[0]).toEqual({ revision: 'release-1', prune: false, dryRun: true, force: false, applyOnly: false })
220
+ })
221
+
222
+ it("keeps the user's option edits when the Application's options change while open", async () => {
223
+ await render()
224
+ await click(toggle('Replace'))
225
+ expect(document.body.textContent).not.toContain(STALE_OPTIONS_NOTICE)
226
+ await render({ applicationSyncOptions: [...APP_OPTIONS] })
227
+ expect(document.body.textContent).not.toContain(STALE_OPTIONS_NOTICE)
228
+ await render({ applicationSyncOptions: ['PruneLast=true'] })
229
+ expect(document.body.textContent).toContain(STALE_OPTIONS_NOTICE)
230
+ expect([...document.querySelectorAll('li')].map((li) => li.textContent)).toEqual(['CreateNamespace=true'])
231
+ expect(document.body.textContent).not.toContain("Keep the Application's other sync options")
232
+ expect(toggle('Replace').checked).toBe(true)
233
+ expect(toggle('Server-side apply').checked).toBe(true)
234
+ await click(button('Sync now'))
235
+ expect(confirmed[0].syncOptions).toEqual(['CreateNamespace=true', 'Replace=true', 'ServerSideApply=true'])
236
+ })
237
+
238
+ it('sends the options it shows once they are no longer the Application’s current ones', async () => {
239
+ const onValidationReset = vi.fn()
240
+ await render({ onValidationReset })
241
+ await click(toggle('Replace'))
242
+ await click(toggle('Replace'))
243
+ await render({ onValidationReset, applicationSyncOptions: [] })
244
+ expect(toggle('Server-side apply').checked).toBe(true)
245
+ await click(button('Sync now'))
246
+ expect(confirmed[0].syncOptions).toEqual(APP_OPTIONS)
247
+ })
248
+
249
+ it('clears the stale-options notice when live options match again or the dialog reopens', async () => {
250
+ await render()
251
+ await click(toggle('Replace'))
252
+ await render({ applicationSyncOptions: [] })
253
+ expect(document.body.textContent).toContain(STALE_OPTIONS_NOTICE)
254
+ await render()
255
+ expect(document.body.textContent).not.toContain(STALE_OPTIONS_NOTICE)
256
+ expect(document.body.textContent).toContain('Also used for this sync')
257
+ await render({ applicationSyncOptions: ['PruneLast=true'] })
258
+ expect(document.body.textContent).toContain(STALE_OPTIONS_NOTICE)
259
+ await render({ open: false, applicationSyncOptions: ['PruneLast=true'] })
260
+ await render({ applicationSyncOptions: ['PruneLast=true'] })
261
+ expect(document.body.textContent).not.toContain(STALE_OPTIONS_NOTICE)
262
+ expect(toggle('Replace').checked).toBe(false)
263
+ expect(document.body.textContent).toContain('Also used for this sync')
264
+ await click(button('Sync now'))
265
+ expect(confirmed[0]).not.toHaveProperty('syncOptions')
266
+ })
267
+
268
+ it('shows the stale-options notice even when the snapshot has no other options', async () => {
269
+ await render({ applicationSyncOptions: [] })
270
+ await click(toggle('Replace'))
271
+ await render({ applicationSyncOptions: ['PruneLast=true'] })
272
+ expect(document.body.textContent).toContain(STALE_OPTIONS_NOTICE)
273
+ expect(document.body.textContent).not.toContain('Also used for this sync')
274
+ await click(button('Sync now'))
275
+ expect(confirmed[0].syncOptions).toEqual(['Replace=true'])
276
+ })
277
+
278
+ it('allows revision previews with auto-sync enabled and omits the override for a real sync', async () => {
279
+ await render({ autoSyncEnabled: true })
280
+ const revision = document.querySelector('input:not([type="checkbox"])') as HTMLInputElement
281
+ expect(revision.disabled).toBe(true)
282
+ expect(document.body.textContent).toContain('Turn off auto-sync to deploy another revision; a dry run can preview one.')
283
+ await click(toggle('Dry run'))
284
+ expect(revision.disabled).toBe(false)
285
+ await act(async () => {
286
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(revision, 'old-sha')
287
+ revision.dispatchEvent(new Event('input', { bubbles: true }))
288
+ })
289
+ await click(button('Run dry-run'))
290
+ expect(confirmed[0]).toMatchObject({ revision: 'old-sha', dryRun: true })
291
+ await click(toggle('Dry run'))
292
+ expect(revision.disabled).toBe(true)
293
+ // The override no longer applies, so it isn't left on screen while the sync deploys something else.
294
+ expect(revision.value).toBe('')
295
+ await click(button('Sync now'))
296
+ expect(confirmed[1].revision).toBeUndefined()
297
+ })
298
+
299
+ it('omits a previously entered revision when auto-sync becomes enabled while open', async () => {
300
+ await render()
301
+ const revision = document.querySelector('input:not([type="checkbox"])') as HTMLInputElement
302
+ await act(async () => {
303
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(revision, 'old-sha')
304
+ revision.dispatchEvent(new Event('input', { bubbles: true }))
305
+ })
306
+ await render({ autoSyncEnabled: true })
307
+ expect(revision.disabled).toBe(true)
308
+ expect(revision.value).toBe('')
309
+ await click(button('Sync now'))
310
+ expect(confirmed[0].revision).toBeUndefined()
311
+ })
312
+
313
+ it.each([
314
+ { comparedRevision: 'abcdef0123456789abcdef0123456789abcdef01', label: ' (abcdef0)' },
315
+ { comparedRevision: 'chart-version-1.2.3', label: ' (chart-version-1.2.3)' },
316
+ { comparedRevision: undefined, label: '' },
317
+ ])('explains empty revision pinning with comparison $comparedRevision', async ({ comparedRevision, label }) => {
318
+ await render({ comparedRevision, pinsComparedRevision: true })
319
+ expect(document.body.textContent).toContain(`Leave empty for the revision Argo last compared${label}.`)
320
+ await click(button('Sync now'))
321
+ expect(confirmed[0].revision).toBeUndefined()
322
+ })
323
+
324
+ // The backend pins the validation dry-run like the sync, so one target is named.
325
+ it('names one compared target for selective validation and sync', async () => {
326
+ await render({
327
+ resource: { kind: 'Deployment', group: 'apps', namespace: 'guestbook', name: 'web' },
328
+ comparedRevision: 'abcdef0123456789abcdef0123456789abcdef01',
329
+ pinsComparedRevision: true,
330
+ onValidate: (opts) => validated.push(opts),
331
+ })
332
+ expect(document.body.textContent).toContain('Uses the revision Argo last compared (abcdef0).')
333
+ expect(document.body.textContent).not.toContain('latest revision')
334
+ await click(button('Dry-run'))
335
+ await click(button('Sync resource'))
336
+ expect(validated[0].revision).toBeUndefined()
337
+ expect(confirmed[0].revision).toBeUndefined()
338
+ })
339
+
340
+ // Argo CD's sync panel confirms Replace and Force. Here a real sync using
341
+ // either names it on the button, with one line saying what it can do.
342
+ describe('disruptive sync modes', () => {
343
+ const primary = () => document.querySelector('button.bg-amber-600, button.btn-brand:last-child')?.textContent?.trim()
344
+
345
+ it('says nothing extra for an ordinary sync', async () => {
346
+ await render()
347
+ expect(document.body.textContent).not.toContain('can cause downtime')
348
+ expect(button('Sync now').textContent?.trim()).toBe('Sync now')
349
+ })
350
+
351
+ it('names Replace inherited from the Application before the sync', async () => {
352
+ await render({ applicationSyncOptions: ['Replace=true'] })
353
+ expect(document.body.textContent).toContain('Replace deletes and recreates resources instead of updating them, which can cause downtime.')
354
+ expect(primary()).toBe('Sync with replace')
355
+ })
356
+
357
+ it('names Force and both modes together, and drops the warning for a dry run', async () => {
358
+ await render()
359
+ await click(toggle('Force'))
360
+ expect(document.body.textContent).toContain('Force removes resources immediately, skipping graceful deletion, which can lose data.')
361
+ expect(primary()).toBe('Sync with force')
362
+ await click(toggle('Replace'))
363
+ expect(primary()).toBe('Sync with replace and force')
364
+ await click(toggle('Dry run'))
365
+ expect(document.body.textContent).not.toContain('can lose data')
366
+ expect(primary()).toBe('Run dry-run')
367
+ })
368
+
369
+ it('labels a selective sync the same way', async () => {
370
+ await render({ resource: { kind: 'Deployment', group: 'apps', namespace: 'guestbook', name: 'web' } })
371
+ await click(toggle('Force'))
372
+ expect(primary()).toBe('Sync resource with force')
373
+ })
374
+ })
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { buildArgoSyncOpts } from './SyncOptionsDialog'
2
+ import { argoSyncOptionsForRequest, buildArgoSyncOpts } from './SyncOptionsDialog'
3
+
4
+ const base = { revision: '', prune: false, dryRun: false, force: false, applyOnly: false, replace: false, serverSideApply: false }
3
5
 
4
6
  describe('buildArgoSyncOpts', () => {
5
7
  it('keeps full application sync controls', () => {
@@ -45,4 +47,55 @@ describe('buildArgoSyncOpts', () => {
45
47
  syncOptions: ['ServerSideApply=true'],
46
48
  })
47
49
  })
50
+
51
+ it('sends no options when the toggles match the Application, so its own apply', () => {
52
+ const opts = buildArgoSyncOpts({ ...base, resourceMode: false, serverSideApply: true, applicationSyncOptions: ['CreateNamespace=true', 'ServerSideApply=true'] })
53
+ expect(opts).not.toHaveProperty('syncOptions')
54
+ expect(buildArgoSyncOpts({ ...base, resourceMode: false })).not.toHaveProperty('syncOptions')
55
+ })
56
+
57
+ it("applies a toggle as an edit on top of the Application's options", () => {
58
+ const applicationSyncOptions = ['CreateNamespace=true', 'ServerSideApply=true', 'PruneLast=true']
59
+ expect(buildArgoSyncOpts({ ...base, resourceMode: false, replace: true, serverSideApply: true, applicationSyncOptions }).syncOptions)
60
+ .toEqual(['CreateNamespace=true', 'PruneLast=true', 'Replace=true', 'ServerSideApply=true'])
61
+ expect(buildArgoSyncOpts({ ...base, resourceMode: false, applicationSyncOptions }).syncOptions)
62
+ .toEqual(['CreateNamespace=true', 'PruneLast=true'])
63
+ })
64
+
65
+ it("turning on an option the Application sets to false replaces that entry", () => {
66
+ expect(buildArgoSyncOpts({ ...base, resourceMode: true, serverSideApply: true, applicationSyncOptions: ['ServerSideApply=false', 'Validate=false'] }).syncOptions)
67
+ .toEqual(['Validate=false', 'ServerSideApply=true'])
68
+ })
69
+
70
+ it('preserves every other option string when editing either owned key', () => {
71
+ const applicationSyncOptions = ['FailOnSharedResource=true', 'PrunePropagationPolicy=orphan', 'Validate=false', 'Custom=opaque', 'Custom=opaque', 'Replace=false', 'ServerSideApply=false']
72
+ const otherOptions = applicationSyncOptions.slice(0, -2)
73
+ expect(buildArgoSyncOpts({ ...base, resourceMode: false, replace: true, applicationSyncOptions }).syncOptions)
74
+ .toEqual([...otherOptions, 'Replace=true'])
75
+ expect(buildArgoSyncOpts({ ...base, resourceMode: false, serverSideApply: true, applicationSyncOptions }).syncOptions)
76
+ .toEqual([...otherOptions, 'ServerSideApply=true'])
77
+ })
78
+ })
79
+
80
+ describe('argoSyncOptionsForRequest', () => {
81
+ const app = ['CreateNamespace=true']
82
+ it('omits the options for a Radar that inherits them', () => {
83
+ expect(argoSyncOptionsForRequest(undefined, app, true)).toBeUndefined()
84
+ })
85
+ it("sends the Application's options to an older Radar, which would drop them", () => {
86
+ expect(argoSyncOptionsForRequest(undefined, app, false)).toEqual(app)
87
+ expect(argoSyncOptionsForRequest(undefined, [], false)).toBeUndefined()
88
+ expect(argoSyncOptionsForRequest(undefined, undefined, false)).toBeUndefined()
89
+ })
90
+ it('sends explicit options unchanged either way', () => {
91
+ expect(argoSyncOptionsForRequest([], app, false)).toEqual([])
92
+ expect(argoSyncOptionsForRequest(['Replace=true'], app, true)).toEqual(['Replace=true'])
93
+ })
94
+ })
95
+
96
+ describe('buildArgoSyncOpts with options that are no longer current', () => {
97
+ it('sends the unchanged list instead of omitting it', () => {
98
+ expect(buildArgoSyncOpts({ ...base, resourceMode: false, serverSideApply: true, applicationSyncOptions: ['ServerSideApply=true'], sendUnchanged: true }).syncOptions)
99
+ .toEqual(['ServerSideApply=true'])
100
+ })
48
101
  })