@skyhook-io/k8s-ui 1.14.0 → 1.14.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 (48) hide show
  1. package/package.json +1 -1
  2. package/src/components/gitops/GitOpsTableView.tsx +168 -11
  3. package/src/components/gitops/applicationset-row.test.ts +214 -0
  4. package/src/components/gitops/detail-helpers.test.ts +54 -0
  5. package/src/components/gitops/detail-helpers.ts +13 -3
  6. package/src/components/gitops/index.ts +1 -0
  7. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +7 -0
  8. package/src/components/issues/IssuesView.tsx +1 -1
  9. package/src/components/issues/types.ts +2 -0
  10. package/src/components/resources/ResourcesView.tsx +382 -141
  11. package/src/components/resources/column-header-width.test.ts +75 -0
  12. package/src/components/resources/column-settings-merge.test.ts +104 -0
  13. package/src/components/resources/istio-gateway-filter.test.ts +5 -3
  14. package/src/components/resources/istio-policy-accessors.test.ts +193 -0
  15. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +11 -24
  16. package/src/components/resources/renderers/eso-cells.tsx +0 -5
  17. package/src/components/resources/renderers/istio-cells.tsx +36 -26
  18. package/src/components/resources/renderers/kueue-cells.tsx +0 -9
  19. package/src/components/resources/renderers/kyverno-cells.tsx +1 -1
  20. package/src/components/resources/renderers/prometheus-cells.tsx +3 -3
  21. package/src/components/resources/renderers/ray-cells.tsx +12 -4
  22. package/src/components/resources/renderers/volcano-cells.tsx +0 -9
  23. package/src/components/resources/resource-utils-cnpg.ts +5 -0
  24. package/src/components/resources/resource-utils-eso.test.ts +56 -0
  25. package/src/components/resources/resource-utils-eso.ts +37 -5
  26. package/src/components/resources/resource-utils-istio.ts +152 -44
  27. package/src/components/resources/resource-utils-kyverno.ts +3 -6
  28. package/src/components/resources/resource-utils-prometheus.ts +51 -23
  29. package/src/components/resources/resource-utils-ray.test.ts +36 -0
  30. package/src/components/resources/resource-utils-ray.ts +6 -0
  31. package/src/components/resources/resource-utils.ts +166 -18
  32. package/src/components/resources/rules-column-width.test.ts +42 -0
  33. package/src/components/resources/status-accessors.test.ts +363 -0
  34. package/src/components/resources/status-overclaim.test.ts +158 -0
  35. package/src/components/shared/ResourceRendererDispatch.test.tsx +75 -0
  36. package/src/components/shared/ResourceRendererDispatch.tsx +24 -6
  37. package/src/components/timeline/DiffViewer.test.tsx +26 -0
  38. package/src/components/timeline/DiffViewer.tsx +16 -12
  39. package/src/components/topology/GroupNode.tsx +6 -2
  40. package/src/components/topology/TopologyGraph.tsx +7 -2
  41. package/src/components/topology/topology.css +4 -1
  42. package/src/components/ui/Collapse.tsx +18 -3
  43. package/src/components/ui/ConfirmDialog.test.tsx +37 -0
  44. package/src/components/ui/ConfirmDialog.tsx +3 -1
  45. package/src/components/ui/RowActionMenu.tsx +4 -0
  46. package/src/components/ui/drawer-components.tsx +5 -3
  47. package/src/components/workload/WorkloadView.tsx +11 -3
  48. package/src/types/gitops.ts +51 -0
@@ -203,6 +203,12 @@ export function getRayCronJobSchedule(resource: any): string {
203
203
  return resource.spec?.schedule || '-'
204
204
  }
205
205
 
206
+ // An unset timeZone does not mean UTC: KubeRay reads the schedule in the
207
+ // operator pod's own local zone, which this object cannot report.
208
+ export function getRayCronJobTimeZone(resource: any): string {
209
+ return resource.spec?.timeZone || 'Operator local'
210
+ }
211
+
206
212
  export function getRayCronJobSuspend(resource: any): boolean {
207
213
  return resource.spec?.suspend === true
208
214
  }
