@skyhook-io/k8s-ui 1.10.4 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/package.json +5 -5
  2. package/src/components/applications/ApplicationDetail.test.tsx +46 -0
  3. package/src/components/applications/ApplicationDetail.tsx +14 -15
  4. package/src/components/issues/IssuesView.tsx +9 -0
  5. package/src/components/issues/diagnostic.ts +3 -0
  6. package/src/components/issues/issues.test.ts +11 -0
  7. package/src/components/issues/severity.ts +3 -0
  8. package/src/components/issues/types.ts +3 -0
  9. package/src/components/resources/ResourcesSidebar.tsx +1 -1
  10. package/src/components/resources/ResourcesView.tsx +259 -30
  11. package/src/components/resources/index.ts +2 -0
  12. package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
  13. package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
  14. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
  15. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
  16. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
  17. package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
  18. package/src/components/resources/renderers/EventRenderer.tsx +10 -18
  19. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
  20. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
  21. package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
  22. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
  23. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
  24. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
  25. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
  26. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
  27. package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
  28. package/src/components/resources/renderers/index.ts +4 -0
  29. package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
  30. package/src/components/resources/renderers/velero-cells.tsx +128 -9
  31. package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
  32. package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
  33. package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
  34. package/src/components/resources/resource-utils-cnpg.ts +373 -56
  35. package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
  36. package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
  37. package/src/components/resources/resource-utils-velero.test.ts +237 -0
  38. package/src/components/resources/resource-utils-velero.ts +214 -22
  39. package/src/components/resources/resource-utils.ts +70 -3
  40. package/src/components/shared/ResourceActionsBar.tsx +3 -1
  41. package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
  42. package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
  43. package/src/components/topology/K8sResourceNode.tsx +11 -0
  44. package/src/components/topology/TopologyGraph.tsx +85 -5
  45. package/src/components/trace/ReachabilityGraph.tsx +517 -0
  46. package/src/components/trace/ReachabilityView.tsx +1173 -0
  47. package/src/components/trace/TracePanel.test.ts +172 -0
  48. package/src/components/trace/TracePanel.tsx +558 -0
  49. package/src/components/trace/TraceSummary.test.ts +213 -0
  50. package/src/components/trace/TraceSummary.tsx +168 -0
  51. package/src/components/trace/inClusterSummary.test.ts +98 -0
  52. package/src/components/trace/inClusterSummary.ts +81 -0
  53. package/src/components/trace/index.ts +19 -0
  54. package/src/components/trace/podReach.test.ts +71 -0
  55. package/src/components/trace/podReach.ts +37 -0
  56. package/src/components/trace/probe-display.test.ts +107 -0
  57. package/src/components/trace/probe-display.ts +37 -0
  58. package/src/components/trace/problemRows.test.ts +107 -0
  59. package/src/components/trace/reachFixtures.ts +261 -0
  60. package/src/components/trace/reachGraphModel.test.ts +1423 -0
  61. package/src/components/trace/reachGraphModel.ts +1521 -0
  62. package/src/components/trace/reachInspector.test.ts +892 -0
  63. package/src/components/trace/reachInspector.ts +797 -0
  64. package/src/components/trace/reachMarks.test.ts +698 -0
  65. package/src/components/trace/reachMarks.ts +623 -0
  66. package/src/components/trace/reachOrigins.test.ts +392 -0
  67. package/src/components/trace/reachOrigins.ts +449 -0
  68. package/src/components/trace/reachVerdict.test.ts +537 -0
  69. package/src/components/trace/reachVerdict.ts +601 -0
  70. package/src/components/trace/traceFingerprint.test.ts +214 -0
  71. package/src/components/trace/traceFingerprint.ts +122 -0
  72. package/src/components/trace/traceToSubgraph.test.ts +623 -0
  73. package/src/components/trace/traceToSubgraph.ts +463 -0
  74. package/src/components/trace/types.ts +414 -0
  75. package/src/components/ui/Badge.tsx +36 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
  77. package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
  78. package/src/components/ui/InClusterConsentDialog.tsx +141 -0
  79. package/src/components/ui/index.ts +1 -0
  80. package/src/components/workload/WorkloadView.tsx +58 -5
  81. package/src/components/workload/index.ts +1 -1
  82. package/src/components/workload/reachabilityTab.test.ts +47 -0
  83. package/src/index.ts +5 -0
  84. package/src/theme/components.css +64 -0
  85. package/src/theme/variables.css +5 -0
  86. package/src/types/core.ts +8 -0
  87. package/src/utils/api-resources.ts +2 -0
  88. package/src/utils/application-history.test.ts +1 -1
  89. package/src/utils/applications.test.ts +63 -1
  90. package/src/utils/applications.ts +27 -0
  91. package/src/utils/inClusterConsent.test.ts +109 -0
  92. package/src/utils/inClusterConsent.ts +72 -0
  93. package/src/utils/index.ts +1 -0
  94. package/src/utils/navigation.test.ts +61 -0
  95. package/src/utils/navigation.ts +11 -9
  96. package/src/utils/pluralize.test.ts +80 -1
  97. package/src/utils/pluralize.ts +34 -0
  98. package/src/utils/resource-icons.test.ts +33 -0
  99. package/src/utils/resource-icons.ts +65 -2
