@skyhook-io/k8s-ui 1.17.0 → 1.17.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.
- package/README.md +41 -0
- package/package.json +3 -3
- package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
- package/src/components/charts/AreaChart.test.tsx +25 -0
- package/src/components/charts/AreaChart.tsx +141 -12
- package/src/components/charts/index.ts +1 -0
- package/src/components/checks/ChecksView.tsx +18 -1
- package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
- package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
- package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
- package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
- package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
- package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
- package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
- package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
- package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
- package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
- package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
- package/src/components/cnpg/backupRuns.test.ts +17 -0
- package/src/components/cnpg/backupRuns.ts +19 -0
- package/src/components/cnpg/connect.test.ts +124 -0
- package/src/components/cnpg/connect.ts +155 -0
- package/src/components/cnpg/databaseRole.test.ts +38 -0
- package/src/components/cnpg/databaseRole.ts +80 -0
- package/src/components/cnpg/ha.test.ts +446 -0
- package/src/components/cnpg/ha.ts +621 -0
- package/src/components/cnpg/index.ts +18 -0
- package/src/components/cnpg/logicalReplication.test.ts +121 -0
- package/src/components/cnpg/logicalReplication.ts +252 -0
- package/src/components/cnpg/pooler.test.ts +84 -0
- package/src/components/cnpg/pooler.ts +225 -0
- package/src/components/cnpg/primitives.tsx +21 -117
- package/src/components/cnpg/problems.ts +12 -0
- package/src/components/cnpg/relations.test.ts +63 -13
- package/src/components/cnpg/relations.ts +61 -52
- package/src/components/cnpg/schedule.ts +35 -0
- package/src/components/cnpg/workspace-disk.test.ts +97 -0
- package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
- package/src/components/cnpg/workspace-problems.test.ts +175 -0
- package/src/components/cnpg/workspace.test.ts +380 -9
- package/src/components/cnpg/workspace.ts +1137 -136
- package/src/components/dock/DockContext.test.tsx +32 -0
- package/src/components/dock/DockContext.tsx +18 -4
- package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
- package/src/components/dock/LocalTerminalTab.tsx +4 -3
- package/src/components/dock/TerminalTab.tsx +20 -8
- package/src/components/facts/certainty.tsx +36 -0
- package/src/components/facts/facts.test.tsx +24 -0
- package/src/components/facts/facts.tsx +62 -0
- package/src/components/facts/index.ts +6 -0
- package/src/components/facts/managed-by.test.tsx +17 -0
- package/src/components/facts/managed-by.tsx +36 -0
- package/src/components/facts/public-exports.test.tsx +35 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
- package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
- package/src/components/gitops/insights/DriftPanel.tsx +62 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +24 -71
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
- package/src/components/gitops/tree/layout.test.ts +111 -0
- package/src/components/gitops/tree/layout.ts +128 -0
- package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
- package/src/components/gitops/tree/tree-helpers.ts +7 -1
- package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
- package/src/components/issues/IssuesView.tsx +40 -5
- package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
- package/src/components/issues/ResourceIssuesSection.tsx +3 -0
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +12 -1
- package/src/components/issues/issues.test.ts +49 -1
- package/src/components/issues/severity.ts +21 -0
- package/src/components/issues/types.ts +23 -0
- package/src/components/logs/LogCore.theme.test.tsx +81 -3
- package/src/components/logs/LogCore.tsx +25 -17
- package/src/components/logs/LogToolbarSelects.tsx +3 -1
- package/src/components/logs/LogsViewer.test.tsx +291 -0
- package/src/components/logs/LogsViewer.tsx +72 -11
- package/src/components/logs/StructuredLogLine.test.tsx +76 -0
- package/src/components/logs/StructuredLogLine.tsx +22 -30
- package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- package/src/components/logs/useLogStream.ts +17 -1
- package/src/components/problems/index.ts +1 -0
- package/src/components/problems/problems.test.tsx +63 -0
- package/src/components/problems/problems.tsx +165 -0
- package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
- package/src/components/resources/ActiveFilterBar.tsx +86 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
- package/src/components/resources/ResourcesSidebar.tsx +92 -41
- package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
- package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
- package/src/components/resources/ResourcesView.tsx +302 -109
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
- package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
- package/src/components/resources/renderers/HPARenderer.tsx +56 -16
- package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
- package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
- package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
- package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
- package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +41 -29
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
- package/src/components/resources/renderers/VPARenderer.tsx +17 -11
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
- package/src/components/resources/resource-utils-cnpg.ts +51 -20
- package/src/components/resources/resource-utils-contour.test.ts +44 -0
- package/src/components/resources/resource-utils-contour.ts +16 -0
- package/src/components/resources/resource-utils-istio.ts +12 -8
- package/src/components/resources/resource-utils-knative.ts +18 -0
- package/src/components/resources/resource-utils.ts +82 -6
- package/src/components/resources/rules-column-width.test.ts +1 -1
- package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
- package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
- package/src/components/shared/ActionConfirmDialog.tsx +246 -0
- package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
- package/src/components/shared/CreateResourceDialog.tsx +126 -80
- package/src/components/shared/DetailShell.test.tsx +80 -0
- package/src/components/shared/DetailShell.tsx +85 -10
- package/src/components/shared/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/ResourceActionsBar.tsx +11 -4
- package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
- package/src/components/shared/index.ts +2 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
- package/src/components/timeline/timeline-filters.ts +1 -0
- package/src/components/topology/K8sResourceNode.test.ts +18 -1
- package/src/components/topology/K8sResourceNode.tsx +44 -2
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +3 -71
- package/src/components/topology/TopologySearch.tsx +1 -0
- package/src/components/topology/internet-groups.test.ts +35 -0
- package/src/components/topology/internet-groups.ts +55 -0
- package/src/components/topology/layout.ts +1 -1
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/FoldSection.tsx +68 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
- package/src/components/ui/RefLink.tsx +20 -0
- package/src/components/ui/RowActionMenu.tsx +8 -3
- package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
- package/src/components/ui/drawer-components.test.tsx +110 -1
- package/src/components/ui/drawer-components.tsx +62 -9
- package/src/components/ui/index.ts +3 -1
- package/src/components/ui/monacoEditorFeatures.ts +77 -0
- package/src/components/ui/monacoRuntime.ts +14 -19
- package/src/components/ui/status-tone.test.ts +10 -1
- package/src/components/ui/status-tone.tsx +29 -0
- package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
- package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
- package/src/components/workload/WorkloadView.tsx +180 -64
- package/src/index.ts +14 -1
- package/src/monaco-deep.d.ts +19 -12
- package/src/theme/components.css +13 -0
- package/src/types/capacity.ts +11 -2
- package/src/types/core.ts +29 -2
- package/src/types/gitops-flux-status.test.ts +32 -1
- package/src/types/gitops-insights.ts +19 -2
- package/src/types/gitops-tree.ts +10 -0
- package/src/types/gitops.ts +26 -6
- package/src/utils/api-resources.ts +7 -0
- package/src/utils/application-history.ts +1 -0
- package/src/utils/cnpg-backup.test.ts +26 -0
- package/src/utils/cnpg-backup.ts +70 -0
- package/src/utils/format.test.ts +9 -0
- package/src/utils/format.ts +4 -0
- package/src/utils/gitops-owner.ts +3 -2
- package/src/utils/grant.test.ts +19 -0
- package/src/utils/grant.ts +35 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/k8s-errors.ts +7 -7
- package/src/utils/log-format.test.ts +41 -0
- package/src/utils/log-format.ts +71 -15
- package/src/utils/log-level.ts +12 -8
- package/src/utils/navigation.test.ts +57 -0
- package/src/utils/navigation.ts +86 -8
- package/src/utils/object-reference-navigation.test.ts +43 -0
- package/src/utils/resource-hierarchy.test.ts +37 -0
- package/src/utils/resource-hierarchy.ts +15 -7
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/skeleton-yaml.test.ts +21 -0
- package/src/utils/skeleton-yaml.ts +16 -0
- package/src/utils/url-path.test.ts +35 -0
- package/src/utils/url-path.ts +21 -0
- package/src/utils/validators.test.ts +4 -13
- package/src/utils/validators.ts +5 -19
- package/src/utils/view-transition.test.ts +35 -1
- package/src/utils/view-transition.ts +16 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from 'vitest'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { ProblemMeta, WorkspaceProblemCallout, ProblemList, problemOriginLabel, type WorkspaceProblem } from './problems'
|
|
4
|
+
|
|
5
|
+
const details = (kind: string) => ({
|
|
6
|
+
id: 'p',
|
|
7
|
+
tone: 'degraded' as const,
|
|
8
|
+
category: 'availability',
|
|
9
|
+
title: 'Something needs a look',
|
|
10
|
+
subject: { kind, group: 'example.io', namespace: 'ns', name: 'child-1' },
|
|
11
|
+
})
|
|
12
|
+
const problem = (kind: string): Extract<WorkspaceProblem, { source: 'issue' }> => ({ ...details(kind), source: 'issue' })
|
|
13
|
+
|
|
14
|
+
describe('ProblemMeta', () => {
|
|
15
|
+
it('names the subject only when it is not the workspace root kind', () => {
|
|
16
|
+
expect(renderToStaticMarkup(<ProblemMeta problem={problem('Backup')} rootKind="Cluster" />)).toContain('Backup')
|
|
17
|
+
expect(renderToStaticMarkup(<ProblemMeta problem={problem('Cluster')} rootKind="Cluster" />)).not.toContain('child-1')
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('problemOriginLabel', () => {
|
|
22
|
+
it('says who measured a measurement, and never calls an issue generic', () => {
|
|
23
|
+
expect(problemOriginLabel({ ...details('Cluster'), source: 'measurement', measuredBy: 'Prometheus' }).label).toBe('Measured by Prometheus')
|
|
24
|
+
expect(problemOriginLabel(problem('Cluster')).label).toBe('Detected by Radar')
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('keeps original scheduler evidence behind a closed disclosure in callouts and lists', () => {
|
|
29
|
+
const p = { ...problem('Pod'), detail: 'both nodes have reached their Pod limit', rawDetailLabel: 'Scheduler message', rawDetail: '0/2 nodes are available: 2 Too many pods.' }
|
|
30
|
+
for (const node of [<WorkspaceProblemCallout problem={p} rootKind="Cluster" />, <ProblemList problems={[p]} rootKind="Cluster" />]) {
|
|
31
|
+
const html = renderToStaticMarkup(node)
|
|
32
|
+
expect(html).toContain(p.detail)
|
|
33
|
+
expect(html).toContain(p.rawDetail)
|
|
34
|
+
expect(html).toContain('Scheduler message')
|
|
35
|
+
expect(html).toContain('aria-expanded="false"')
|
|
36
|
+
}
|
|
37
|
+
expect(renderToStaticMarkup(<WorkspaceProblemCallout problem={problem('Pod')} rootKind="Cluster" />)).not.toContain('Scheduler message')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('uses a generic label for raw evidence from another controller', () => {
|
|
41
|
+
const p = { ...problem('Deployment'), rawDetail: 'Progress deadline exceeded.' }
|
|
42
|
+
expect(renderToStaticMarkup(<WorkspaceProblemCallout problem={p} rootKind="Application" />)).toContain('Original evidence')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('keeps source-specific provenance fields exclusive in the public type', () => {
|
|
46
|
+
type Details = ReturnType<typeof details>
|
|
47
|
+
expectTypeOf<Details & { source: 'issue'; measuredBy: string }>().not.toMatchTypeOf<WorkspaceProblem>()
|
|
48
|
+
expectTypeOf<Details & { source: 'measurement'; origin: { label: string } }>().not.toMatchTypeOf<WorkspaceProblem>()
|
|
49
|
+
expectTypeOf<Details & { source: 'audit'; origin: { label: string } }>().not.toMatchTypeOf<WorkspaceProblem>()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('preserves alert and unknown tones in callouts and compact lists', () => {
|
|
53
|
+
for (const tone of ['alert', 'unknown'] as const) {
|
|
54
|
+
const p = { ...problem('Pod'), tone }
|
|
55
|
+
const callout = renderToStaticMarkup(<WorkspaceProblemCallout problem={p} rootKind="Cluster" />)
|
|
56
|
+
expect(callout).toContain(`status-${tone}`)
|
|
57
|
+
const list = renderToStaticMarkup(<ProblemList problems={[p]} rootKind="Cluster" />)
|
|
58
|
+
expect(list).toContain(tone === 'alert' ? 'bg-orange-500' : 'bg-slate-400')
|
|
59
|
+
expect(list).toContain(tone === 'alert' ? 'text-orange-600' : 'text-theme-text-tertiary')
|
|
60
|
+
}
|
|
61
|
+
type HealthyProblem = Omit<ReturnType<typeof details>, 'tone'> & { source: 'issue'; tone: 'healthy' }
|
|
62
|
+
expectTypeOf<HealthyProblem>().not.toMatchTypeOf<WorkspaceProblem>()
|
|
63
|
+
})
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import type { HealthLevel } from '../resources/resource-utils'
|
|
4
|
+
import type { ResourceRef } from '../../types/core'
|
|
5
|
+
import { healthToSeverity } from '../../utils/badge-colors'
|
|
6
|
+
import { StatusDot, toneTextClass } from '../ui/status-tone'
|
|
7
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
8
|
+
import { AlertBanner } from '../ui/drawer-components'
|
|
9
|
+
import { FoldSection } from '../ui/FoldSection'
|
|
10
|
+
import { RefLink, type NavigateToRef } from '../ui/RefLink'
|
|
11
|
+
|
|
12
|
+
/** Where a problem's evidence comes from, in user terms. */
|
|
13
|
+
export interface ProblemOrigin {
|
|
14
|
+
label: string
|
|
15
|
+
/** The exact field or condition, shown on hover. */
|
|
16
|
+
detail?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Something about a workspace object that needs a look. `C` is the
|
|
21
|
+
* integration's own category set.
|
|
22
|
+
*/
|
|
23
|
+
export interface ProblemDetails<C extends string = string> {
|
|
24
|
+
/** Stable identity for keys. */
|
|
25
|
+
id: string
|
|
26
|
+
category: C
|
|
27
|
+
title: string
|
|
28
|
+
detail?: string
|
|
29
|
+
rawDetail?: string
|
|
30
|
+
rawDetailLabel?: string
|
|
31
|
+
/** The object the evidence is about: the workspace's root object or one of its children. */
|
|
32
|
+
subject: ResourceRef & { group: string }
|
|
33
|
+
/** A shorter headline for tight places (a fleet cell); `title` stays the precise one. */
|
|
34
|
+
shortTitle?: string
|
|
35
|
+
/** Other objects the same problem is about, e.g. earlier runs that failed the same way. */
|
|
36
|
+
alsoAbout?: Pick<ResourceRef, 'kind' | 'name'>[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ProblemProvenance =
|
|
40
|
+
| { source: 'issue'; origin?: ProblemOrigin; measuredBy?: never; sourceDetail?: never }
|
|
41
|
+
| { source: 'audit'; sourceDetail?: string; origin?: never; measuredBy?: never }
|
|
42
|
+
| { source: 'measurement'; measuredBy?: string; sourceDetail?: string; origin?: never }
|
|
43
|
+
|
|
44
|
+
export type WorkspaceProblem<C extends string = string> = ProblemDetails<C> & ProblemProvenance & { tone: Exclude<HealthLevel, 'healthy'> }
|
|
45
|
+
|
|
46
|
+
/** A problem's provenance label: where its evidence comes from, never a generic "Radar issue". */
|
|
47
|
+
export function problemOriginLabel(problem: WorkspaceProblem): ProblemOrigin {
|
|
48
|
+
switch (problem.source) {
|
|
49
|
+
case 'audit':
|
|
50
|
+
return { label: 'Best-practice check', detail: problem.sourceDetail }
|
|
51
|
+
case 'measurement':
|
|
52
|
+
return { label: problem.measuredBy ? `Measured by ${problem.measuredBy}` : 'Measured', detail: problem.sourceDetail }
|
|
53
|
+
}
|
|
54
|
+
return problem.origin ?? { label: 'Detected by Radar' }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* How a host opens a problem on its Issues page. Supplied by context so every
|
|
59
|
+
* summary and drawer gets the link without threading a prop through each.
|
|
60
|
+
*/
|
|
61
|
+
export const OpenIssueContext = createContext<((problem: WorkspaceProblem) => void) | undefined>(undefined)
|
|
62
|
+
|
|
63
|
+
export function ProblemMeta({
|
|
64
|
+
problem,
|
|
65
|
+
rootKind,
|
|
66
|
+
onNavigate,
|
|
67
|
+
subjectIsSelf,
|
|
68
|
+
children,
|
|
69
|
+
}: {
|
|
70
|
+
problem: WorkspaceProblem
|
|
71
|
+
/** The workspace's root kind: a problem about another kind names its subject. */
|
|
72
|
+
rootKind: string
|
|
73
|
+
onNavigate?: NavigateToRef
|
|
74
|
+
subjectIsSelf?: boolean
|
|
75
|
+
children?: ReactNode
|
|
76
|
+
}) {
|
|
77
|
+
const openIssue = useContext(OpenIssueContext)
|
|
78
|
+
const origin = problemOriginLabel(problem)
|
|
79
|
+
const aboutChild = !subjectIsSelf && problem.subject.kind !== rootKind
|
|
80
|
+
return (
|
|
81
|
+
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-theme-text-tertiary">
|
|
82
|
+
{aboutChild && (
|
|
83
|
+
<span>
|
|
84
|
+
{problem.subject.kind}{' '}
|
|
85
|
+
<RefLink refTo={problem.subject} onNavigate={onNavigate} mono />
|
|
86
|
+
{problem.alsoAbout && problem.alsoAbout.length > 0 && ' '}
|
|
87
|
+
{problem.alsoAbout && problem.alsoAbout.length > 0 && (
|
|
88
|
+
<Tooltip
|
|
89
|
+
content={
|
|
90
|
+
<ul>
|
|
91
|
+
{problem.alsoAbout.map((o) => (
|
|
92
|
+
<li key={`${o.kind}/${o.name}`} className="font-mono">
|
|
93
|
+
{o.kind} {o.name}
|
|
94
|
+
</li>
|
|
95
|
+
))}
|
|
96
|
+
</ul>
|
|
97
|
+
}
|
|
98
|
+
>
|
|
99
|
+
<span>and {problem.alsoAbout.length} more</span>
|
|
100
|
+
</Tooltip>
|
|
101
|
+
)}
|
|
102
|
+
</span>
|
|
103
|
+
)}
|
|
104
|
+
<Tooltip content={origin.detail} disabled={!origin.detail}>
|
|
105
|
+
<span>{origin.label}</span>
|
|
106
|
+
</Tooltip>
|
|
107
|
+
{openIssue && problem.source === 'issue' && (
|
|
108
|
+
<button type="button" onClick={() => openIssue(problem)} className="whitespace-nowrap text-accent-text hover:underline">
|
|
109
|
+
See in Issues →
|
|
110
|
+
</button>
|
|
111
|
+
)}
|
|
112
|
+
{children}
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function WorkspaceProblemCallout({
|
|
118
|
+
problem,
|
|
119
|
+
rootKind,
|
|
120
|
+
more,
|
|
121
|
+
onNavigate,
|
|
122
|
+
action,
|
|
123
|
+
subjectIsSelf,
|
|
124
|
+
}: {
|
|
125
|
+
problem: WorkspaceProblem
|
|
126
|
+
rootKind: string
|
|
127
|
+
more?: ReactNode
|
|
128
|
+
onNavigate?: NavigateToRef
|
|
129
|
+
action?: ReactNode
|
|
130
|
+
/** The callout sits on the subject's own page, so linking to it would loop. */
|
|
131
|
+
subjectIsSelf?: boolean
|
|
132
|
+
}) {
|
|
133
|
+
const severity = healthToSeverity(problem.tone)
|
|
134
|
+
const variant = severity === 'neutral' ? 'unknown' : severity
|
|
135
|
+
return (
|
|
136
|
+
<AlertBanner variant={variant} title={problem.title} message={problem.detail}>
|
|
137
|
+
{problem.rawDetail && <FoldSection title={problem.rawDetailLabel ?? "Original evidence"} summary="" attention={false}><div className="break-words text-xs text-theme-text-secondary">{problem.rawDetail}</div></FoldSection>}
|
|
138
|
+
<ProblemMeta problem={problem} rootKind={rootKind} onNavigate={onNavigate} subjectIsSelf={subjectIsSelf}>
|
|
139
|
+
{action}
|
|
140
|
+
{more}
|
|
141
|
+
</ProblemMeta>
|
|
142
|
+
</AlertBanner>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The problems a callout does not show, as a compact list with the callout's tone, title and source. */
|
|
147
|
+
export function ProblemList({ problems, rootKind, onNavigate }: { problems: WorkspaceProblem[]; rootKind: string; onNavigate?: NavigateToRef }) {
|
|
148
|
+
return (
|
|
149
|
+
<ul className="space-y-2">
|
|
150
|
+
{problems.map((p) => (
|
|
151
|
+
<li key={p.id} className="flex items-start gap-2 text-sm">
|
|
152
|
+
<span className="mt-1.5 shrink-0">
|
|
153
|
+
<StatusDot tone={p.tone} size="sm" />
|
|
154
|
+
</span>
|
|
155
|
+
<div className="min-w-0">
|
|
156
|
+
<div className={clsx('font-medium break-words', toneTextClass(p.tone))}>{p.title}</div>
|
|
157
|
+
{p.detail && <div className="text-xs text-theme-text-secondary break-words">{p.detail}</div>}
|
|
158
|
+
{p.rawDetail && <FoldSection title={p.rawDetailLabel ?? "Original evidence"} summary="" attention={false}><div className="break-words text-xs text-theme-text-secondary">{p.rawDetail}</div></FoldSection>}
|
|
159
|
+
<ProblemMeta problem={p} rootKind={rootKind} onNavigate={onNavigate} />
|
|
160
|
+
</div>
|
|
161
|
+
</li>
|
|
162
|
+
))}
|
|
163
|
+
</ul>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { ActiveFilterBar, type ActiveFilterBarProps } from './ActiveFilterBar'
|
|
6
|
+
|
|
7
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
8
|
+
|
|
9
|
+
let root: Root | null = null
|
|
10
|
+
let element: HTMLDivElement
|
|
11
|
+
|
|
12
|
+
async function render(overrides: Partial<ActiveFilterBarProps> = {}) {
|
|
13
|
+
const props: ActiveFilterBarProps = {
|
|
14
|
+
columnFilters: {},
|
|
15
|
+
columnFilterExcludes: {},
|
|
16
|
+
problemFilters: [],
|
|
17
|
+
labelSelector: '',
|
|
18
|
+
onClearColumn: vi.fn(),
|
|
19
|
+
onClearProblems: vi.fn(),
|
|
20
|
+
onClearLabels: vi.fn(),
|
|
21
|
+
...overrides,
|
|
22
|
+
}
|
|
23
|
+
element = document.createElement('div')
|
|
24
|
+
root = createRoot(element)
|
|
25
|
+
await act(async () => root!.render(<ActiveFilterBar {...props} />))
|
|
26
|
+
return props
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buttonWithText(text: string): HTMLButtonElement {
|
|
30
|
+
const button = [...element.querySelectorAll('button')].find((b) => b.textContent?.includes(text))
|
|
31
|
+
if (!button) throw new Error(`no button containing "${text}"`)
|
|
32
|
+
return button
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await act(async () => root?.unmount())
|
|
37
|
+
root = null
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('ActiveFilterBar', () => {
|
|
41
|
+
it('renders nothing without active filters or a restore', async () => {
|
|
42
|
+
await render({ columnFilters: { status: [] } })
|
|
43
|
+
expect(element.innerHTML).toBe('')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('renders one chip per active filter, marking excluded columns', async () => {
|
|
47
|
+
await render({
|
|
48
|
+
columnFilters: { status: ['Running', 'Pending'], node: ['n1'], empty: [] },
|
|
49
|
+
columnFilterExcludes: { node: true },
|
|
50
|
+
problemFilters: ['crashloop'],
|
|
51
|
+
labelSelector: 'app=nginx',
|
|
52
|
+
})
|
|
53
|
+
expect(element.querySelectorAll('button')).toHaveLength(4)
|
|
54
|
+
expect(element.textContent).toContain('status: Running, Pending')
|
|
55
|
+
expect(element.textContent).toContain('node: not n1')
|
|
56
|
+
expect(element.textContent).toContain('Problems: crashloop')
|
|
57
|
+
expect(element.textContent).toContain('app=nginx')
|
|
58
|
+
expect(element.textContent).not.toContain('restored')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('calls the matching clear handler when a chip is clicked', async () => {
|
|
62
|
+
const props = await render({ columnFilters: { status: ['Running'] }, problemFilters: ['oom'], labelSelector: 'tier=web' })
|
|
63
|
+
await act(async () => buttonWithText('status:').click())
|
|
64
|
+
await act(async () => buttonWithText('Problems:').click())
|
|
65
|
+
await act(async () => buttonWithText('tier=web').click())
|
|
66
|
+
expect(props.onClearColumn).toHaveBeenCalledWith('status')
|
|
67
|
+
expect(props.onClearProblems).toHaveBeenCalledOnce()
|
|
68
|
+
expect(props.onClearLabels).toHaveBeenCalledOnce()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('shows the restored line only when restored, with Clear calling onClearAll', async () => {
|
|
72
|
+
const onClearAll = vi.fn()
|
|
73
|
+
await render({ restored: true, onClearAll, columnFilters: { status: ['Running'] } })
|
|
74
|
+
expect(element.textContent).toContain('Filters restored from your last visit')
|
|
75
|
+
await act(async () => buttonWithText('Clear').click())
|
|
76
|
+
expect(onClearAll).toHaveBeenCalledOnce()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { clsx } from 'clsx'
|
|
2
|
+
import { AlertTriangle, History, ListFilter, Tag, X } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
export interface ActiveFilterBarProps {
|
|
5
|
+
columnFilters: Record<string, string[]>
|
|
6
|
+
columnFilterExcludes: Record<string, boolean>
|
|
7
|
+
problemFilters: string[]
|
|
8
|
+
labelSelector: string
|
|
9
|
+
onClearColumn: (key: string) => void
|
|
10
|
+
onClearProblems: () => void
|
|
11
|
+
onClearLabels: () => void
|
|
12
|
+
restored?: boolean
|
|
13
|
+
onClearAll?: () => void
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ActiveFilterBar({
|
|
18
|
+
columnFilters,
|
|
19
|
+
columnFilterExcludes,
|
|
20
|
+
problemFilters,
|
|
21
|
+
labelSelector,
|
|
22
|
+
onClearColumn,
|
|
23
|
+
onClearProblems,
|
|
24
|
+
onClearLabels,
|
|
25
|
+
restored = false,
|
|
26
|
+
onClearAll,
|
|
27
|
+
className,
|
|
28
|
+
}: ActiveFilterBarProps) {
|
|
29
|
+
const activeColEntries = Object.entries(columnFilters).filter(([, vals]) => vals.length > 0)
|
|
30
|
+
const hasChips = activeColEntries.length > 0 || problemFilters.length > 0 || !!labelSelector
|
|
31
|
+
if (!hasChips && !restored) return null
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className={clsx('flex flex-wrap items-center gap-1.5', className)}>
|
|
35
|
+
{restored && (
|
|
36
|
+
<span className="flex items-center gap-1 text-xs text-theme-text-tertiary">
|
|
37
|
+
<History className="w-3 h-3" />
|
|
38
|
+
<span>Filters restored from your last visit.</span>
|
|
39
|
+
{onClearAll && (
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
onClick={onClearAll}
|
|
43
|
+
className="text-theme-text-secondary hover:text-theme-text-primary underline underline-offset-2"
|
|
44
|
+
>
|
|
45
|
+
Clear
|
|
46
|
+
</button>
|
|
47
|
+
)}
|
|
48
|
+
</span>
|
|
49
|
+
)}
|
|
50
|
+
{activeColEntries.map(([key, vals]) => (
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
key={key}
|
|
54
|
+
onClick={() => onClearColumn(key)}
|
|
55
|
+
className="flex items-center gap-1 px-2 py-1 text-xs selection selection-text rounded-md hover:selection-strong transition-colors"
|
|
56
|
+
>
|
|
57
|
+
<ListFilter className="w-3 h-3" />
|
|
58
|
+
<span>{key}: {columnFilterExcludes[key] ? 'not ' : ''}{vals.join(', ')}</span>
|
|
59
|
+
<X className="w-3 h-3" />
|
|
60
|
+
</button>
|
|
61
|
+
))}
|
|
62
|
+
{problemFilters.length > 0 && (
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={onClearProblems}
|
|
66
|
+
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/15 text-red-700 dark:text-red-300 rounded-md hover:bg-red-500/25 transition-colors"
|
|
67
|
+
>
|
|
68
|
+
<AlertTriangle className="w-3 h-3" />
|
|
69
|
+
<span>Problems: {problemFilters.join(', ')}</span>
|
|
70
|
+
<X className="w-3 h-3" />
|
|
71
|
+
</button>
|
|
72
|
+
)}
|
|
73
|
+
{labelSelector && (
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
onClick={onClearLabels}
|
|
77
|
+
className="flex items-center gap-1 px-2 py-1 text-xs bg-green-500/15 text-green-700 dark:text-green-300 rounded-md hover:bg-green-500/25 transition-colors"
|
|
78
|
+
>
|
|
79
|
+
<Tag className="w-3 h-3" />
|
|
80
|
+
<span>{labelSelector}</span>
|
|
81
|
+
<X className="w-3 h-3" />
|
|
82
|
+
</button>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
5
|
+
import { ResourcesSidebar } from './ResourcesSidebar'
|
|
6
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
7
|
+
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
|
|
8
|
+
let root: ReturnType<typeof createRoot>
|
|
9
|
+
let host: HTMLDivElement
|
|
10
|
+
afterEach(() => { act(() => root?.unmount()); host?.remove() })
|
|
11
|
+
it('navigates Favorites, Views and grouped kinds in their visible order', () => {
|
|
12
|
+
Element.prototype.scrollIntoView = vi.fn()
|
|
13
|
+
const view = vi.fn(), kind = vi.fn()
|
|
14
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
15
|
+
const cluster = { group: 'postgresql.cnpg.io', version: 'v1', kind: 'Cluster', name: 'clusters', namespaced: true, isCrd: true, verbs: ['list'] }
|
|
16
|
+
act(() => root.render(<ResourcesSidebar selectedKind={null} onSelectedKindChange={kind} apiResources={[cluster]} resourceCounts={{ 'postgresql.cnpg.io/Cluster': 1 }} pinned={[{ name: 'pods', kind: 'Pod', group: '' }]} categoryWorkspaces={{ CloudNativePG: { destinations: [{ id: 'overview', label: 'Clusters view', onSelect: view }], defaultKindsCollapsed: true } }} />))
|
|
17
|
+
const category = [...host.querySelectorAll('button')].find((b) => b.textContent?.includes('CloudNativePG'))!
|
|
18
|
+
if (category.getAttribute('aria-expanded') !== 'true') act(() => category.click())
|
|
19
|
+
const input = host.querySelector('input')!
|
|
20
|
+
const key = (name: string) => act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: name, bubbles: true })))
|
|
21
|
+
key('ArrowDown'); key('ArrowDown'); key('Enter')
|
|
22
|
+
expect(view).toHaveBeenCalledTimes(1)
|
|
23
|
+
expect(kind).not.toHaveBeenCalled()
|
|
24
|
+
act(() => [...host.querySelectorAll('button')].find((b) => b.textContent === 'Resource kinds')!.click())
|
|
25
|
+
key('ArrowDown'); key('ArrowDown'); key('ArrowDown'); key('Enter')
|
|
26
|
+
expect(kind).toHaveBeenCalledWith(expect.objectContaining({ name: 'clusters', group: 'postgresql.cnpg.io' }))
|
|
27
|
+
})
|
|
28
|
+
it('opens a matching View with Enter when search finds no kind', () => {
|
|
29
|
+
Element.prototype.scrollIntoView = vi.fn()
|
|
30
|
+
const view = vi.fn()
|
|
31
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
32
|
+
act(() => root.render(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={[{ group: 'postgresql.cnpg.io', version: 'v1', kind: 'Cluster', name: 'clusters', namespaced: true, isCrd: true, verbs: ['list'] }]} resourceCounts={{ 'postgresql.cnpg.io/Cluster': 1 }} categoryWorkspaces={{ CloudNativePG: { destinations: [{ id: 'operator', label: 'Operator', onSelect: view }], defaultKindsCollapsed: true } }} />))
|
|
33
|
+
const input = host.querySelector('input')!
|
|
34
|
+
act(() => {
|
|
35
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'operator')
|
|
36
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
37
|
+
})
|
|
38
|
+
expect(host.textContent).toContain('Operator')
|
|
39
|
+
expect(host.textContent).not.toContain('>Cluster<')
|
|
40
|
+
act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
|
|
41
|
+
expect(view).toHaveBeenCalledTimes(1)
|
|
42
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { renderToString } from 'react-dom/server'
|
|
3
3
|
import type { APIResource } from '../../types'
|
|
4
|
-
import { rawCRDGroupTitle, resourceMatchesSidebarFilter, ResourcesSidebar } from './ResourcesSidebar'
|
|
4
|
+
import { rawCRDGroupTitle, resourceMatchesSidebarFilter, ResourcesSidebar, sidebarDestinationsMatching } from './ResourcesSidebar'
|
|
5
5
|
|
|
6
6
|
const sqlInstance: APIResource = {
|
|
7
7
|
group: 'sql.cnrm.cloud.google.com',
|
|
@@ -144,7 +144,7 @@ describe('ResourcesSidebar category workspaces', () => {
|
|
|
144
144
|
}}
|
|
145
145
|
/>
|
|
146
146
|
)
|
|
147
|
-
expect(html).toContain('
|
|
147
|
+
expect(html).toContain('Views')
|
|
148
148
|
expect(html).toContain('Overview')
|
|
149
149
|
expect(html).toContain('pg-orders')
|
|
150
150
|
expect(html).toContain('Counts for namespace payments')
|
|
@@ -153,6 +153,21 @@ describe('ResourcesSidebar category workspaces', () => {
|
|
|
153
153
|
expect(html).not.toContain('selection-strong selection-text">Pod')
|
|
154
154
|
})
|
|
155
155
|
|
|
156
|
+
it('marks a count taken over partly readable data as a lower bound, and never shows its zero as none', () => {
|
|
157
|
+
const render = (count: number) =>
|
|
158
|
+
renderToString(
|
|
159
|
+
<ResourcesSidebar
|
|
160
|
+
selectedKind={null}
|
|
161
|
+
onSelectedKindChange={() => {}}
|
|
162
|
+
apiResources={[cnpgCluster]}
|
|
163
|
+
resourceCounts={{ 'postgresql.cnpg.io/Cluster': 1 }}
|
|
164
|
+
categoryWorkspaces={{ CloudNativePG: { destinations: [{ id: 'overview', label: 'Overview', count, countLowerBound: true, onSelect: () => {} }] } }}
|
|
165
|
+
/>,
|
|
166
|
+
)
|
|
167
|
+
expect(render(2)).toMatch(/≥(<!-- -->)?2/)
|
|
168
|
+
expect(render(0)).toContain('–')
|
|
169
|
+
})
|
|
170
|
+
|
|
156
171
|
it('keeps a workspace category visible when it has no resources', () => {
|
|
157
172
|
const html = renderToString(
|
|
158
173
|
<ResourcesSidebar
|
|
@@ -181,3 +196,22 @@ describe('ResourcesSidebar category workspaces', () => {
|
|
|
181
196
|
expect(html).toContain('barmancloud.cnpg.io')
|
|
182
197
|
})
|
|
183
198
|
})
|
|
199
|
+
|
|
200
|
+
describe('sidebarDestinationsMatching', () => {
|
|
201
|
+
const views = ['Clusters', 'Backups', 'Declarations', 'Pooling', 'Operator'].map((label) => ({ id: label, label, onSelect: () => {} }))
|
|
202
|
+
const resources = [{ group: 'postgresql.cnpg.io' }, { group: 'barmancloud.cnpg.io' }]
|
|
203
|
+
const labels = (term: string) => sidebarDestinationsMatching('CloudNativePG', resources, views, term).map((d) => d.label)
|
|
204
|
+
|
|
205
|
+
it('keeps every view when the term names the category or one of its API groups', () => {
|
|
206
|
+
expect(labels('cloud')).toEqual(['Clusters', 'Backups', 'Declarations', 'Pooling', 'Operator'])
|
|
207
|
+
expect(labels('cnpg')).toEqual(['Clusters', 'Backups', 'Declarations', 'Pooling', 'Operator'])
|
|
208
|
+
})
|
|
209
|
+
it('keeps the views whose label matches otherwise', () => {
|
|
210
|
+
expect(labels('backup')).toEqual(['Backups'])
|
|
211
|
+
expect(labels('declar')).toEqual(['Declarations'])
|
|
212
|
+
expect(labels('deployment')).toEqual([])
|
|
213
|
+
})
|
|
214
|
+
it('keeps every view without a term', () => {
|
|
215
|
+
expect(labels(' ')).toHaveLength(5)
|
|
216
|
+
})
|
|
217
|
+
})
|