@skyhook-io/k8s-ui 1.18.0 → 1.18.2

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 (128) hide show
  1. package/README.md +21 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  5. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  6. package/src/components/compare/ResourceCompareView.tsx +6 -2
  7. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  9. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  10. package/src/components/gitops/GitOpsTableView.tsx +46 -12
  11. package/src/components/gitops/detail-helpers.test.ts +58 -13
  12. package/src/components/gitops/detail-helpers.ts +37 -6
  13. package/src/components/gitops/index.ts +1 -0
  14. package/src/components/issues/IssuesView.tsx +16 -11
  15. package/src/components/issues/diagnostic.ts +2 -0
  16. package/src/components/issues/severity.ts +1 -0
  17. package/src/components/logs/LogCore.tsx +13 -11
  18. package/src/components/logs/LogsViewer.test.tsx +104 -59
  19. package/src/components/logs/LogsViewer.tsx +28 -8
  20. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  21. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  22. package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
  23. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  24. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  25. package/src/components/resources/ResourcesSidebar.tsx +62 -26
  26. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  27. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  28. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  29. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  30. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  31. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  32. package/src/components/resources/ResourcesView.tsx +342 -83
  33. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  34. package/src/components/resources/generic-status.ts +15 -0
  35. package/src/components/resources/printer-columns.test.ts +36 -0
  36. package/src/components/resources/printer-columns.ts +12 -1
  37. package/src/components/resources/readiness-gates.test.ts +95 -0
  38. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  39. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  40. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  41. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  42. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  43. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  44. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  45. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  46. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  47. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  48. package/src/components/resources/renderers/index.ts +3 -0
  49. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  50. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  51. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  52. package/src/components/resources/resource-utils-flagger.ts +76 -0
  53. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  54. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  55. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  56. package/src/components/resources/resource-utils-trivy.ts +122 -0
  57. package/src/components/resources/resource-utils.ts +80 -2
  58. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  59. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  60. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  61. package/src/components/shared/DetailShell.tsx +21 -8
  62. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  63. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  64. package/src/components/shared/EditableYamlView.tsx +18 -3
  65. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  66. package/src/components/shared/FilterRailPanel.tsx +62 -0
  67. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  68. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  69. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  70. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  71. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  72. package/src/components/shared/ManagedByChip.tsx +158 -5
  73. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  74. package/src/components/shared/ResourceActionsBar.tsx +31 -9
  75. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  76. package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
  77. package/src/components/shared/SetImageDialog.tsx +3 -0
  78. package/src/components/timeline/TimelineList.tsx +5 -1
  79. package/src/components/topology/TopologyControls.tsx +1 -1
  80. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  81. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  82. package/src/components/ui/FetchResult.test.tsx +19 -2
  83. package/src/components/ui/FetchResult.tsx +13 -1
  84. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  85. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  86. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  87. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  88. package/src/components/ui/PageHeader.tsx +7 -4
  89. package/src/components/ui/SummaryTile.tsx +13 -3
  90. package/src/components/ui/Toast.tsx +33 -26
  91. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  92. package/src/components/ui/Tooltip.tsx +11 -2
  93. package/src/components/ui/YamlReview.test.tsx +52 -0
  94. package/src/components/ui/YamlReview.tsx +7 -0
  95. package/src/components/ui/drawer-components.test.tsx +280 -0
  96. package/src/components/ui/drawer-components.tsx +302 -18
  97. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  98. package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
  99. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  100. package/src/components/workload/WorkloadView.tsx +105 -34
  101. package/src/hooks/index.ts +2 -0
  102. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  103. package/src/hooks/useKeepInViewport.test.ts +27 -0
  104. package/src/hooks/useKeepInViewport.ts +87 -0
  105. package/src/hooks/useMediaQuery.ts +21 -0
  106. package/src/hooks/usePhoneLayout.ts +12 -0
  107. package/src/theme/tailwind-theme.css +5 -0
  108. package/src/types/core.ts +119 -3
  109. package/src/types/gitops-flux-inventory.test.ts +24 -0
  110. package/src/types/gitops.ts +5 -5
  111. package/src/types/index.ts +3 -0
  112. package/src/types/read-coverage.ts +65 -0
  113. package/src/types/resource-coverage.ts +40 -0
  114. package/src/utils/api-resources.ts +1 -0
  115. package/src/utils/format.test.ts +7 -0
  116. package/src/utils/format.ts +6 -1
  117. package/src/utils/gitops-owner.test.ts +42 -1
  118. package/src/utils/gitops-owner.ts +158 -8
  119. package/src/utils/gitops-write-guard.test.ts +332 -0
  120. package/src/utils/gitops-write-guard.ts +175 -11
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/read-actions.test.ts +22 -0
  123. package/src/utils/read-actions.ts +90 -0
  124. package/src/utils/resource-coverage.test.ts +121 -0
  125. package/src/utils/resource-coverage.ts +117 -0
  126. package/src/utils/touch-intent.test.ts +39 -0
  127. package/src/utils/touch-intent.ts +35 -0
  128. package/vitest.config.ts +26 -0
@@ -1,3 +1,5 @@
1
+ import type { ResourceCoverage } from '../../types/resource-coverage'
2
+ import { boundedCountLabel, isEmptyCount, isLowerBound, resourceCountHint, resourceCountLabel } from '../../utils/resource-coverage'
1
3
  import { useState, useMemo, useEffect, useRef, useCallback, useId, forwardRef, type ComponentType } from 'react'