@@ -15,7 +15,7 @@ import { getPolicyReportStatus as _getPolicyReportStatus, getKyvernoPolicyStatus
15
15
  import { getResourceClaimStatus as _getResourceClaimStatus, getResourceClaimDeviceClasses as _getResourceClaimDeviceClasses, getResourceClaimTemplateDeviceClasses as _getResourceClaimTemplateDeviceClasses, getResourceClaimAllocation as _getResourceClaimAllocation, getResourceClaimReservedFor as _getResourceClaimReservedFor } from './resource-utils-dra'
16
16
  import { getNvidiaClusterPolicyStatus as _getNvidiaClusterPolicyStatus, getNvidiaClusterPolicyEnabledComponents as _getNvidiaClusterPolicyEnabledComponents, getNvidiaDriverStatus as _getNvidiaDriverStatus } from './resource-utils-nvidia'
17
17
  import { getClusterQueueStatus as _getClusterQueueStatus, getLocalQueueStatus as _getLocalQueueStatus, getKueueWorkloadStatus as _getKueueWorkloadStatus, getResourceFlavorStatus as _getResourceFlavorStatus, getAdmissionCheckStatus as _getAdmissionCheckStatus, getProvisioningRequestStatus as _getProvisioningRequestStatus } from './resource-utils-kueue'
18
- import { getRayClusterStatus as _getRayClusterStatus, getRayJobStatus as _getRayJobStatus, getRayServiceStatus as _getRayServiceStatus, getRayCronJobStatus as _getRayCronJobStatus } from './resource-utils-ray'
18
+ import { getRayClusterStatus as _getRayClusterStatus, getRayJobStatus as _getRayJobStatus, getRayServiceStatus as _getRayServiceStatus, getRayCronJobStatus as _getRayCronJobStatus, getRayCronJobTimeZone as _getRayCronJobTimeZone } from './resource-utils-ray'
19
19
  import { getLeaderWorkerSetStatus as _getLeaderWorkerSetStatus, getJobSetStatus as _getJobSetStatus } from './resource-utils-jobset-lws'
20
20
  import { getInferenceServiceStatus as _getInferenceServiceStatus, getServingRuntimeStatus as _getServingRuntimeStatus, getInferenceGraphStatus as _getInferenceGraphStatus, getTrainedModelStatus as _getTrainedModelStatus, getLLMInferenceServiceStatus as _getLLMInferenceServiceStatus } from './resource-utils-kserve'
21
21
  import { getInferencePoolStatus as _getInferencePoolStatus, getInferenceObjectiveStatus as _getInferenceObjectiveStatus } from './resource-utils-inference-gateway'
@@ -30,8 +30,8 @@ import { getExternalSecretStatus as _getExternalSecretStatus, getClusterExternal
30
30
  import { getHPATableState, hpaStatusFromState } from './resource-utils-hpa'
31
31
  import { getCNPGClusterStatus as _getCNPGClusterStatus, getCNPGBackupStatus as _getCNPGBackupStatus, getCNPGScheduledBackupStatus as _getCNPGScheduledBackupStatus, getCNPGPoolerStatus as _getCNPGPoolerStatus, isApiGroup as _isApiGroup, CNPG_GROUP as _CNPG_GROUP } from './resource-utils-cnpg'
32
32
  import { getGenericResourceStatus } from './generic-status'
33
- import { getIstioGatewayStatus as _getIstioGatewayStatus, getIstioGatewayServerCount as _getIstioGatewayServerCount, getIstioGatewaySelectorString as _getIstioGatewaySelectorString } from './resource-utils-istio'
34
- import { getCalicoIPPoolAllowedUses, getCalicoIPPoolBlockSize, getCalicoIPPoolEncapsulation, getCalicoPolicyNamespaceSelector, getCalicoPolicyServiceAccountSelector, getCalicoPolicyTypes, isCalicoApiVersion, isCalicoPolicyResource } from './resource-utils-calico'
33
+ import { getIstioGatewayStatus as _getIstioGatewayStatus, getIstioGatewayServerCount as _getIstioGatewayServerCount, getIstioGatewaySelectorString as _getIstioGatewaySelectorString, getAuthorizationPolicySelectorString as _getAuthorizationPolicySelectorString, getDestinationRuleTlsMode as _getDestinationRuleTlsMode } from './resource-utils-istio'
34
+ import { formatKubernetesLabelSelector, getCalicoIPPoolAllowedUses, getCalicoIPPoolBlockSize, getCalicoIPPoolEncapsulation, getCalicoPolicyNamespaceSelector, getCalicoPolicyServiceAccountSelector, getCalicoPolicyTypes, isCalicoApiVersion, isCalicoPolicyResource } from './resource-utils-calico'
35
35
 
36
36
  // ============================================================================
37
37
  // STATUS & HEALTH UTILITIES
@@ -1861,21 +1861,145 @@ export function getGatewayClassDescription(gc: any): string {
1861
1861
  // GATEWAY API ROUTE UTILITIES (shared by HTTPRoute, GRPCRoute, TCPRoute, TLSRoute)
1862
1862
  // ============================================================================
1863
1863
 
1864
+ const GATEWAY_API_GROUP = 'gateway.networking.k8s.io'
1865
+
1866
+ /**
1867
+ * Whether a status report describes a parent the spec still asks for.
1868
+ *
1869
+ * A controller can leave a report behind after its parentRef is removed, and
1870
+ * the defaults matter: an unset group/kind/namespace on either side means
1871
+ * gateway.networking.k8s.io, Gateway, and the route's own namespace.
1872
+ */
1873
+ function isReportForParentRef(ref: any, reported: any, routeNamespace: string): boolean {
1874
+ const norm = (r: any) => ({
1875
+ group: r?.group ?? GATEWAY_API_GROUP,
1876
+ kind: r?.kind ?? 'Gateway',
1877
+ namespace: r?.namespace ?? routeNamespace,
1878
+ name: r?.name,
1879
+ sectionName: r?.sectionName,
1880
+ port: r?.port,
1881
+ })
1882
+ const a = norm(ref)
1883
+ const b = norm(reported)
1884
+ return a.group === b.group && a.kind === b.kind && a.namespace === b.namespace &&
1885
+ a.name === b.name && a.sectionName === b.sectionName && a.port === b.port
1886
+ }
1887
+
1864
1888
  // All Gateway API route types share the same status/parents/rules/hostnames structure
1889
+ /**
1890
+ * A route's verdict, from BOTH conditions the API guarantees it carries.
1891
+ *
1892
+ * Accepted alone is not health: a backendRef naming a Service that does not
1893
+ * exist is Accepted=True by design, because the gateway still has to answer the
1894
+ * request — with a 5xx. Reading only Accepted therefore reported the "why is my
1895
+ * route 503ing" case as healthy, beside a Backends column naming the Service
1896
+ * that is missing. An ABSENT ResolvedRefs is not healthy either: GEP-1364 has
1897
+ * routes always carry both, so a missing one means nothing has confirmed the
1898
+ * refs yet.
1899
+ */
1900
+ /**
1901
+ * The reports that describe parents this route is actually attached to, plus
1902
+ * the readers the verdict uses. Shared with getRouteStatusReason so the badge
1903
+ * and its explanation can never disagree about which parents counted.
1904
+ */
1905
+ function routeParentEvidence(route: any) {
1906
+ const refs = route.spec?.parentRefs || []
1907
+ const reports = route.status?.parents || []
1908
+ const routeNamespace = route.metadata?.namespace || ''
1909
+ const generation = route.metadata?.generation
1910
+
1911
+ // Status entries are identified by parentRef AND controllerName, so one
1912
+ // parent can carry reports from two controllers while one replaces the other.
1913
+ // All of them count: reading a single report would make the verdict depend on
1914
+ // their order in the array.
1915
+ const reportsFor = (ref: any) =>
1916
+ reports.filter((p: any) => isReportForParentRef(ref, p?.parentRef, routeNamespace))
1917
+ const perRef = refs.map(reportsFor)
1918
+
1919
+ // A route can also attach to default Gateways without naming them, in which
1920
+ // case the attachment exists only in status. Those reports are real and their
1921
+ // failures must count. Without that opt-in an unmatched report is a leftover
1922
+ // from a parentRef the spec no longer names, and counting it would resurrect
1923
+ // a verdict for a parent the route has left.
1924
+ const useDefaultGateways = route.spec?.useDefaultGateways
1925
+ const attachedByDefault = useDefaultGateways !== undefined && useDefaultGateways !== 'None'
1926
+ const claimed = new Set(perRef.flat())
1927
+ // Only a Gateway can be a default parent. An unmatched report for anything
1928
+ // else — a mesh Service parent the spec has dropped — is obsolete by
1929
+ // definition, and would otherwise keep voting forever if its controller is
1930
+ // gone.
1931
+ const isDefaultGatewayReport = (p: any) =>
1932
+ (p?.parentRef?.group ?? GATEWAY_API_GROUP) === GATEWAY_API_GROUP &&
1933
+ (p?.parentRef?.kind ?? 'Gateway') === 'Gateway'
1934
+ const considered = attachedByDefault
1935
+ ? [...perRef.flat(), ...reports.filter((p: any) => !claimed.has(p) && isDefaultGatewayReport(p))]
1936
+ : perRef.flat()
1937
+
1938
+ // A condition observed against a superseded generation describes a spec that
1939
+ // has since changed, so it cannot confirm the current one. A condition with no
1940
+ // observedGeneration at all is taken at face value.
1941
+ const isCurrent = (c: any) =>
1942
+ c?.observedGeneration === undefined || generation === undefined || c.observedGeneration === generation
1943
+ const conditionOf = (report: any, type: string) =>
1944
+ (report?.conditions || []).filter((c: any) => c?.type === type && isCurrent(c)).pop()
1945
+ const statusOf = (report: any, type: string) => conditionOf(report, type)?.status
1946
+
1947
+ // A report whose conditions all describe a superseded spec says nothing about
1948
+ // the current one, either way — so it neither votes nor confirms the parent it
1949
+ // belongs to. Requiring it to agree would let one leftover from a replaced
1950
+ // controller hold a live healthy parent at Pending; counting it as
1951
+ // confirmation would let the other parents speak for one nothing has
1952
+ // confirmed.
1953
+ const speaksToCurrentSpec = (r: any) =>
1954
+ conditionOf(r, 'Accepted') !== undefined || conditionOf(r, 'ResolvedRefs') !== undefined
1955
+ const current = considered.filter(speaksToCurrentSpec)
1956
+ const everyRefConfirmed = perRef.every((list: any[]) => list.some(speaksToCurrentSpec))
1957
+
1958
+ return { considered, current, everyRefConfirmed, conditionOf, statusOf }
1959
+ }
1960
+
1865
1961
  export function getRouteStatus(route: any): StatusBadge {
1866
- const parents = route.status?.parents || []
1867
- if (parents.length === 0) return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
1868
- const allAccepted = parents.every((p: any) =>
1869
- (p.conditions || []).some((c: any) => c.type === 'Accepted' && c.status === 'True')
1870
- )
1871
- const anyRejected = parents.some((p: any) =>
1872
- (p.conditions || []).some((c: any) => c.type === 'Accepted' && c.status === 'False')
1873
- )
1874
- if (allAccepted) return { text: 'Accepted', color: healthColors.healthy, level: 'healthy' }
1875
- if (anyRejected) return { text: 'Not Accepted', color: healthColors.unhealthy, level: 'unhealthy' }
1962
+ const { considered, current, everyRefConfirmed, statusOf } = routeParentEvidence(route)
1963
+
1964
+ if (considered.length === 0) return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
1965
+ if (current.length === 0) return { text: 'Pending', color: healthColors.degraded, level: 'degraded' }
1966
+ const anyFailure = current.some((r: any) =>
1967
+ statusOf(r, 'Accepted') === 'False' || statusOf(r, 'ResolvedRefs') === 'False')
1968
+
1969
+ if (everyRefConfirmed && current.every((r: any) => statusOf(r, 'Accepted') === 'False')) {
1970
+ return { text: 'Not Accepted', color: healthColors.unhealthy, level: 'unhealthy' }
1971
+ }
1972
+ if (anyFailure) {
1973
+ return { text: 'Degraded', color: healthColors.degraded, level: 'degraded' }
1974
+ }
1975
+ if (everyRefConfirmed && current.every((r: any) =>
1976
+ statusOf(r, 'Accepted') === 'True' && statusOf(r, 'ResolvedRefs') === 'True')) {
1977
+ return { text: 'Accepted', color: healthColors.healthy, level: 'healthy' }
1978
+ }
1876
1979
  return { text: 'Pending', color: healthColors.degraded, level: 'degraded' }
1877
1980
  }
1878
1981
 
1982
+ /**
1983
+ * Why the badge says what it says: which parent is unhappy, and the controller's
1984
+ * own reason. Without it "Degraded" tells an operator something is wrong and
1985
+ * nothing about what — and the whole point of reading ResolvedRefs is that the
1986
+ * answer is usually "the backend you named does not exist".
1987
+ */
1988
+ export function getRouteStatusReason(route: any): string {
1989
+ const { current, conditionOf } = routeParentEvidence(route)
1990
+ const parts: string[] = []
1991
+ for (const report of current) {
1992
+ const name = report?.parentRef?.name || 'parent'
1993
+ for (const type of ['Accepted', 'ResolvedRefs']) {
1994
+ const c = conditionOf(report, type)
1995
+ if (c?.status !== 'False') continue
1996
+ const detail = [c.reason, c.message].filter(Boolean).join(': ')
1997
+ parts.push(detail ? `${name}: ${detail}` : `${name}: ${type} is false`)
1998
+ }
1999
+ }
2000
+ return parts.join(' · ')
2001
+ }
2002
+
1879
2003
  export function getRouteParents(route: any): string {
1880
2004
  const refs = route.spec?.parentRefs || []
1881
2005
  return refs.map((r: any) => r.name).join(', ') || '-'
@@ -1954,10 +2078,24 @@ export function getNetworkPolicyRuleCount(np: any): { ingress: number; egress: n
1954
2078
  }
1955
2079
  }
1956
2080
 
2081
+ /**
2082
+ * Who the policy applies to.
2083
+ *
2084
+ * podSelector is a LabelSelector, so it can target a subset through
2085
+ * matchExpressions alone. Reading only matchLabels reported those policies as
2086
+ * covering the whole namespace — a gap rendered as coverage, on the surface
2087
+ * where that reads as "this namespace is protected, look elsewhere".
2088
+ */
1957
2089
  export function getNetworkPolicySelector(np: any): string {
1958
- const labels = np.spec?.podSelector?.matchLabels
1959
- if (!labels || Object.keys(labels).length === 0) return 'All pods'
1960
- return Object.entries(labels).map(([k, v]) => `${k}=${v}`).join(', ')
2090
+ const selector = np.spec?.podSelector
2091
+ const hasLabels = Object.keys(selector?.matchLabels ?? {}).length > 0
2092
+ // Count only what the formatter will actually render: it skips malformed
2093
+ // entries, and an expression list of nothing but those would otherwise reach
2094
+ // its empty-case string and describe the same state in different words.
2095
+ const hasExpressions = (Array.isArray(selector?.matchExpressions) ? selector.matchExpressions : [])
2096
+ .some((e: any) => e && typeof e === 'object')
2097
+ if (!hasLabels && !hasExpressions) return 'All pods'
2098
+ return formatKubernetesLabelSelector(selector)
1961
2099
  }
1962
2100
 
1963
2101
  // ============================================================================
@@ -2232,7 +2370,6 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
2232
2370
  if (kindLower === 'clusterqueues') return _getClusterQueueStatus(resource).text
2233
2371
  if (kindLower === 'localqueues') return _getLocalQueueStatus(resource).text
2234
2372
  if (kindLower === 'workloads') return _getKueueWorkloadStatus(resource).text
2235
- if (kindLower === 'resourceflavors') return _getResourceFlavorStatus(resource).text
2236
2373
  if (kindLower === 'admissionchecks') return _getAdmissionCheckStatus(resource).text
2237
2374
  if (kindLower === 'provisioningrequests') return _getProvisioningRequestStatus(resource).text
2238
2375
  if (kindLower === 'rayclusters') return _getRayClusterStatus(resource).text
@@ -2252,7 +2389,6 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
2252
2389
  if (kindLower === 'volcanoqueues') return _getVolcanoQueueStatus(resource).text
2253
2390
  if (kindLower === 'volcanopodgroups') return _getVolcanoPodGroupStatus(resource).text
2254
2391
  if (kindLower === 'jobflows') return _getJobFlowStatus(resource).text
2255
- if (kindLower === 'jobtemplates') return _getJobTemplateStatus(resource).text
2256
2392
  if (kindLower === 'kaiqueues') return _getKaiQueueStatus(resource).text
2257
2393
  if (kindLower === 'kaipodgroups') return _getKaiPodGroupStatus(resource).text
2258
2394
  if (kindLower === 'kaitoworkspaces') return _getKaitoWorkspaceStatus(resource).text
@@ -2320,6 +2456,13 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
2320
2456
  return ''
2321
2457
  case 'selector':
2322
2458
  if (kindLower === 'istiogateways') return _getIstioGatewaySelectorString(resource)
2459
+ if (kindLower === 'authorizationpolicies') return _getAuthorizationPolicySelectorString(resource)
2460
+ return ''
2461
+ case 'tlsMode':
2462
+ // Distinct from Traefik's boolean 'tls' key, which SKIP_FILTER_COLUMNS
2463
+ // excludes — sharing it would have made this reader unreachable. The
2464
+ // generic fallback cannot reach spec.trafficPolicy.tls.mode.
2465
+ if (kindLower === 'destinationrules') return _getDestinationRuleTlsMode(resource)
2323
2466
  return ''
2324
2467
  case 'state':
2325
2468
  if (kindLower === 'orders') return getOrderState(resource).text
@@ -2451,6 +2594,11 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
2451
2594
  case 'mig':
2452
2595
  if (kindLower === 'nvidiaclusterpolicies') return resource.spec?.mig?.strategy || ''
2453
2596
  break
2597
+ // The fallback would read spec.timeZone and yield '' for an undeclared
2598
+ // zone, so those rows would drop out of a filter the cell shows a value for.
2599
+ case 'timeZone':
2600
+ if (kindLower === 'raycronjobs') return _getRayCronJobTimeZone(resource)
2601
+ break
2454
2602
  }
2455
2603
 
2456
2604
  // Fallback: try common paths
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFileSync } from 'fs'
3
+ import { join } from 'path'
4
+
5
+ // Parsed from the source rather than exported, matching
6
+ // curated-column-ownership.test.ts: this asserts a property of the data as it
7
+ // is written, and an export would let the two drift.
8
+ const SRC = readFileSync(join(__dirname, 'ResourcesView.tsx'), 'utf8')
9
+
10
+ function columnSetBody(key: string): string {
11
+ const start = SRC.match(new RegExp(`^ ${key}: \\[`, 'm'))
12
+ if (!start || start.index === undefined) throw new Error(`${key} not found in KNOWN_COLUMNS`)
13
+ const open = SRC.indexOf('[', start.index)
14
+ let depth = 1
15
+ let i = open + 1
16
+ while (depth > 0) {
17
+ if (SRC[i] === '[') depth++
18
+ else if (SRC[i] === ']') depth--
19
+ i++
20
+ }
21
+ return SRC.slice(open + 1, i)
22
+ }
23
+
24
+ // The header cell is px-4, so w-16 (64px) leaves 31px for the label while
25
+ // uppercase "RULES" needs ~37px — it clips to "RUL…" unconditionally, with no
26
+ // sort or filter button involved: 'rules' is in SKIP_FILTER_COLUMNS and is not
27
+ // in the sortable-key list, so neither affordance ever renders on it. w-20
28
+ // (80px) leaves 47px.
29
+ describe('Rules column width', () => {
30
+ const kinds = ['kyvernopolicies', 'clusterpolicies', 'httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes']
31
+
32
+ it.each(kinds)('%s Rules column is at least w-20', (kind) => {
33
+ const rules = columnSetBody(kind).match(/\{ key: 'rules'.*\}/)
34
+ expect(rules, `${kind} has no rules column`).not.toBeNull()
35
+ const width = rules![0].match(/width: 'w-(\d+)'/)
36
+ expect(width, `${kind} rules column has no fixed w-* width`).not.toBeNull()
37
+ // Compared numerically, not pinned to w-20, so the wider table-width pass
38
+ // tracked in #1583 can widen these without tripping a test that only
39
+ // claims a floor.
40
+ expect(Number(width![1])).toBeGreaterThanOrEqual(20)
41
+ })
42
+ })