@@ -0,0 +1,892 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildSidebar, buildVerdict } from './reachInspector'
3
+ import { buildGraph } from './reachGraphModel'
4
+ import { buildOrigins } from './reachOrigins'
5
+ import type { Trace, RouteResult, ProbeResult, PodStatus } from './types'
6
+
7
+ const p = (o: Partial<ProbeResult>): ProbeResult => ({ layer: 'http', target: '10.0.0.1:8080', vantage: 'in-cluster', path: 'data', ok: true, ...o })
8
+ const pod = (name: string, ready: boolean, ip: string, reason?: string): PodStatus => ({ name, ready, ip, reason })
9
+
10
+ function mk(pods: PodStatus[], probes: ProbeResult[]): Trace {
11
+ return {
12
+ subject: { kind: 'Service', name: 'shop', namespace: 'store' },
13
+ verdict: 'healthy',
14
+ brokenAt: -1,
15
+ upstreams: [],
16
+ // The producer attaches a concrete request to every probed route; without
17
+ // one the server has nothing to send, so a fixture that omits it is not a
18
+ // realistic trace.
19
+ routes: [{ route: 'GET /', target: 'shop:80', outcome: 'verified', confidence: 'real', inClusterRequest: { scheme: 'http', path: '/' } }],
20
+ downstream: [
21
+ { resource: { kind: 'Service', name: 'shop', namespace: 'store' }, edge: 'service', findings: [], config: { clusterIP: '10.96.0.1', selector: { app: 'shop' } } },
22
+ {
23
+ resource: { kind: 'Pods', name: '', namespace: 'store' },
24
+ edge: 'service->pods',
25
+ findings: [],
26
+ meta: { ready: pods.filter((x) => x.ready).length, selected: pods.length },
27
+ config: { pods, podTotal: pods.length },
28
+ probes,
29
+ },
30
+ ],
31
+ }
32
+ }
33
+
34
+ const route = (o: Partial<RouteResult> = {}): RouteResult => ({ route: 'GET /', target: ':80 → 8080', outcome: 'verified', confidence: 'real', ...o })
35
+
36
+ // Pods nodes are parent-scoped so multi-backend routes keep a group each, so
37
+ // tests address them by role rather than by a fixed id.
38
+ function podsNodeId(t: Trace): string {
39
+ const g = buildGraph({ trace: t, route: route(), origin: buildOrigins(t).find((o) => o.id === 'incluster')! })
40
+ return g.nodes.find((n) => n.kind === 'PODS')!.id
41
+ }
42
+ function podsEdgeId(t: Trace): string {
43
+ const g = buildGraph({ trace: t, route: route(), origin: buildOrigins(t).find((o) => o.id === 'incluster')! })
44
+ return g.edges.find((e) => e.label === 'selects')!.id
45
+ }
46
+
47
+ function ctx(t: Trace, originId: string, r = route()) {
48
+ const origins = buildOrigins(t)
49
+ const origin = origins.find((o) => o.id === originId)!
50
+ const g = buildGraph({ trace: t, route: r, origin })
51
+ return {
52
+ trace: t,
53
+ route: r,
54
+ origin,
55
+ origins,
56
+ nodes: g.nodes,
57
+ breakNodeId: g.breakNodeId,
58
+ breakAtExitOf: g.breakAtExitOf,
59
+ nonNetworkNodeIds: g.nonNetworkNodeIds,
60
+ contextNodeIds: g.contextNodeIds,
61
+ interleave: g.interleave,
62
+ entryParallelCount: g.entryParallelCount,
63
+ journeyEntryNodeIds: g.journeyEntryNodeIds,
64
+ pathNodeIds: g.pathNodeIds,
65
+ }
66
+ }
67
+
68
+ describe('the diagnosis is always present', () => {
69
+ // The reason the tab exists is "did traffic get through". That must never
70
+ // require a click, so the path section is computed regardless of selection.
71
+ it('answers the path question with nothing selected', () => {
72
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
73
+ const s = buildSidebar(undefined, ctx(t, 'incluster'))
74
+ expect(s.path.title).toBeTruthy()
75
+ expect(s.path.evidence.length).toBeGreaterThan(0)
76
+ // The panel always answers the path question; a next-step block is offered
77
+ // only when there is genuinely a next step.
78
+ expect(s.path.body).toBeTruthy()
79
+ expect(s.resource).toBeUndefined()
80
+ })
81
+
82
+ // Every hop is reported whether or not anything was clicked; a selection only
83
+ // changes which one is open. Understanding a path used to take three clicks.
84
+ it('reports the whole path without any selection, and a click only opens one', () => {
85
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
86
+ const c = ctx(t, 'incluster')
87
+ const before = buildSidebar(undefined, c)
88
+ expect(before.hops.map((h) => h.kind)).toContain('PODS')
89
+ const after = buildSidebar(podsNodeId(t), c)
90
+ expect(after.path).toEqual(before.path)
91
+ expect(after.hops.find((h) => h.kind === 'PODS')!.expanded).toBe(true)
92
+ })
93
+
94
+ it('an apiserver result always states what it skipped', () => {
95
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
96
+ const s = buildSidebar(undefined, ctx(t, 'apiserver'))
97
+ expect(s.path.notProve.join(' ')).toMatch(/relayed|routing|network policy|mesh/i)
98
+ })
99
+
100
+ it('a synthetic result always states the identity gap', () => {
101
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
102
+ const s = buildSidebar(undefined, ctx(t, 'incluster'))
103
+ expect(s.path.notProve.join(' ')).toMatch(/not as your application|who is calling/i)
104
+ })
105
+
106
+ it('never claims complete evidence', () => {
107
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
108
+ expect(buildSidebar(undefined, ctx(t, 'incluster')).path.notProve.length).toBeGreaterThan(0)
109
+ })
110
+
111
+ it('does not claim a front-door gap on a resource with no entry point', () => {
112
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
113
+ expect(t.upstreams).toHaveLength(0)
114
+ expect(buildSidebar(undefined, ctx(t, 'incluster')).path.notProve.join(' ')).not.toMatch(/internet|outside/i)
115
+ })
116
+
117
+ it('offers only actions that can be taken', () => {
118
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
119
+ const s = buildSidebar(undefined, ctx(t, 'apiserver'))
120
+ expect(s.path.next.ctas.every((c) => !c.disabledReason)).toBe(true)
121
+ expect(s.path.next.body).toMatch(/in-cluster/i)
122
+ })
123
+
124
+ it('the RBAC prompt names the permission and copies a command that runs', () => {
125
+ // Every other vantage must be spent, or offering one of those would be the
126
+ // better next step than asking for permission.
127
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' }), p({ vantage: 'local', path: 'data' })])
128
+ const origins = buildOrigins(t, { inClusterAllowed: false })
129
+ const g = buildGraph({ trace: t, route: route(), origin: origins.find((o) => o.id === 'apiserver')! })
130
+ const s = buildSidebar(undefined, { trace: t, route: route(), origin: origins.find((o) => o.id === 'apiserver')!, origins, nodes: g.nodes })
131
+ expect(s.path.next.body).toMatch(/create.*jobs/i)
132
+ expect(s.path.next.ctas[0].command).toMatch(/kubectl auth can-i/)
133
+ })
134
+ })
135
+
136
+ // The graph already gates on this; the sidebar is the surface people actually
137
+ // read, so it must gate identically or it states another vantage's result under
138
+ // the selected vantage's name.
139
+ describe('the sidebar is scoped to the selected vantage', () => {
140
+ it('an unavailable vantage never inherits another vantage\'s success', () => {
141
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
142
+ const origins = buildOrigins(t)
143
+ const caller = origins.find((o) => o.id === 'caller')!
144
+ const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: caller })
145
+ const s = buildSidebar(undefined, { trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: caller, origins, nodes: g.nodes })
146
+ expect(s.path.body).not.toMatch(/a real request went through/i)
147
+ expect(s.path.evidence.every((e) => e.mark !== 'proved')).toBe(true)
148
+ expect(s.path.evidence.map((e) => e.text).join(' ')).toMatch(/cannot test from here/i)
149
+ })
150
+
151
+ it('a vantage that did run still reports its result', () => {
152
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
153
+ const s = buildSidebar(undefined, ctx(t, 'incluster'))
154
+ expect(s.path.body).toMatch(/a real request went through/i)
155
+ })
156
+
157
+ it('a stale result does not lead with its old assertion', () => {
158
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
159
+ t.headline = 'Reachable in-cluster on :80'
160
+ const v = buildVerdict(t, route(), { stale: true })
161
+ expect(v.title).not.toMatch(/Reachable/)
162
+ expect(v.title).toMatch(/out of date/i)
163
+ })
164
+ })
165
+
166
+ describe('node detail is additive', () => {
167
+ it('a Pods node reports what is and is not taking traffic', () => {
168
+ const t = mk([pod('a', true, '10.0.0.1'), pod('b', false, '10.0.0.2', 'readiness failing')], [p({})])
169
+ const r = buildSidebar(podsNodeId(t), ctx(t, 'incluster')).hops.find((h) => h.kind === 'PODS')!
170
+ expect(r.facts.find((x) => x.k === 'SITTING OUT')!.v).toMatch(/not ready/)
171
+ // Derived from readiness, so it must not claim observed delivery.
172
+ expect(r.facts.some((x) => x.k === 'ELIGIBLE')).toBe(true)
173
+ expect(r.facts.some((x) => x.k === 'TAKING TRAFFIC')).toBe(false)
174
+ expect(r.notProve.join(' ')).toMatch(/nothing was sent to them/)
175
+ expect(r.rows!.some((x) => x.mark === 'excluded')).toBe(true)
176
+ })
177
+
178
+ it('a resource node carries its own config, not the path result', () => {
179
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
180
+ const c = ctx(t, 'incluster')
181
+ const svc = c.nodes.find((n) => n.kind === 'SERVICE')!
182
+ const r = buildSidebar(svc.id, c).hops.find((h) => h.id === svc.id)!
183
+ expect(r.facts.some((x) => x.k === 'CLUSTER IP')).toBe(true)
184
+ expect(r.openRef?.name).toBe('shop')
185
+ })
186
+
187
+ // A vantage is not a hop on the path - it is where the path was watched from.
188
+ it('the origin capsule is never a hop in the report', () => {
189
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
190
+ const c = ctx(t, 'incluster')
191
+ const cap = c.nodes.find((n) => n.isOrigin)!
192
+ expect(buildSidebar(cap.id, c).hops.some((h) => h.id === cap.id)).toBe(false)
193
+ })
194
+ })
195
+
196
+ describe('verdict band', () => {
197
+ // These rules used to be asserted through the header's fact strip. The strip
198
+ // is gone - every one of its four facts is now shown by the graph or the
199
+ // panel - so each rule is pinned where it actually renders.
200
+ it('reports nothing proven when no origin has run', () => {
201
+ const t = mk([pod('a', true, '10.0.0.1')], [])
202
+ const v = buildVerdict(t, route({ outcome: 'not-tested' }))
203
+ expect(v.tone).toBe('unknown')
204
+ expect(v.chipText).toMatch(/not tested/i)
205
+ })
206
+
207
+ it('an apiserver-only pass never claims the backend was proven', () => {
208
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
209
+ const v = buildVerdict(t, route({ confidence: 'indirect' }))
210
+ expect(v.tone).toBe('degraded')
211
+ const s = buildSidebar(undefined, ctx(t, 'apiserver', route({ confidence: 'indirect' })))
212
+ // It may say something is SERVING; it must also say that is not the same as
213
+ // the normal path working.
214
+ expect(s.path.body).toMatch(/serving/i)
215
+ expect(s.path.body).toMatch(/not that the normal path works/i)
216
+ })
217
+
218
+ it('points the next step at something Radar can actually do', () => {
219
+ // The real caller is the strongest missing origin AND permanently
220
+ // unavailable. Offering it would give every resource the same
221
+ // un-actionable next step.
222
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
223
+ const s = buildSidebar(undefined, ctx(t, 'apiserver', route({ confidence: 'indirect' })))
224
+ expect(s.path.next.body).toMatch(/in-cluster probe/i)
225
+ expect(s.path.next.header).toMatch(/run this next/i)
226
+ })
227
+
228
+ it('offers nothing when nothing stronger can be run', () => {
229
+ // A resource-level "there is no more to learn" repeated on every vantage was
230
+ // scope mixing; the ceiling is stated with specifics in the caveats and the
231
+ // footer's coverage ledger instead.
232
+ const t = mk([pod('a', true, '10.0.0.1')], [p({}), p({ path: 'apiserver' }), p({ vantage: 'local', path: 'data' })])
233
+ const s = buildSidebar(undefined, ctx(t, 'incluster'))
234
+ expect(s.path.next.header).toBe('')
235
+ expect(s.path.next.ctas).toHaveLength(0)
236
+ })
237
+
238
+ it('falls back to the backend verdict when there is no route to derive a tone from', () => {
239
+ // A config fault found without probing still has a verdict; a grey
240
+ // "unknown" dot there under-reports what the tracer already concluded.
241
+ const t = mk([pod('a', true, '10.0.0.1')], [])
242
+ t.verdict = 'degraded'
243
+ t.routes = []
244
+ const v = buildVerdict(t, undefined, buildOrigins(t))
245
+ expect(v.tone).toBe('degraded')
246
+ })
247
+
248
+ it('leads with the named fault rather than burying it under a coverage headline', () => {
249
+ const t = mk([pod('a', true, '10.0.0.1')], [])
250
+ t.headline = 'Configuration only - not yet tested'
251
+ t.diagnosis = { summary: 'Accepted: NoMatchingListenerHostname - no hostname intersections' }
252
+ const v = buildVerdict(t, undefined, buildOrigins(t))
253
+ expect(v.problem).toMatch(/NoMatchingListenerHostname/)
254
+ // and it is not duplicated into the body
255
+ expect(v.body).toBe('')
256
+ })
257
+
258
+ it('a running test is informational, never green', () => {
259
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
260
+ const v = buildVerdict(t, route(), { running: true })
261
+ expect(v.tone).toBe('info')
262
+ expect(v.chipText).toBe('testing')
263
+ })
264
+ })
265
+
266
+ describe('an in-cluster test that could not run says so first', () => {
267
+ // A route whose probes were all skipped never gets a concrete request, so the
268
+ // server answers "not supported for this subject". The panel used to
269
+ // recommend the run as the strongest evidence available and let the operator
270
+ // discover the failure by spending a Job on it.
271
+ const noRunnableRoute = (): Trace => {
272
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
273
+ t.routes = [{ route: 'port 7000', target: 'shop:7000', outcome: 'not-tested', evidence: 'port looks non-HTTP' }]
274
+ return t
275
+ }
276
+
277
+ // The button moved to the vantage capsule in the graph, so the panel must not
278
+ // offer a third copy of it - it keeps the reasoning instead.
279
+ it('offers the run, disabled, with the reason attached', () => {
280
+ // The action lives WITH its explanation now, so it is present even when it
281
+ // cannot be taken - but never silently clickable into a guaranteed no-op.
282
+ const s = buildSidebar(undefined, ctx(noRunnableRoute(), 'apiserver'))
283
+ const run = s.path.next.ctas.find((c) => c.action === 'run-in-cluster')
284
+ expect(run).toBeTruthy()
285
+ expect(run!.disabledReason).toMatch(/nothing to run|no path/i)
286
+ })
287
+
288
+ it('explains it in the body rather than promising stronger evidence', () => {
289
+ const s = buildSidebar(undefined, ctx(noRunnableRoute(), 'apiserver'))
290
+ expect(s.path.next.body).not.toMatch(/strongest evidence/i)
291
+ expect(s.path.next.body).toMatch(/skipped before a request could be formed/i)
292
+ })
293
+
294
+ it('still explains the run is available when a route carries a request', () => {
295
+ const s = buildSidebar(undefined, ctx(mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })]), 'apiserver'))
296
+ expect(s.path.next.body).toMatch(/strongest evidence/i)
297
+ })
298
+ })
299
+
300
+ describe('the report is ordered around the break', () => {
301
+ // The break is the answer, so it opens; what came before it worked and is
302
+ // context; what came after was never tried and must not read as healthy.
303
+ const brokenTrace = () => mk([pod('a', true, '10.0.0.1')], [p({ ok: false, tone: 'unhealthy' })])
304
+ const brokenRoute = () => route({ outcome: 'unreachable', confidence: 'real', evidence: 'connection refused' })
305
+
306
+ it('opens the hop where the request stopped, not the last one', () => {
307
+ const t = brokenTrace()
308
+ const c = ctx(t, 'incluster', brokenRoute())
309
+ const s = buildSidebar(undefined, c)
310
+ expect(c.breakNodeId).toBeDefined()
311
+ const open = s.hops.filter((h) => h.expanded)
312
+ expect(open).toHaveLength(1)
313
+ expect(open[0].id).toBe(c.breakNodeId)
314
+ expect(open[0].state).toBe('break')
315
+ })
316
+
317
+ it('opens exactly one hop when nothing is selected', () => {
318
+ const s = buildSidebar(undefined, ctx(mk([pod('a', true, '10.0.0.1')], [p({})]), 'incluster'))
319
+ expect(s.hops.filter((h) => h.expanded)).toHaveLength(1)
320
+ })
321
+
322
+ it('labels hops past the break as never tried, not as fine', () => {
323
+ const t = brokenTrace()
324
+ const c = ctx(t, 'incluster', brokenRoute())
325
+ const s = buildSidebar(undefined, c)
326
+ expect(c.breakNodeId).toBeDefined()
327
+ const after = s.hops.slice(s.hops.findIndex((h) => h.id === c.breakNodeId) + 1)
328
+ for (const h of after) expect(h.state).toBe('after')
329
+ })
330
+
331
+ it('keeps hops in path order', () => {
332
+ const s = buildSidebar(undefined, ctx(mk([pod('a', true, '10.0.0.1')], [p({})]), 'incluster'))
333
+ expect(s.hops.map((h) => h.kind)).toEqual(['SERVICE', 'PODS'])
334
+ })
335
+ })
336
+
337
+ describe('a derived break says which KIND it is', () => {
338
+ const derived = (basis: 'declared-config' | 'cluster-state'): RouteResult => ({
339
+ route: 'GET /', target: 'web:80', outcome: 'unreachable', basis,
340
+ })
341
+
342
+ // Calling "no ready endpoints" a configuration failure sends the reader to
343
+ // edit YAML when the fix is to get Pods running.
344
+ it('does not call a cluster-state break a configuration failure', () => {
345
+ const t = mk([pod('a', true, '10.0.0.1')], [])
346
+ const s = buildSidebar(undefined, ctx(t, 'incluster', derived('cluster-state')))
347
+ expect(s.path.body).not.toMatch(/configuration/i)
348
+ expect(s.path.body).toMatch(/ready to serve/i)
349
+ expect(s.path.body).toMatch(/changes when the workload does/i)
350
+ })
351
+
352
+ it('still calls a declared-config break a configuration failure', () => {
353
+ const t = mk([pod('a', true, '10.0.0.1')], [])
354
+ const s = buildSidebar(undefined, ctx(t, 'incluster', derived('declared-config')))
355
+ expect(s.path.body).toMatch(/configuration itself is broken/i)
356
+ })
357
+
358
+ // Both are DERIVED: neither was dialled, so neither may read as an
359
+ // observation of the selected vantage.
360
+ it('attributes neither to the selected vantage', () => {
361
+ const t = mk([pod('a', true, '10.0.0.1')], [])
362
+ for (const b of ['declared-config', 'cluster-state'] as const) {
363
+ const s = buildSidebar(undefined, ctx(t, 'local', derived(b)))
364
+ expect(s.path.body).not.toMatch(/from here/i)
365
+ }
366
+ })
367
+ })
368
+
369
+ describe('the verdict band follows the selected origin', () => {
370
+ // The band sits directly above the inspector, which reads the SELECTED
371
+ // origin's own result. On the merged rollup the two contradicted each other
372
+ // in adjacent panes - "could not get through" over "got through" - on exactly
373
+ // the disagreeing traces per-vantage evidence exists to represent. This seam
374
+ // regressed once already and had no pin.
375
+ const disagreeing = (): RouteResult =>
376
+ route({
377
+ outcome: 'unreachable',
378
+ confidence: 'real',
379
+ byVantage: [
380
+ { vantage: 'local', path: 'data', outcome: 'unreachable' },
381
+ { vantage: 'in-cluster', path: 'data', outcome: 'verified', confidence: 'real' },
382
+ ],
383
+ })
384
+
385
+ it('shows the origin-scoped result, not the rollup, and names its scope', () => {
386
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
387
+ const v = buildVerdict(t, disagreeing(), { originId: 'incluster', originName: 'In-cluster probe' })
388
+ expect(v.tone).not.toBe('unhealthy')
389
+ expect(v.chipText).toBe('got through')
390
+ expect(v.chipScope).toContain('from In-cluster probe')
391
+ })
392
+
393
+ it('an origin absent from a produced breakdown reads not tested, never the rollup chip', () => {
394
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
395
+ const only = route({
396
+ outcome: 'verified',
397
+ confidence: 'real',
398
+ byVantage: [{ vantage: 'in-cluster', path: 'data', outcome: 'verified', confidence: 'real' }],
399
+ })
400
+ const v = buildVerdict(t, only, { originId: 'local', originName: 'Radar on your machine' })
401
+ expect(v.chipText).toBe('not tested')
402
+ })
403
+ })
404
+
405
+ describe('localization facts belong to the vantage that produced them', () => {
406
+ // route.localization rows are apiserver-relay observations (direct-pod dials
407
+ // past the entry point). Crediting them to the in-cluster origin would claim
408
+ // observations that vantage never made.
409
+ const localized = (): RouteResult =>
410
+ route({
411
+ outcome: 'verified',
412
+ confidence: 'indirect',
413
+ byVantage: [{ vantage: 'local', path: 'apiserver', outcome: 'verified' }],
414
+ localization: [{ layer: 'tcp', ok: true, detail: 'Pods answered directly, Service did not' }],
415
+ })
416
+
417
+ it('shows them under the apiserver origin', () => {
418
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ vantage: 'local', path: 'apiserver' })])
419
+ const s = buildSidebar(undefined, ctx(t, 'apiserver', localized()))
420
+ const texts = s.path.evidence.map((e) => e.text).join('\n')
421
+ expect(texts).toMatch(/checked directly, past the entry point/)
422
+ })
423
+
424
+ it('never credits them to the in-cluster origin', () => {
425
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
426
+ const s = buildSidebar(undefined, ctx(t, 'incluster', localized()))
427
+ const texts = s.path.evidence.map((e) => e.text).join('\n')
428
+ expect(texts).not.toMatch(/checked directly/)
429
+ })
430
+ })
431
+
432
+
433
+ describe('a Service-routing boundary in the sidebar', () => {
434
+ const brokenRouting = (): RouteResult =>
435
+ route({
436
+ outcome: 'unreachable',
437
+ confidence: 'real',
438
+ byVantage: [{ vantage: 'in-cluster', path: 'data', outcome: 'unreachable', failedBoundary: 'service-routing' }],
439
+ })
440
+ const withWorkload = () => {
441
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
442
+ t.downstream![1].config!.workload = { kind: 'Deployment', name: 'web', namespace: 'store' }
443
+ return t
444
+ }
445
+
446
+ it('the SERVICE hop carries the break, exit-phrased', () => {
447
+ const s = buildSidebar(undefined, ctx(withWorkload(), 'incluster', brokenRouting()))
448
+ const svc = s.hops.find((h) => h.kind.startsWith('SERVICE'))!
449
+ expect(svc.state).toBe('break')
450
+ expect(svc.chipText).toMatch(/just past here/)
451
+ })
452
+
453
+ it('the workload hop is never a journey participant', () => {
454
+ const s = buildSidebar(undefined, ctx(withWorkload(), 'incluster', brokenRouting()))
455
+ const wl = s.hops.find((h) => h.kind.startsWith('DEPLOYMENT'))!
456
+ expect(wl.state).toBe('plain')
457
+ })
458
+ })
459
+
460
+ describe('bypassed and parallel entries are context, not journey', () => {
461
+ const withEntries = (n = 1): Trace => {
462
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
463
+ t.upstreams = Array.from({ length: n }, (_, i) => ({
464
+ resource: { kind: 'Ingress', name: `web-${i}`, namespace: 'store' },
465
+ edge: 'ingress->service',
466
+ findings: [],
467
+ config: { hostnames: [`h${i}.example.com`], addresses: ['34.0.0.1'] },
468
+ }))
469
+ return t
470
+ }
471
+
472
+ it('the apiserver vantage does not list entries as its path', () => {
473
+ const s = buildSidebar(undefined, ctx(withEntries(), 'apiserver'))
474
+ expect(s.hops.some((h) => h.kind.startsWith('INGRESS'))).toBe(false)
475
+ expect(s.context?.hops.some((h) => h.kind.startsWith('INGRESS'))).toBe(true)
476
+ expect(s.context?.label).toMatch(/RELAYED/)
477
+ })
478
+
479
+ it('the laptop vantage keeps entries on the path', () => {
480
+ const s = buildSidebar(undefined, ctx(withEntries(), 'local'))
481
+ expect(s.hops.some((h) => h.kind.startsWith('INGRESS'))).toBe(true)
482
+ expect(s.context).toBeUndefined()
483
+ })
484
+
485
+ it('two entries the route cannot be pinned to read as one parallel stage, never a sequence', () => {
486
+ const s = buildSidebar(undefined, ctx(withEntries(2), 'local'))
487
+ const entries = s.hops.filter((h) => h.kind.startsWith('INGRESS'))
488
+ expect(entries).toHaveLength(2)
489
+ for (const e of entries) expect(e.parallelCount).toBe(2)
490
+ })
491
+ })
492
+
493
+ describe("'testing' is scoped to the vantage that is testing", () => {
494
+ // The Job runs from the in-cluster vantage. Starting it while the LAPTOP is
495
+ // selected must not flip the laptop-scoped band or sidebar to "testing" -
496
+ // that is one vantage's state under another's name, in motion.
497
+ it('the verdict band for a non-running origin never says testing', () => {
498
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
499
+ // The view computes running && origin.id === 'incluster'; for the laptop
500
+ // that is false even mid-run.
501
+ const v = buildVerdict(t, route(), { running: false, originId: 'local', originName: 'Radar on your machine' })
502
+ expect(v.chipText).not.toBe('testing')
503
+ })
504
+
505
+ it('the sidebar for a non-running origin never says testing now', () => {
506
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ vantage: 'local', path: 'data' })])
507
+ const c = ctx(t, 'local')
508
+ const s = buildSidebar(undefined, { ...c, running: false })
509
+ expect(JSON.stringify(s.path)).not.toMatch(/testing now/)
510
+ })
511
+ })
512
+
513
+ describe('a break at one parallel entry orders nothing', () => {
514
+ // Two entries with no host attribution stay in the journey as one parallel
515
+ // stage. A failed dial of entry A must not make sibling B read "before the
516
+ // break", nor the Service behind them "never tried - something earlier
517
+ // stopped": the request may have gone through B.
518
+ it('siblings and downstream hops read plain, never before/after', () => {
519
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
520
+ t.upstreams = [
521
+ {
522
+ resource: { kind: 'Ingress', name: 'entry-a', namespace: 'store' },
523
+ edge: 'ingress->service',
524
+ findings: [],
525
+ config: { addresses: ['34.0.0.1'] },
526
+ probes: [{ layer: 'tcp', target: '34.0.0.1:443', vantage: 'local', path: 'data', ok: false, tone: 'unhealthy' }],
527
+ },
528
+ {
529
+ resource: { kind: 'Ingress', name: 'entry-b', namespace: 'store' },
530
+ edge: 'ingress->service',
531
+ findings: [],
532
+ config: { addresses: ['34.0.0.2'] },
533
+ probes: [{ layer: 'http', target: '34.0.0.2:443', vantage: 'local', path: 'data', ok: true, tone: 'healthy' }],
534
+ },
535
+ ]
536
+ const s = buildSidebar(undefined, ctx(t, 'local'))
537
+ const a = s.hops.find((h) => h.name === 'entry-a')
538
+ const b = s.hops.find((h) => h.name === 'entry-b')
539
+ const svc = s.hops.find((h) => h.kind.startsWith('SERVICE'))
540
+ if (a?.state === 'break') {
541
+ expect(b?.state).toBe('plain')
542
+ expect(svc?.state).toBe('plain')
543
+ } else {
544
+ // No break anchored at the stage in this fixture shape - the serial
545
+ // machine must still not be claiming order across parallel entries.
546
+ expect(b?.state).not.toBe('before')
547
+ expect(svc?.state).not.toBe('after')
548
+ }
549
+ })
550
+ })
551
+
552
+ describe('a skipped route says why, from the exact vantage that skipped it', () => {
553
+ const reason = "HTTPS backend - the API-server proxy speaks plain HTTP and can't verify TLS on this port. Test it directly."
554
+ const skipped = (target = 'shop:443'): RouteResult =>
555
+ route({ target, outcome: 'not-tested', confidence: undefined, evidence: reason, byVantage: undefined })
556
+ const mkSkipped = (skipProbe: Partial<ProbeResult>, runVantage: 'local' | 'in-cluster' = 'local'): Trace => {
557
+ const t = mk([pod('shop-1', true, '10.0.0.1')], [])
558
+ t.verdict = 'unknown'
559
+ t.runVantage = runVantage
560
+ t.coverage = { tested: 0, passed: 0, failed: 0, skipped: 1 }
561
+ t.routes = [skipped()]
562
+ t.downstream![0].probes = [p({ target: 'shop:443', port: 443, skipped: true, reason, ok: false, ...skipProbe })]
563
+ return t
564
+ }
565
+ const proxySkip = { vantage: 'local' as const, path: 'apiserver' as const }
566
+
567
+ it('surfaces the skip reason under the origin whose dial was skipped', () => {
568
+ const s = buildSidebar(undefined, ctx(mkSkipped(proxySkip), 'apiserver', skipped()))
569
+ expect(s.path.evidence.map((e) => e.text).join('\n')).toContain('API-server proxy speaks plain HTTP')
570
+ })
571
+
572
+ it('keeps the proxy reason under the proxy even when the run came from inside the cluster', () => {
573
+ const s = buildSidebar(undefined, ctx(mkSkipped(proxySkip, 'in-cluster'), 'apiserver', skipped()))
574
+ expect(s.path.evidence.map((e) => e.text).join('\n')).toContain('API-server proxy speaks plain HTTP')
575
+ })
576
+
577
+ it('never charges a vantage whose own dial was not the one skipped', () => {
578
+ const t = mkSkipped(proxySkip)
579
+ for (const id of ['local', 'incluster']) {
580
+ const texts = buildSidebar(undefined, ctx(t, id, skipped())).path.evidence.map((e) => e.text).join('\n')
581
+ expect(texts).not.toContain('speaks plain HTTP')
582
+ }
583
+ })
584
+
585
+ it("never borrows another port's reason", () => {
586
+ const t = mkSkipped({ ...proxySkip, port: 80, target: 'shop:80' })
587
+ const texts = buildSidebar(undefined, ctx(t, 'apiserver', skipped('shop:443'))).path.evidence.map((e) => e.text).join('\n')
588
+ expect(texts).not.toContain('API-server proxy')
589
+ })
590
+
591
+ it('a portless skip speaks for every port', () => {
592
+ const t = mkSkipped({ ...proxySkip, port: undefined })
593
+ const texts = buildSidebar(undefined, ctx(t, 'apiserver', skipped())).path.evidence.map((e) => e.text).join('\n')
594
+ expect(texts).toContain('API-server proxy speaks plain HTTP')
595
+ })
596
+
597
+ it('says out loud that dots are health, not results, when nothing anywhere was tested', () => {
598
+ const s = buildSidebar(undefined, ctx(mkSkipped(proxySkip), 'apiserver', skipped()))
599
+ expect(s.path.body).toContain('cluster state, not a test result')
600
+ })
601
+ })
602
+
603
+ describe('an attempted in-cluster run that never started keeps its attempt', () => {
604
+ it('WHAT WE SAW carries the execution error, not "no test has been run"', () => {
605
+ const t = mk([pod('shop-1', true, '10.0.0.1')], [])
606
+ const r = route({ outcome: 'not-tested', confidence: undefined, byVantage: undefined })
607
+ t.routes = [r]
608
+ const origins = buildOrigins(t, { inClusterRunError: 'the probe image could not be pulled' })
609
+ const origin = origins.find((o) => o.id === 'incluster')!
610
+ const g = buildGraph({ trace: t, route: r, origin })
611
+ const s = buildSidebar(undefined, {
612
+ trace: t, route: r, origin, origins,
613
+ nodes: g.nodes, breakNodeId: g.breakNodeId, breakAtExitOf: g.breakAtExitOf,
614
+ nonNetworkNodeIds: g.nonNetworkNodeIds, contextNodeIds: g.contextNodeIds, interleave: g.interleave,
615
+ entryParallelCount: g.entryParallelCount, journeyEntryNodeIds: g.journeyEntryNodeIds, pathNodeIds: g.pathNodeIds,
616
+ })
617
+ const texts = s.path.evidence.map((e) => e.text).join('\n')
618
+ expect(texts).toContain('image could not be pulled')
619
+ expect(texts).not.toContain('no test has been run from here')
620
+ })
621
+ })
622
+
623
+ describe('a vantage that cannot be used explains itself in WHAT WE SAW', () => {
624
+ it('the laptop with nothing to dial shows the structural reason, not "no test has been run"', () => {
625
+ const t = mk([pod('a', true, '10.0.0.1')], [])
626
+ t.verdict = 'unknown'
627
+ t.routes = [route({ outcome: 'not-tested', confidence: undefined, byVantage: undefined })]
628
+ t.downstream![0].probes = [p({ target: 'shop:80', port: 80, vantage: 'local', path: 'apiserver', skipped: true, reason: 'proxy said no', ok: false })]
629
+ const s = buildSidebar(undefined, ctx(t, 'local', t.routes[0]))
630
+ const texts = s.path.evidence.map((e) => e.text).join('\n')
631
+ expect(texts).toMatch(/no entry point .* from your machine|no Ingress/i)
632
+ expect(texts).not.toContain('no test has been run from here')
633
+ })
634
+ })
635
+
636
+ describe('a proxy-only failure never claims the target answered', () => {
637
+ it('the body states the relayed dial failed and the real path stays untested', () => {
638
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver', ok: false, tone: 'unhealthy' })])
639
+ const r = route({ outcome: 'unreachable', confidence: 'indirect' })
640
+ t.routes = [r]
641
+ const s = buildSidebar(undefined, ctx(t, 'apiserver', r))
642
+ expect(s.path.body).toMatch(/relayed dial failed/i)
643
+ expect(s.path.body).not.toMatch(/target answered/i)
644
+ })
645
+ })
646
+
647
+ describe('nothing to suggest means nothing shown', () => {
648
+ it('renders no next-step block at all when there is no stronger test', () => {
649
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
650
+ const s = buildSidebar(undefined, ctx(t, 'incluster'))
651
+ expect(s.path.next.header).toBe('')
652
+ expect(s.path.next.body).toBe('')
653
+ expect(s.path.next.ctas).toHaveLength(0)
654
+ })
655
+ })
656
+
657
+ describe('a fresh failed attempt outranks leftover skip rows', () => {
658
+ it("WHAT WE SAW shows this run's execution error, not last run's skip", () => {
659
+ const t = mk([pod('a', true, '10.0.0.1')], [])
660
+ const r = route({ target: 'shop:443', outcome: 'not-tested', confidence: undefined, byVantage: undefined })
661
+ t.routes = [r]
662
+ t.downstream![1].probes = [
663
+ p({ target: '10.0.0.1:8080', port: 8080, vantage: 'in-cluster', path: 'data', source: 'probe-job', skipped: true, reason: 'stale skip from the previous run', ok: false }),
664
+ ]
665
+ const origins = buildOrigins(t, { inClusterRunError: 'the probe image could not be pulled', route: r })
666
+ const origin = origins.find((o) => o.id === 'incluster')!
667
+ const g = buildGraph({ trace: t, route: r, origin })
668
+ const s = buildSidebar(undefined, {
669
+ trace: t, route: r, origin, origins,
670
+ nodes: g.nodes, breakNodeId: g.breakNodeId, breakAtExitOf: g.breakAtExitOf,
671
+ nonNetworkNodeIds: g.nonNetworkNodeIds, contextNodeIds: g.contextNodeIds, interleave: g.interleave,
672
+ entryParallelCount: g.entryParallelCount, journeyEntryNodeIds: g.journeyEntryNodeIds, pathNodeIds: g.pathNodeIds,
673
+ })
674
+ const texts = s.path.evidence.map((e) => e.text).join('\n')
675
+ expect(texts).toContain('image could not be pulled')
676
+ expect(texts).not.toContain('stale skip')
677
+ })
678
+ })
679
+
680
+ describe('a result with no evidence string still reads as a result', () => {
681
+ it('never says "no test has been run" beside an outcome that ran', () => {
682
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ layer: 'tcp', path: 'data', ok: true })])
683
+ const r = route({ route: 'redis', target: 'redis:6379', outcome: 'reached', confidence: 'real', evidence: undefined })
684
+ t.routes = [r]
685
+ const texts = buildSidebar(undefined, ctx(t, 'incluster', r)).path.evidence.map((e) => e.text).join('\n')
686
+ expect(texts).not.toContain('no test has been run from here')
687
+ expect(texts.length).toBeGreaterThan(0)
688
+ })
689
+ })
690
+
691
+ describe('selecting a context entry opens it', () => {
692
+ // The entry-problem row exists to answer "show me the cause". Landing on a
693
+ // still-collapsed section makes the reader spend a second click on the very
694
+ // thing they asked for - and it must hold for EVERY declared entry kind.
695
+ const withEntries = (): Trace => {
696
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
697
+ t.upstreams = [
698
+ { resource: { kind: 'Ingress', name: 'shop', namespace: 'store' }, edge: 'ingress->service', findings: [], config: { addresses: ['34.0.0.1'] } },
699
+ { resource: { kind: 'HTTPRoute', name: 'shop', namespace: 'store' }, edge: 'httproute->service', findings: [] },
700
+ ]
701
+ return t
702
+ }
703
+ for (const kind of ['Ingress', 'HTTPRoute']) {
704
+ it(`expands the selected ${kind}, and leaves its sibling collapsed`, () => {
705
+ const t = withEntries()
706
+ const c = ctx(t, 'apiserver')
707
+ const id = `n:${kind}/store/shop`
708
+ const s = buildSidebar(id, c)
709
+ const hops = s.context?.hops ?? []
710
+ expect(hops.length).toBeGreaterThan(0)
711
+ const picked = hops.find((h) => h.id === id)
712
+ expect(picked, `${kind} should be a context hop`).toBeTruthy()
713
+ expect(picked!.expanded).toBe(true)
714
+ expect(hops.filter((h) => h.id !== id).every((h) => !h.expanded)).toBe(true)
715
+ })
716
+ }
717
+ it('leaves every context hop collapsed when nothing is selected', () => {
718
+ const s = buildSidebar(undefined, ctx(withEntries(), 'apiserver'))
719
+ expect((s.context?.hops ?? []).every((h) => !h.expanded)).toBe(true)
720
+ })
721
+ })
722
+
723
+ describe('a coverage statement is not a fault', () => {
724
+ it('the verdict keeps it on the wire but marks it coverage-class', () => {
725
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
726
+ t.diagnosis = { class: 'coverage', summary: "reachable via API server - the real-traffic path wasn't confirmed from here" } as never
727
+ // buildVerdict still surfaces it for consumers that want the sentence...
728
+ const v = buildVerdict(t, route({ outcome: 'reached', confidence: 'indirect' }), { originId: 'apiserver', originName: 'API-server proxy' })
729
+ expect(v.problem).toBeTruthy()
730
+ // ...and the class is what the header uses to keep it OUT of the problem list.
731
+ expect(t.diagnosis!.class).toBe('coverage')
732
+ })
733
+ })
734
+
735
+ describe('one observation is stated once', () => {
736
+ it('a localization fact does not repeat the route evidence it came from', () => {
737
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver', tone: 'reached', detail: 'HTTP 404 · reached' })])
738
+ const r = route({
739
+ outcome: 'reached', confidence: 'indirect', evidence: 'HTTP 404 · reached',
740
+ localization: [{ layer: 'http', ok: true, detail: 'HTTP 404 · reached' }],
741
+ } as never)
742
+ t.routes = [r]
743
+ const texts = buildSidebar(undefined, ctx(t, 'apiserver', r)).path.evidence.map((e) => e.text)
744
+ const plain = texts.filter((x) => x.toLowerCase().startsWith('http 404 · reached'))
745
+ expect(plain, `saw: ${JSON.stringify(texts)}`).toHaveLength(1)
746
+ // and the surviving line is the MORE specific one
747
+ expect(plain[0]).toContain('checked directly')
748
+ })
749
+ })
750
+
751
+ describe('an answer is interpretable', () => {
752
+ const reached = (evidence: string): RouteResult =>
753
+ route({ outcome: 'reached', confidence: 'real', evidence, inClusterRequest: { protocol: 'http', scheme: 'http', path: '/' } } as never)
754
+ const body = (evidence: string) => {
755
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ tone: 'reached', detail: evidence })])
756
+ const r = reached(evidence)
757
+ t.routes = [r]
758
+ return buildSidebar(undefined, ctx(t, 'incluster', r)).path
759
+ }
760
+
761
+ it('a 404 says the PATH works and names what would verify a route', () => {
762
+ const b = body('HTTP 404 · reached').body
763
+ expect(b).toContain('path works')
764
+ expect(b).toContain('serves no route')
765
+ expect(b).toContain('re-run with a path your app serves')
766
+ })
767
+ it('auth, redirect and app-error answers are each read correctly', () => {
768
+ expect(body('HTTP 401 · reached').body).toContain('credentials')
769
+ expect(body('HTTP 308 · reached, redirect').body).toMatch(/redirect .*Radar does not follow/)
770
+ expect(body('HTTP 503 · reached, server error').body).toContain('application health')
771
+ })
772
+ it('a transport-only reach never claims something was asked', () => {
773
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ layer: 'tcp', ok: true })])
774
+ const r = route({ outcome: 'reached', confidence: 'real', evidence: '', inClusterRequest: { protocol: 'tcp' } } as never)
775
+ t.routes = [r]
776
+ expect(buildSidebar(undefined, ctx(t, 'incluster', r)).path.body).toContain('nothing was asked of the application')
777
+ })
778
+ it('shows WHAT was requested, so a status code can be read at all', () => {
779
+ expect(body('HTTP 404 · reached').scope.some((s) => s.k === 'REQUEST' && s.v === 'GET /')).toBe(true)
780
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ layer: 'tcp', ok: true })])
781
+ const r = route({ outcome: 'reached', confidence: 'real', inClusterRequest: { protocol: 'tcp' } } as never)
782
+ t.routes = [r]
783
+ expect(buildSidebar(undefined, ctx(t, 'incluster', r)).path.scope.some((s) => s.k === 'REQUEST' && s.v === 'TCP connect')).toBe(true)
784
+ })
785
+ })
786
+
787
+ describe('a relayed answer explains the relay AND the answer', () => {
788
+ it('keeps the relay caveat and still reads the status', () => {
789
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver', tone: 'reached', detail: 'HTTP 404 · reached' })])
790
+ const r = route({ outcome: 'reached', confidence: 'indirect', evidence: 'HTTP 404 · reached', inClusterRequest: { protocol: 'http', path: '/' } } as never)
791
+ t.routes = [r]
792
+ const b = buildSidebar(undefined, ctx(t, 'apiserver', r)).path.body
793
+ expect(b).toContain('relayed')
794
+ expect(b).toContain('serves no route')
795
+ // ...but never claims the real path works, because a relay cannot show that
796
+ expect(b).not.toContain('The path works')
797
+ })
798
+ })
799
+
800
+ // An answer is not the same as an answer that got through. A gateway saying it
801
+ // could not reach its upstream, and a handshake that never verified, are both
802
+ // answers - and both mean the opposite of "the path works".
803
+ describe('an answer that reports a break never reads as a success', () => {
804
+ const bodyFor = (r: Partial<RouteResult>) => {
805
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ tone: 'reached' } as never)])
806
+ const full = route({ outcome: 'server-error', confidence: 'real', inClusterRequest: { protocol: 'http', path: '/' }, ...r } as never)
807
+ t.routes = [full]
808
+ return buildSidebar(undefined, ctx(t, 'incluster', full)).path.body
809
+ }
810
+
811
+ for (const code of [502, 504]) {
812
+ it(`does not claim the path works on HTTP ${code}`, () => {
813
+ const b = bodyFor({ evidence: `HTTP ${code}`, failedLayer: 'upstream' } as never)
814
+ expect(b).not.toContain('The path works')
815
+ expect(b).toContain('could not reach the backend')
816
+ })
817
+ }
818
+
819
+ it('explains a certificate failure, which carries no status code at all', () => {
820
+ const b = bodyFor({ evidence: 'x509: certificate has expired', failedLayer: 'tls' } as never)
821
+ expect(b).not.toContain('The path works')
822
+ expect(b).toContain('certificate problem')
823
+ // The generic fallthrough would have said this instead, explaining nothing.
824
+ expect(b).not.toContain('not with what was asked for')
825
+ })
826
+
827
+ it('still credits the path when the APP itself answered', () => {
828
+ expect(bodyFor({ evidence: 'HTTP 404' } as never)).toContain('The path works')
829
+ expect(bodyFor({ evidence: 'HTTP 500' } as never)).toContain('The path works')
830
+ })
831
+ })
832
+
833
+ describe('every state explains itself, not the nearest generic sentence', () => {
834
+ const withRoute = (r: RouteResult, probes = [p({})]) => {
835
+ const t = mk([pod('a', true, '10.0.0.1')], probes)
836
+ t.routes = [r]
837
+ return buildSidebar(undefined, ctx(t, 'incluster', r)).path.body
838
+ }
839
+
840
+ it('a deliberately dormant path is not described as an answer', () => {
841
+ const b = withRoute(route({ outcome: 'unreachable', benign: true, evidence: 'no running backends (scaled to 0)' } as never))
842
+ expect(b).toContain('deliberate')
843
+ expect(b).not.toContain('not with what was asked for')
844
+ })
845
+
846
+ it('a kept-informational run says it ran AND why it does not decide', () => {
847
+ const t = mk([pod('a', true, '10.0.0.1')], [
848
+ p({ target: 'shop:80', port: 80, skipped: true, skipClass: 'informational', reason: 'the throwaway Pod was denied by mTLS', ok: false, source: 'probe-job' }),
849
+ ])
850
+ const r = route({ target: 'shop:80', outcome: 'not-tested', confidence: undefined, byVantage: undefined } as never)
851
+ t.routes = [r]
852
+ const b = buildSidebar(undefined, ctx(t, 'incluster', r)).path.body
853
+ expect(b + JSON.stringify(buildSidebar(undefined, ctx(t, 'incluster', r)).path.evidence)).toContain('denied by mTLS')
854
+ })
855
+
856
+ it('a verified answer is not described as a partial one', () => {
857
+ const b = withRoute(route({ outcome: 'verified', confidence: 'real', evidence: 'HTTP 200 · verified' } as never))
858
+ expect(b).toContain('A real request went through')
859
+ })
860
+ })
861
+
862
+
863
+ describe('the action sits with its explanation', () => {
864
+ it('an untested vantage offers the run, and does not repeat the sentence above it', () => {
865
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
866
+ const r = route({ outcome: 'not-tested', confidence: undefined, byVantage: undefined, inClusterRequest: { protocol: 'http', path: '/' } } as never)
867
+ t.routes = [r]
868
+ const s = buildSidebar(undefined, ctx(t, 'incluster', r)).path
869
+ expect(s.body).toContain('Nothing has been tested from here')
870
+ // the action is present...
871
+ expect(s.next.ctas.some((c) => c.action === 'run-in-cluster')).toBe(true)
872
+ // ...and the block does not say "nothing has been tested" a second time
873
+ expect(s.next.body).toBe('')
874
+ })
875
+
876
+ it('WHAT WE SAW disappears rather than echoing "no test has been run"', () => {
877
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
878
+ const r = route({ outcome: 'not-tested', confidence: undefined, byVantage: undefined } as never)
879
+ t.routes = [r]
880
+ const ev = buildSidebar(undefined, ctx(t, 'incluster', r)).path.evidence
881
+ expect(ev).toHaveLength(0)
882
+ })
883
+
884
+ it('a vantage that CANNOT be used still says why, instead of going silent', () => {
885
+ const t = mk([pod('a', true, '10.0.0.1')], [])
886
+ t.verdict = 'unknown'
887
+ t.routes = [route({ outcome: 'not-tested', confidence: undefined, byVantage: undefined } as never)]
888
+ t.downstream![0].probes = [p({ target: 'shop:80', port: 80, vantage: 'local', path: 'apiserver', skipped: true, reason: 'proxy said no', ok: false })]
889
+ const ev = buildSidebar(undefined, ctx(t, 'local', t.routes[0])).path.evidence
890
+ expect(ev.map((e) => e.text).join(' ')).toMatch(/no entry point|Ingress/i)
891
+ })
892
+ })