2
4
  import {
3
5
  Search,
@@ -60,6 +62,7 @@ export interface ResourcesSidebarProps {
60
62
  onKindChange?: () => void
61
63
  apiResources?: APIResource[]
62
64
  resourceCounts?: Record<string, number | null>
65
+ resourceCoverage?: Record<string, ResourceCoverage>
63
66
  resourceUnavailable?: string[]
64
67
  resourceForbidden?: string[]
65
68
  pinned?: PinnedItem[]
@@ -76,6 +79,18 @@ export interface ResourcesSidebarProps {
76
79
  /** Task destinations keyed by category name (e.g. "CloudNativePG"). A category
77
80
  * with a workspace stays visible even when it has no resources. */
78
81
  categoryWorkspaces?: Record<string, SidebarCategoryWorkspace>
82
+ /** Display settings held by the parent, for a sidebar that unmounts between
83
+ * uses (the phone drawer) and should not reset them each time it opens. */
84
+ displayState?: SidebarDisplayState
85
+ onDisplayStateChange?: (state: SidebarDisplayState) => void
86
+ /** Escape in a non-empty kind filter clears it without letting the key
87
+ * reach an enclosing dialog, which would close. */
88
+ containEscape?: boolean
89
+ }
90
+
91
+ export interface SidebarDisplayState {
92
+ showEmptyKinds: boolean
93
+ favoritesExpanded: boolean
79
94
  }
80
95
 
81
96
  // Persisted across remounts so collapsed categories survive tab switches
@@ -143,6 +158,7 @@ interface ResourceTypeButtonProps {
143
158
  /** `null` means "count not loaded yet" — rendered as a placeholder so
144
159
  * the badge doesn't flicker to "0" while the API call is in flight. */
145
160
  count: number | null
161
+ coverage?: ResourceCoverage
146
162
  isSelected: boolean
147
163
  /** Keyboard-highlight state (arrow nav in the filter input). */
148
164
  isHighlighted?: boolean
@@ -154,7 +170,7 @@ interface ResourceTypeButtonProps {
154
170
  }
155
171
 
156
172
  const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps>(
157
- function ResourceTypeButton({ resource, count, isSelected, isHighlighted, isForbidden: forbidden, isPinned, onTogglePin, onClick, indent }, ref) {
173
+ function ResourceTypeButton({ resource, count, coverage, isSelected, isHighlighted, isForbidden: forbidden, isPinned, onTogglePin, onClick, indent }, ref) {
158
174
  const Icon = getResourceIcon(resource.kind, resource.group)
159
175
  return (
160
176
  <button
@@ -214,13 +230,14 @@ const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps
214
230
  </span>
215
231
  </Tooltip>
216
232
  ) : (
217
- <span className={clsx(
218
- 'text-xs py-0.5 rounded text-center font-mono',
219
- isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
220
- count < 1000 ? 'w-8' : 'w-9',
221
- )}>
222
- {count}
223
- </span>
233
+ <Tooltip disabled={!resourceCountHint(coverage)} content={resourceCountHint(coverage)} position="left">
234
+ <span className={clsx(
235
+ 'text-xs py-0.5 px-1 min-w-8 rounded text-center font-mono',
236
+ isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
237
+ )}>
238
+ {resourceCountLabel(count, coverage)}
239
+ </span>
240
+ </Tooltip>
224
241
  )}
225
242
  </div>
226
243
  </button>
@@ -234,6 +251,7 @@ export function ResourcesSidebar({
234
251
  onKindChange,
235
252
  apiResources,
236
253
  resourceCounts,
254
+ resourceCoverage,
237
255
  resourceUnavailable,
238
256
  resourceForbidden,
239
257
  pinned = [],
@@ -244,6 +262,9 @@ export function ResourcesSidebar({
244
262
  basePath,
245
263
  onKindNavigated,
246
264
  categoryWorkspaces,
265
+ displayState,
266
+ onDisplayStateChange,
267
+ containEscape,
247
268
  }: ResourcesSidebarProps) {
248
269
  // Wraps kind selection to also navigate when basePath/onNavigate are provided
249
270
  const selectKind = (kind: SelectedKindInfo) => {
@@ -261,8 +282,13 @@ export function ResourcesSidebar({
261
282
  () => persistedExpandedCategories ?? new Set(['Workloads', 'Networking', 'Configuration'])
262
283
  )
263
284
  useEffect(() => { persistedExpandedCategories = expandedCategories }, [expandedCategories])
264
- const [showEmptyKinds, setShowEmptyKinds] = useState(false)
265
- const [favoritesExpanded, setFavoritesExpanded] = useState(() => pinned.length > 0)
285
+ const [ownDisplayState, setOwnDisplayState] = useState<SidebarDisplayState>(() => ({ showEmptyKinds: false, favoritesExpanded: pinned.length > 0 }))
286
+ const { showEmptyKinds, favoritesExpanded } = displayState ?? ownDisplayState
287
+ const setDisplayState = (patch: Partial<SidebarDisplayState>) => {
288
+ const next = { showEmptyKinds, favoritesExpanded, ...patch }
289
+ if (onDisplayStateChange) onDisplayStateChange(next)
290
+ else setOwnDisplayState(next)
291
+ }
266
292
  const favoritesDisclosure = useDisclosure(favoritesExpanded)
267
293
  // Category sections are mapped inline, so they can't each call useDisclosure;
268
294
  // one generated prefix plus the category name keeps aria-controls unique.
@@ -401,20 +427,25 @@ export function ResourcesSidebar({
401
427
  (sum, resource) => sum + (counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind] ?? 0),
402
428
  0
403
429
  )
430
+ const lowerBound = category.resources.some(resource =>
431
+ isLowerBound(resourceCoverage?.[resource.group ? `${resource.group}/${resource.kind}` : resource.kind]))
432
+ const totalLabel = boundedCountLabel(total, lowerBound)
404
433
 
405
- // Filter resources: hide only confirmed-empty kinds. Unknown counts stay
406
- // visible as a dash so count coverage gaps do not masquerade as emptiness.
434
+ // Filter resources: hide only empty kinds. An unknown count stays visible
435
+ // as a dash, and a partial zero as one too, so count coverage gaps do not
436
+ // masquerade as emptiness.
407
437
  const visibleResources = category.resources.filter(resource => {
408
438
  const count = counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind]
409
439
  const isSelectedResource =
410
440
  (effectiveSelectedKind.name === resource.name && effectiveSelectedKind.group === resource.group) ||
411
441
  (effectiveSelectedKind.kind.toLowerCase() === resource.kind.toLowerCase() && effectiveSelectedKind.group === resource.group)
412
- const shouldShow = isSelectedResource || count === null || (count ?? 0) > 0 || showEmptyKinds
442
+ const key = resource.group ? `${resource.group}/${resource.kind}` : resource.kind
443
+ const shouldShow = isSelectedResource || count === null || !isEmptyCount(count ?? 0, resourceCoverage?.[key]) || showEmptyKinds
413
444
  if (!shouldShow) totalHiddenKinds++
414
445
  return shouldShow
415
446
  })
416
447
 
417
- return { ...category, total, visibleResources }
448
+ return { ...category, total, totalLabel, visibleResources }
418
449
  })
419
450
 
420
451
  // Sort: the category whose workspace is open first, then categories with
@@ -435,7 +466,7 @@ export function ResourcesSidebar({
435
466
  })
436
467
 
437
468
  return { sortedCategories: visibleCategories, hiddenKindsCount: totalHiddenKinds, hiddenGroupsCount: totalHiddenGroups }
438
- }, [categories, counts, showEmptyKinds, effectiveSelectedKind.name, effectiveSelectedKind.kind, effectiveSelectedKind.group, categoryWorkspaces, activeDestinationCategory])
469
+ }, [categories, counts, resourceCoverage, showEmptyKinds, effectiveSelectedKind.name, effectiveSelectedKind.kind, effectiveSelectedKind.group, categoryWorkspaces, activeDestinationCategory])
439
470
 
440
471
  // Filter sidebar categories/kinds by the kind search term
441
472
  const filteredCategories = useMemo(() => {
@@ -518,6 +549,7 @@ export function ResourcesSidebar({
518
549
 
519
550
  const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
520
551
  if (e.key === 'Escape') {
552
+ if (containEscape && kindFilter) e.stopPropagation()
521
553
  setKindFilter('')
522
554
  setHighlightedIndex(-1)
523
555
  ;(e.target as HTMLInputElement).blur()
@@ -535,7 +567,7 @@ export function ResourcesSidebar({
535
567
  setKindFilter('')
536
568
  onKindNavigated?.()
537
569
  }
538
- }, [visibleResults.length, highlightedResult, selectKind, onKindNavigated]) // eslint-disable-line react-hooks/exhaustive-deps
570
+ }, [visibleResults.length, highlightedResult, selectKind, onKindNavigated, containEscape, kindFilter]) // eslint-disable-line react-hooks/exhaustive-deps
539
571
 
540
572
  const isKindHighlighted = useCallback((name: string, group: string) => {
541
573
  return highlightedKind?.name === name && highlightedKind?.group === group
@@ -592,7 +624,7 @@ export function ResourcesSidebar({
592
624
  <div className="mb-2">
593
625
  <button
594
626
  {...favoritesDisclosure.buttonProps}
595
- onClick={() => setFavoritesExpanded((v) => !v)}
627
+ onClick={() => setDisplayState({ favoritesExpanded: !favoritesExpanded })}
596
628
  className="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-theme-text-tertiary hover:text-theme-text-secondary uppercase tracking-wide"
597
629
  >
598
630
  <CollapseChevron open={favoritesExpanded} className="w-3 h-3" />
@@ -621,6 +653,7 @@ export function ResourcesSidebar({
621
653
  ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
622
654
  resource={{ name: p.name, kind: p.kind, group: p.group, version: '', namespaced: true, isCrd: false, verbs: [] }}
623
655
  count={counts[p.group ? `${p.group}/${p.kind}` : p.kind] ?? null}
656
+ coverage={resourceCoverage?.[p.group ? `${p.group}/${p.kind}` : p.kind]}
624
657
  isSelected={isResourceSelected}
625
658
  isHighlighted={highlighted}
626
659
  isForbidden={forbiddenKinds.has(p.group ? `${p.group}/${p.kind}` : p.kind)}
@@ -653,8 +686,8 @@ export function ResourcesSidebar({
653
686
  <CollapseChevron open={isExpanded} className="w-3 h-3" />
654
687
  <span className="flex-1 text-left truncate" title={rawGroupTitle}>{category.name}</span>
655
688
  {!isExpanded && (
656
- <span className={clsx('text-xs py-0.5 rounded bg-theme-elevated text-theme-text-secondary font-normal normal-case text-center font-mono', category.total < 1000 ? 'w-8' : 'w-9')}>
657
- {category.total}
689
+ <span className="text-xs py-0.5 px-1 min-w-8 rounded bg-theme-elevated text-theme-text-secondary font-normal normal-case text-center font-mono">
690
+ {category.totalLabel}
658
691
  </span>
659
692
  )}
660
693
  </button>
@@ -692,6 +725,7 @@ export function ResourcesSidebar({
692
725
  ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
693
726
  resource={resource}
694
727
  count={counts[resource.group ? `${resource.group}/${resource.kind}` : resource.kind] ?? null}
728
+ coverage={resourceCoverage?.[resource.group ? `${resource.group}/${resource.kind}` : resource.kind]}
695
729
  isSelected={showSelected}
696
730
  isHighlighted={highlighted}
697
731
  isForbidden={forbiddenKinds.has(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)}
@@ -748,12 +782,14 @@ export function ResourcesSidebar({
748
782
  </span>
749
783
  </Tooltip>
750
784
  ) : (
751
- <span className={clsx(
752
- 'badge font-mono',
753
- isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
754
- )}>
755
- {count}
756
- </span>
785
+ <Tooltip disabled={!resourceCountHint(resourceCoverage?.[kindKey])} content={resourceCountHint(resourceCoverage?.[kindKey])} position="left">
786
+ <span className={clsx(
787
+ 'badge font-mono',
788
+ isSelected ? 'bg-skyhook-500/30 selection-text' : 'bg-theme-elevated',
789
+ )}>
790
+ {resourceCountLabel(count, resourceCoverage?.[kindKey])}
791
+ </span>
792
+ </Tooltip>
757
793
  )}
758
794
  </button>
759
795
  )
@@ -763,7 +799,7 @@ export function ResourcesSidebar({
763
799
  {/* Toggle for showing/hiding empty kinds and groups */}
764
800
  {hiddenKindsCount > 0 || hiddenGroupsCount > 0 || showEmptyKinds ? (
765
801
  <button
766
- onClick={() => setShowEmptyKinds(!showEmptyKinds)}
802
+ onClick={() => setDisplayState({ showEmptyKinds: !showEmptyKinds })}
767
803
  className="w-full flex items-center gap-2 px-3 py-2 mt-2 text-xs text-theme-text-tertiary hover:text-theme-text-secondary border-t border-theme-border"
768
804
  >
769
805
  {showEmptyKinds ? (
@@ -0,0 +1,133 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { VirtuosoMockContext } from 'react-virtuoso'
5
+ import { afterEach, expect, it, vi } from 'vitest'
6
+ import { ResourcesView } from './ResourcesView'
7
+ import { getCellFilterValue, serializeColumnFilters } from './resource-utils'
8
+ import { getResourceStatus } from '../shared/ResourceRendererDispatch'
9
+ import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
10
+ import type { APIResource } from '../../types'
11
+ import type { PrinterTable } from './printer-columns'
12
+
13
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
14
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
15
+ Element.prototype.scrollIntoView = vi.fn()
16
+ let root: ReturnType<typeof createRoot>
17
+ let host: HTMLDivElement
18
+
19
+ afterEach(() => { act(() => root?.unmount()); host?.remove(); localStorage.clear(); window.history.replaceState(null, '', '/') })
20
+
21
+ const pause = { type: 'Synced', status: 'False', reason: 'ReconcilePaused' }
22
+ const mr = { providerConfigRef: { name: 'default' } }
23
+ const xr = { resourceRefs: [] }
24
+ const claim = { resourceRef: { kind: 'Archive', name: 'xr' }, compositionRef: { name: 'archive' } }
25
+
26
+ it.each([
27
+ ['managed', 'test.crossplane.io', mr, false],
28
+ ['foreign-group managed', 'example.com', mr, false],
29
+ ['composite default', 'example.com', xr, false],
30
+ ['composite printer', 'example.com', xr, true],
31
+ ['claim printer', 'example.com', claim, true],
32
+ ['ref-less early composite printer', 'example.com', {}, true],
33
+ ['ref-less early claim default', 'example.com', {}, false],
34
+ ] as const)('keeps pause context and primary evidence on the real %s table path', async (_name, group, spec, printer) => {
35
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
36
+ const kind: APIResource = { group, version: 'v1', kind: 'Archive', name: 'archives', namespaced: true, isCrd: true, verbs: ['get', 'list'] }
37
+ const resource = {
38
+ apiVersion: `${group}/v1`, kind: 'Archive',
39
+ metadata: { name: 'acknowledged', namespace: 'test', uid: 'fixture', annotations: { 'crossplane.io/paused': 'true' } },
40
+ spec, status: { conditions: [pause, { type: 'Ready', status: 'False', reason: 'Unavailable' }] },
41
+ }
42
+ const printerTable: PrinterTable | undefined = printer ? {
43
+ kind: kind.name, group,
44
+ columns: [
45
+ { name: 'Synced', jsonPath: ".status.conditions[?(@.type=='Synced')].status", type: 'string' },
46
+ { name: 'Ready', jsonPath: ".status.conditions[?(@.type=='Ready')].status", type: 'string' },
47
+ ],
48
+ cells: { fixture: ['False', 'False'] },
49
+ } : undefined
50
+ await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView
51
+ namespaces={['test']}
52
+ apiResources={[kind]}
53
+ resourceCounts={{ Archive: 1 }}
54
+ selectedKindQuery={{ isLoading: false, data: [resource], resourceName: kind.name, group }}
55
+ locationPathname="/resources/archives"
56
+ locationSearch={`?apiGroup=${group}`}
57
+ onNavigate={() => {}}
58
+ printerTable={printerTable}
59
+ /></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
60
+ const row = host.querySelector('tbody tr')!
61
+ expect(row.textContent).toContain('acknowledged')
62
+ expect(row.textContent?.match(/Paused/g)).toHaveLength(1)
63
+ if (printer) {
64
+ const printed = [...row.querySelectorAll('td')].filter(cell => cell.textContent?.trim() === 'False')
65
+ expect(printed).toHaveLength(2)
66
+ expect(printed[0].querySelector('.status-neutral')).not.toBeNull()
67
+ expect(printed[1].querySelector('.status-unhealthy')).not.toBeNull()
68
+ expect(row.textContent).not.toContain('Unavailable')
69
+ } else {
70
+ expect(row.textContent).toContain('Unavailable')
71
+ expect(row.textContent).not.toContain('Out of sync')
72
+ }
73
+ })
74
+
75
+
76
+ it('preserves curated package install failure ahead of a recorded pause in the table and filter', async () => {
77
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
78
+ const group = 'pkg.crossplane.io'
79
+ const kind: APIResource = { group, version: 'v1', kind: 'Provider', name: 'providers', namespaced: false, isCrd: true, verbs: ['get', 'list'] }
80
+ const resource = {
81
+ apiVersion: `${group}/v1`, kind: 'Provider',
82
+ metadata: { name: 'install-failure', uid: 'package-fixture', annotations: { 'crossplane.io/paused': 'true' } },
83
+ spec: { package: 'example.invalid/provider:v1' },
84
+ status: { conditions: [pause, { type: 'Installed', status: 'False', reason: 'UnpackFailed' }, { type: 'Healthy', status: 'True' }] },
85
+ }
86
+ await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView
87
+ namespaces={[]}
88
+ apiResources={[kind]}
89
+ resourceCounts={{ Provider: 1 }}
90
+ selectedKindQuery={{ isLoading: false, data: [resource], resourceName: kind.name, group }}
91
+ locationPathname="/resources/providers"
92
+ locationSearch={`?apiGroup=${group}`}
93
+ onNavigate={() => {}}
94
+ /></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
95
+ const row = host.querySelector('tbody tr')!
96
+ expect(row.textContent).toContain('UnpackFailed')
97
+ expect(row.querySelector('.status-unhealthy')).not.toBeNull()
98
+ expect(row.textContent).not.toContain('Readiness unknown')
99
+ expect(getCellFilterValue(resource, 'status', 'providers')).toBe('UnpackFailed')
100
+ expect(getCellFilterValue(resource, 'status', 'providers')).toBe(getResourceStatus('providers', resource)?.text)
101
+ })
102
+
103
+
104
+ it.each([
105
+ ['ref-less generic', 'example.com', 'Archive', 'archives', {}, 'Ready (reported)', 'Ready (reported)'],
106
+ ['managed-name collision', 'acm.aws.upbound.io', 'Certificate', 'certificates', mr, 'Ready (reported)', 'Ready (reported)'],
107
+ ['curated Knative', 'serving.knative.dev', 'Revision', 'revisions', {}, 'Ready', 'Scaled to Zero'],
108
+ ] as const)('routes the actual %s status filter through the existing column policy', async (_name, group, kindName, plural, spec, filterText, cellText) => {
109
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
110
+ const kind: APIResource = { group, version: 'v1', kind: kindName, name: plural, namespaced: true, isCrd: true, verbs: ['get', 'list'] }
111
+ const resource = {
112
+ apiVersion: `${group}/v1`, kind: kindName,
113
+ metadata: { name: 'kept', namespace: 'test', uid: 'kept-fixture', annotations: { 'crossplane.io/paused': 'true' } },
114
+ spec, status: { conditions: [pause, { type: 'Ready', status: 'True' }, { type: 'Active', status: 'False', reason: 'NoTraffic' }] },
115
+ }
116
+ const excluded = { ...resource, metadata: { ...resource.metadata, name: 'excluded', uid: 'excluded-fixture' }, status: { conditions: [pause, { type: 'Ready', status: 'False', reason: 'Unavailable' }] } }
117
+ const search = `?${new URLSearchParams({ apiGroup: group, filters: serializeColumnFilters({ status: [filterText] }) })}`
118
+ window.history.replaceState(null, '', `/resources/${plural}${search}`)
119
+ await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView
120
+ namespaces={['test']}
121
+ apiResources={[kind]}
122
+ resourceCounts={{ [kindName]: 2 }}
123
+ selectedKindQuery={{ isLoading: false, data: [resource, excluded], resourceName: plural, group }}
124
+ locationPathname={`/resources/${plural}`}
125
+ locationSearch={search}
126
+ onNavigate={() => {}}
127
+ /></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
128
+ const rows = host.querySelectorAll('tbody tr')
129
+ expect(rows).toHaveLength(1)
130
+ expect(rows[0].textContent).toContain('kept')
131
+ expect(rows[0].textContent).toContain(cellText)
132
+ expect(rows[0].textContent).not.toContain('excluded')
133
+ })
@@ -0,0 +1,31 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { VirtuosoMockContext } from 'react-virtuoso'
5
+ import { afterEach, expect, it, vi } from 'vitest'
6
+ import { ResourcesView } from './ResourcesView'
7
+ import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
8
+ import type { APIResource } from '../../types'
9
+
10
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
11
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
12
+ Element.prototype.scrollIntoView = vi.fn()
13
+ let root: ReturnType<typeof createRoot>
14
+ let host: HTMLDivElement
15
+ afterEach(() => { act(() => root?.unmount()); host?.remove(); localStorage.clear() })
16
+
17
+ it('shows failed rollout and suspension together in the actual Canary table', async () => {
18
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
19
+ const kind: APIResource = { group: 'flagger.app', version: 'v1beta1', kind: 'Canary', name: 'canaries', namespaced: true, isCrd: true, verbs: ['get', 'list'] }
20
+ const data = { apiVersion: 'flagger.app/v1beta1', kind: 'Canary', metadata: { name: 'storefront', namespace: 'test' }, spec: { suspend: true, targetRef: { kind: 'Deployment', name: 'storefront' }, analysis: { iterations: 2 } }, status: { phase: 'Failed' } }
21
+ await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView namespaces={['test']} apiResources={[kind]} resourceCounts={{ Canary: 1 }} selectedKindQuery={{ isLoading: false, data: [data], resourceName: kind.name, group: kind.group }} locationPathname="/resources/canaries" locationSearch="?apiGroup=flagger.app" onNavigate={() => {}} /></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
22
+ const table = host.querySelector('table')!
23
+ expect(table).not.toBeNull()
24
+ expect(table.textContent).toContain('Rollout State')
25
+ expect(table.textContent).toContain('Failed')
26
+ expect(table.querySelector('.status-unhealthy')).not.toBeNull()
27
+ expect(table.textContent).toContain('Blue/green')
28
+ expect(table.textContent).toContain('Not weighted')
29
+ expect(table.textContent).toContain('Suspend Requested')
30
+ expect(table.textContent).toContain('Yes')
31
+ })
@@ -3,6 +3,7 @@ import { act, type ComponentProps } from 'react'
3
3
  import { createRoot } from 'react-dom/client'
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest'
5
5
  import { ResourcesView } from './ResourcesView'
6
+ import { ResourceActionsBar } from '../shared/ResourceActionsBar'
6
7
  import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
7
8
  import type { APIResource } from '../../types'
8
9
 
@@ -252,7 +253,7 @@ it('can show selection from compare without turning off the saved preference', (
252
253
  expect(localStorage.getItem(checkboxKey)).toBe('true')
253
254
  })
254
255
 
255
- it('keeps delete confirmation and clears successful selection without hiding checkboxes', () => {
256
+ it('confirms bulk deletion and clears successful selection without hiding checkboxes', () => {
256
257
  const { remove } = renderBulkTable()
257
258
  key('b')
258
259
  act(() => required<HTMLInputElement>('input[title="Select all"]').click())
@@ -268,6 +269,32 @@ it('keeps delete confirmation and clears successful selection without hiding che
268
269
  expect(localStorage.getItem(checkboxKey)).toBe('true')
269
270
  })
270
271
 
272
+ it('requires a new bulk confirmation after submission without clearing uncertain selection', async () => {
273
+ const { remove, render } = renderBulkTable()
274
+ const other = { ...pod, metadata: { ...pod.metadata, name: 'remaining', uid: 'remaining-id' } }
275
+ render(pods, [pod, other])
276
+ key('b')
277
+ act(() => required<HTMLInputElement>('input[title="Select all"]').click())
278
+ clickButton('Delete')
279
+ const dialog = required<HTMLElement>('[role="dialog"]', document)
280
+ clickButton('Delete 2 resources', dialog)
281
+ expect(remove).toHaveBeenCalledTimes(1)
282
+ expect(remove.mock.calls[0]?.[0]).toHaveLength(2)
283
+ expect(dialog.closest('[inert]')).not.toBeNull()
284
+ expect(host.textContent).toContain('2 selected')
285
+
286
+ render(pods, [other])
287
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 300)) })
288
+ expect(document.querySelector('[role="dialog"]')).toBeNull()
289
+ expect(host.textContent).toContain('1 selected')
290
+ expect(remove).toHaveBeenCalledTimes(1)
291
+ clickButton('Delete')
292
+ const nextDialog = required<HTMLElement>('[role="dialog"]', document)
293
+ expect(nextDialog.textContent).toContain('Delete 1 Pod?')
294
+ clickButton('Delete 1 resource', nextDialog)
295
+ expect(remove.mock.calls[1]?.[0]).toEqual([{ kind: 'pods', group: '', namespace: 'test', name: 'remaining' }])
296
+ })
297
+
271
298
  it.each(['false', 'garbage', 'null', '1'])('ignores invalid or disabled visibility value %s', value => {
272
299
  localStorage.setItem(checkboxKey, value)
273
300
  renderBulkTable()
@@ -294,3 +321,71 @@ it('does not offer selection without a bulk action callback even with saved visi
294
321
  expect(host.querySelector('input[title="Select all"]')).toBeNull()
295
322
  expect(localStorage.getItem(checkboxKey)).toBe('true')
296
323
  })
324
+
325
+ it('closes single-delete confirmation on submission but keeps the inspected resource until known success', () => {
326
+ const remove = vi.fn<NonNullable<ComponentProps<typeof ResourceActionsBar>['onDelete']>>()
327
+ const close = vi.fn()
328
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
329
+ act(() => root.render(<ResourceActionsBar
330
+ resource={{ kind: 'pods', namespace: 'test', name: 'example' }}
331
+ data={pod}
332
+ onDelete={remove}
333
+ onClose={close}
334
+ />))
335
+ act(() => required<SVGElement>('svg[class*="trash"]').closest<HTMLButtonElement>('button')!.click())
336
+ const dialog = required<HTMLElement>('[role="dialog"]', document)
337
+ clickButton('Delete', dialog)
338
+ expect(remove).toHaveBeenCalledTimes(1)
339
+ expect(dialog.closest('[inert]')).not.toBeNull()
340
+ expect(close).not.toHaveBeenCalled()
341
+ act(() => remove.mock.calls[0]?.[1]?.onSuccess?.())
342
+ expect(close).toHaveBeenCalledTimes(1)
343
+ })
344
+
345
+ it('preserves rows selected after a bulk delete was submitted', () => {
346
+ const { remove, render } = renderBulkTable()
347
+ key('b')
348
+ act(() => required<HTMLInputElement>('input[title="Select all"]').click())
349
+ clickButton('Delete')
350
+ clickButton('Delete 1 resource', required<HTMLElement>('[role="dialog"]', document))
351
+ const other = { ...pod, metadata: { ...pod.metadata, name: 'remaining', uid: 'remaining-id' } }
352
+ render(pods, [pod, other])
353
+ act(() => required<HTMLInputElement>('input[title="Deselect all"]').click())
354
+ expect(host.textContent).toContain('2 selected')
355
+ act(() => remove.mock.calls[0]?.[1]?.onSuccess?.())
356
+ expect(host.textContent).toContain('1 selected')
357
+ render(pods, [other])
358
+ expect(host.textContent).toContain('1 selected')
359
+ })
360
+
361
+ it.each(['name', 'uid'])('does not close a newly inspected resource after an earlier delete succeeds (%s)', change => {
362
+ const remove = vi.fn<NonNullable<ComponentProps<typeof ResourceActionsBar>['onDelete']>>()
363
+ const close = vi.fn()
364
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
365
+ const render = (name: string, uid: string, deleting = false) => act(() => root.render(<ResourceActionsBar
366
+ resource={{ kind: 'pods', namespace: 'test', name }}
367
+ data={{ ...pod, metadata: { ...pod.metadata, name, uid } }}
368
+ onDelete={remove}
369
+ onClose={close}
370
+ isDeleting={deleting}
371
+ />))
372
+ render('example', 'original')
373
+ act(() => required<HTMLButtonElement>('button[aria-label="Delete resource"]').click())
374
+ clickButton('Delete', required<HTMLElement>('[role="dialog"]', document))
375
+ render(change === 'name' ? 'other' : 'example', 'replacement', true)
376
+ expect(required<HTMLButtonElement>('button[aria-label="Deleting resource"]').disabled).toBe(true)
377
+ act(() => remove.mock.calls[0]?.[1]?.onSuccess?.())
378
+ expect(close).not.toHaveBeenCalled()
379
+ })
380
+
381
+ it('ignores a delete success callback after its action bar is unmounted', () => {
382
+ const remove = vi.fn<NonNullable<ComponentProps<typeof ResourceActionsBar>['onDelete']>>()
383
+ const close = vi.fn()
384
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
385
+ act(() => root.render(<ResourceActionsBar resource={{ kind: 'pods', namespace: 'test', name: 'example' }} data={pod} onDelete={remove} onClose={close} />))
386
+ act(() => required<HTMLButtonElement>('button[aria-label="Delete resource"]').click())
387
+ clickButton('Delete', required<HTMLElement>('[role="dialog"]', document))
388
+ act(() => root.render(<div>Another view</div>))
389
+ act(() => remove.mock.calls[0]?.[1]?.onSuccess?.())
390
+ expect(close).not.toHaveBeenCalled()
391
+ })
@@ -0,0 +1,29 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { VirtuosoMockContext } from 'react-virtuoso'
5
+ import { afterEach, expect, it, vi } from 'vitest'
6
+ import { ResourcesView } from './ResourcesView'
7
+ import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
8
+ import type { APIResource } from '../../types'
9
+
10
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
11
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
12
+ Element.prototype.scrollIntoView = vi.fn()
13
+ let root: ReturnType<typeof createRoot>
14
+ let host: HTMLDivElement
15
+ afterEach(() => { act(() => root?.unmount()); host?.remove(); localStorage.clear() })
16
+
17
+ it('shows reconciliation and labeled Pod snapshots in the actual collector table', async () => {
18
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
19
+ const kind: APIResource = { group: 'opentelemetry.io', version: 'v1beta1', kind: 'OpenTelemetryCollector', name: 'opentelemetrycollectors', namespaced: true, isCrd: true, verbs: ['get', 'list'] }
20
+ const data = { apiVersion: 'opentelemetry.io/v1beta1', kind: 'OpenTelemetryCollector', metadata: { name: 'collector', namespace: 'test' }, spec: { mode: 'deployment', managementState: 'managed' }, status: { conditions: [{ type: 'Ready', status: 'False', reason: 'ReconcileError' }], scale: { statusReplicas: '0/1' }, version: '0.162.0' } }
21
+ await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView namespaces={['test']} apiResources={[kind]} resourceCounts={{ OpenTelemetryCollector: 1 }} selectedKindQuery={{ isLoading: false, data: [data], resourceName: kind.name, group: kind.group }} locationPathname="/resources/opentelemetrycollectors" locationSearch="?apiGroup=opentelemetry.io" onNavigate={() => {}} /></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
22
+ const table = host.querySelector('table')!
23
+ expect(table).not.toBeNull()
24
+ expect(table.textContent).toContain('Reconciliation')
25
+ expect(table.textContent).toContain('Reconcile failed')
26
+ expect(table.textContent).toContain('Reported Pods')
27
+ expect(table.textContent).toContain('0/1')
28
+ expect(table.textContent).toContain('0.162.0')
29
+ })
@@ -3,6 +3,7 @@ import type { APIResource } from '../../types'
3
3
  import {
4
4
  canonicalizeSelectedKind,
5
5
  deriveSidebarResourceCounts,
6
+ deriveSidebarResourceCoverage,
6
7
  LOADED_RESOURCE_COUNT_TTL_MS,
7
8
  resourceQueryMatchesSelectedKind,
8
9
  } from './ResourcesView'
@@ -141,3 +142,16 @@ describe('resourceQueryMatchesSelectedKind', () => {
141
142
  ).toBe(true)
142
143
  })
143
144
  })
145
+
146
+
147
+ describe('loaded count coverage', () => {
148
+ const partial = { status: 'partial' as const, scope: { type: 'all_namespaces' as const }, read: { type: 'namespaces' as const, namespaces: { names: ['alpha'], count: 1 } }, gaps: [{ code: 'radar_namespace_not_collected' }] }
149
+ it('retains a cached partial count qualification and expires both together', () => {
150
+ const cache = { Endpoints: { count: 0, expiresAt: 20, coverage: partial } }
151
+ expect(deriveSidebarResourceCoverage({}, {}, cache, 10)).toEqual({ Endpoints: partial })
152
+ expect(deriveSidebarResourceCoverage({}, {}, cache, 20)).toEqual({})
153
+ })
154
+ it('does not pair older unqualified count responses with cached newer metadata', () => {
155
+ expect(deriveSidebarResourceCoverage({ Endpoints: 0 }, undefined, { Endpoints: { count: 0, expiresAt: 20, coverage: partial } }, 10)).toEqual({ Endpoints: { status: 'unknown' } })
156
+ })
157
+ })
@@ -0,0 +1,59 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+ import { ResourcesView } from './ResourcesView'
6
+ import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
7
+ import type { APIResource } from '../../types'
8
+
9
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
10
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
11
+ Element.prototype.scrollIntoView = vi.fn()
12
+
13
+ const group = 'aquasecurity.github.io'
14
+ const vulnReports: APIResource = { group, version: 'v1alpha1', kind: 'VulnerabilityReport', name: 'vulnerabilityreports', namespaced: true, isCrd: true, verbs: ['get', 'list'] }
15
+ const report = (name: string, vulnerabilityID: string) => ({
16
+ apiVersion: `${group}/v1alpha1`, kind: 'VulnerabilityReport',
17
+ metadata: { name, namespace: 'legacy', uid: name },
18
+ report: { artifact: { repository: 'trafex/php-nginx', tag: '3.6.0' }, vulnerabilities: [{ vulnerabilityID, resource: 'php83', installedVersion: '8.3.20-r0', fixedVersion: '8.3.36-r0' }] },
19
+ })
20
+ // The match sorts last, so selecting the first row proves the filter ran.
21
+ const billing = report('replicaset-billing-5cfccf7776-php-nginx', 'CVE-2025-0001')
22
+ const web = report('replicaset-web-7685556d86-php', 'CVE-2026-109618')
23
+
24
+ let root: ReturnType<typeof createRoot>
25
+ let host: HTMLDivElement
26
+ afterEach(() => { act(() => root?.unmount()); host?.remove(); localStorage.clear(); window.history.replaceState(null, '', '/') })
27
+
28
+ function render(search: string, onResourceClick = vi.fn()) {
29
+ const locationSearch = `?apiGroup=${group}&search=${encodeURIComponent(search)}`
30
+ window.history.replaceState(null, '', `/resources/vulnerabilityreports${locationSearch}`)
31
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
32
+ act(() => root.render(<KeyboardShortcutProvider><ResourcesView
33
+ namespaces={[]}
34
+ apiResources={[vulnReports]}
35
+ resourceCounts={{ VulnerabilityReport: 2 }}
36
+ selectedKindQuery={{ isLoading: false, data: [web, billing], resourceName: 'vulnerabilityreports', group }}
37
+ locationPathname="/resources/vulnerabilityreports"
38
+ locationSearch={locationSearch}
39
+ onNavigate={() => {}}
40
+ onResourceClick={onResourceClick}
41
+ /></KeyboardShortcutProvider>))
42
+ return onResourceClick
43
+ }
44
+
45
+ describe('Trivy report table search', () => {
46
+ it('finds a report by a CVE in its findings', () => {
47
+ const selected = render('CVE-2026-109618')
48
+ act(() => document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'j', bubbles: true })))
49
+ act(() => document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
50
+ expect(selected).toHaveBeenCalledTimes(1)
51
+ expect(selected).toHaveBeenCalledWith(expect.objectContaining({ name: web.metadata.name }))
52
+ })
53
+
54
+ it('says what it searched when nothing matches', () => {
55
+ render('CVE-1999-0001')
56
+ expect(host.textContent).toContain('No results for "CVE-1999-0001"')
57
+ expect(host.textContent).toContain('Searched names, namespaces and report findings')
58
+ })
59
+ })