@skyhook-io/k8s-ui 1.10.3 → 1.10.5

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 (57) 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/topology/K8sResourceNode.tsx +5 -0
  5. package/src/components/topology/TopologyGraph.tsx +85 -5
  6. package/src/components/topology/layout.worker.ts +2 -1
  7. package/src/components/trace/ReachabilityGraph.tsx +503 -0
  8. package/src/components/trace/ReachabilityView.tsx +972 -0
  9. package/src/components/trace/TracePanel.test.ts +172 -0
  10. package/src/components/trace/TracePanel.tsx +558 -0
  11. package/src/components/trace/TraceSummary.test.ts +213 -0
  12. package/src/components/trace/TraceSummary.tsx +168 -0
  13. package/src/components/trace/inClusterSummary.test.ts +98 -0
  14. package/src/components/trace/inClusterSummary.ts +81 -0
  15. package/src/components/trace/index.ts +19 -0
  16. package/src/components/trace/podReach.test.ts +71 -0
  17. package/src/components/trace/podReach.ts +37 -0
  18. package/src/components/trace/probe-display.test.ts +107 -0
  19. package/src/components/trace/probe-display.ts +37 -0
  20. package/src/components/trace/reachFixtures.ts +261 -0
  21. package/src/components/trace/reachGraphModel.test.ts +1407 -0
  22. package/src/components/trace/reachGraphModel.ts +1521 -0
  23. package/src/components/trace/reachInspector.test.ts +669 -0
  24. package/src/components/trace/reachInspector.ts +667 -0
  25. package/src/components/trace/reachMarks.test.ts +667 -0
  26. package/src/components/trace/reachMarks.ts +579 -0
  27. package/src/components/trace/reachOrigins.test.ts +392 -0
  28. package/src/components/trace/reachOrigins.ts +437 -0
  29. package/src/components/trace/reachVerdict.test.ts +537 -0
  30. package/src/components/trace/reachVerdict.ts +601 -0
  31. package/src/components/trace/traceFingerprint.test.ts +214 -0
  32. package/src/components/trace/traceFingerprint.ts +122 -0
  33. package/src/components/trace/traceToSubgraph.test.ts +623 -0
  34. package/src/components/trace/traceToSubgraph.ts +463 -0
  35. package/src/components/trace/types.ts +389 -0
  36. package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
  37. package/src/components/ui/InClusterConsentDialog.tsx +141 -0
  38. package/src/components/ui/index.ts +1 -0
  39. package/src/components/workload/WorkloadView.tsx +58 -5
  40. package/src/components/workload/index.ts +1 -1
  41. package/src/components/workload/reachabilityTab.test.ts +47 -0
  42. package/src/index.ts +5 -0
  43. package/src/theme/components.css +38 -0
  44. package/src/theme/variables.css +5 -0
  45. package/src/types/core.ts +23 -0
  46. package/src/utils/application-history.test.ts +1 -1
  47. package/src/utils/applications.test.ts +63 -1
  48. package/src/utils/applications.ts +27 -0
  49. package/src/utils/asset-url.test.ts +113 -0
  50. package/src/utils/asset-url.ts +33 -1
  51. package/src/utils/inClusterConsent.test.ts +109 -0
  52. package/src/utils/inClusterConsent.ts +72 -0
  53. package/src/utils/index.ts +2 -0
  54. package/src/utils/navigation.test.ts +61 -0
  55. package/src/utils/navigation.ts +11 -9
  56. package/src/utils/pluralize.test.ts +80 -1
  57. package/src/utils/pluralize.ts +34 -0
@@ -0,0 +1,669 @@
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
+ expect(s.path.next.header).toBeTruthy()
77
+ expect(s.resource).toBeUndefined()
78
+ })
79
+
80
+ // Every hop is reported whether or not anything was clicked; a selection only
81
+ // changes which one is open. Understanding a path used to take three clicks.
82
+ it('reports the whole path without any selection, and a click only opens one', () => {
83
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
84
+ const c = ctx(t, 'incluster')
85
+ const before = buildSidebar(undefined, c)
86
+ expect(before.hops.map((h) => h.kind)).toContain('PODS')
87
+ const after = buildSidebar(podsNodeId(t), c)
88
+ expect(after.path).toEqual(before.path)
89
+ expect(after.hops.find((h) => h.kind === 'PODS')!.expanded).toBe(true)
90
+ })
91
+
92
+ it('an apiserver result always states what it skipped', () => {
93
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
94
+ const s = buildSidebar(undefined, ctx(t, 'apiserver'))
95
+ expect(s.path.notProve.join(' ')).toMatch(/relayed|routing|network policy|mesh/i)
96
+ })
97
+
98
+ it('a synthetic result always states the identity gap', () => {
99
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
100
+ const s = buildSidebar(undefined, ctx(t, 'incluster'))
101
+ expect(s.path.notProve.join(' ')).toMatch(/not as your application|who is calling/i)
102
+ })
103
+
104
+ it('never claims complete evidence', () => {
105
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
106
+ expect(buildSidebar(undefined, ctx(t, 'incluster')).path.notProve.length).toBeGreaterThan(0)
107
+ })
108
+
109
+ it('does not claim a front-door gap on a resource with no entry point', () => {
110
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
111
+ expect(t.upstreams).toHaveLength(0)
112
+ expect(buildSidebar(undefined, ctx(t, 'incluster')).path.notProve.join(' ')).not.toMatch(/internet|outside/i)
113
+ })
114
+
115
+ it('offers only actions that can be taken', () => {
116
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
117
+ const s = buildSidebar(undefined, ctx(t, 'apiserver'))
118
+ expect(s.path.next.ctas.every((c) => !c.disabledReason)).toBe(true)
119
+ expect(s.path.next.body).toMatch(/in-cluster/i)
120
+ })
121
+
122
+ it('the RBAC prompt names the permission and copies a command that runs', () => {
123
+ // Every other vantage must be spent, or offering one of those would be the
124
+ // better next step than asking for permission.
125
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' }), p({ vantage: 'local', path: 'data' })])
126
+ const origins = buildOrigins(t, { inClusterAllowed: false })
127
+ const g = buildGraph({ trace: t, route: route(), origin: origins.find((o) => o.id === 'apiserver')! })
128
+ const s = buildSidebar(undefined, { trace: t, route: route(), origin: origins.find((o) => o.id === 'apiserver')!, origins, nodes: g.nodes })
129
+ expect(s.path.next.body).toMatch(/create.*jobs/i)
130
+ expect(s.path.next.ctas[0].command).toMatch(/kubectl auth can-i/)
131
+ })
132
+ })
133
+
134
+ // The graph already gates on this; the sidebar is the surface people actually
135
+ // read, so it must gate identically or it states another vantage's result under
136
+ // the selected vantage's name.
137
+ describe('the sidebar is scoped to the selected vantage', () => {
138
+ it('an unavailable vantage never inherits another vantage\'s success', () => {
139
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
140
+ const origins = buildOrigins(t)
141
+ const caller = origins.find((o) => o.id === 'caller')!
142
+ const g = buildGraph({ trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: caller })
143
+ const s = buildSidebar(undefined, { trace: t, route: route({ outcome: 'verified', confidence: 'real' }), origin: caller, origins, nodes: g.nodes })
144
+ expect(s.path.body).not.toMatch(/a real request went through/i)
145
+ expect(s.path.evidence.every((e) => e.mark !== 'proved')).toBe(true)
146
+ expect(s.path.evidence.map((e) => e.text).join(' ')).toMatch(/cannot test from here/i)
147
+ })
148
+
149
+ it('a vantage that did run still reports its result', () => {
150
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
151
+ const s = buildSidebar(undefined, ctx(t, 'incluster'))
152
+ expect(s.path.body).toMatch(/a real request went through/i)
153
+ })
154
+
155
+ it('a stale result does not lead with its old assertion', () => {
156
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
157
+ t.headline = 'Reachable in-cluster on :80'
158
+ const v = buildVerdict(t, route(), { stale: true })
159
+ expect(v.title).not.toMatch(/Reachable/)
160
+ expect(v.title).toMatch(/out of date/i)
161
+ })
162
+ })
163
+
164
+ describe('node detail is additive', () => {
165
+ it('a Pods node reports what is and is not taking traffic', () => {
166
+ const t = mk([pod('a', true, '10.0.0.1'), pod('b', false, '10.0.0.2', 'readiness failing')], [p({})])
167
+ const r = buildSidebar(podsNodeId(t), ctx(t, 'incluster')).hops.find((h) => h.kind === 'PODS')!
168
+ expect(r.facts.find((x) => x.k === 'SITTING OUT')!.v).toMatch(/not ready/)
169
+ // Derived from readiness, so it must not claim observed delivery.
170
+ expect(r.facts.some((x) => x.k === 'ELIGIBLE')).toBe(true)
171
+ expect(r.facts.some((x) => x.k === 'TAKING TRAFFIC')).toBe(false)
172
+ expect(r.notProve.join(' ')).toMatch(/nothing was sent to them/)
173
+ expect(r.rows!.some((x) => x.mark === 'excluded')).toBe(true)
174
+ })
175
+
176
+ it('a resource node carries its own config, not the path result', () => {
177
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
178
+ const c = ctx(t, 'incluster')
179
+ const svc = c.nodes.find((n) => n.kind === 'SERVICE')!
180
+ const r = buildSidebar(svc.id, c).hops.find((h) => h.id === svc.id)!
181
+ expect(r.facts.some((x) => x.k === 'CLUSTER IP')).toBe(true)
182
+ expect(r.openRef?.name).toBe('shop')
183
+ })
184
+
185
+ // A vantage is not a hop on the path - it is where the path was watched from.
186
+ it('the origin capsule is never a hop in the report', () => {
187
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
188
+ const c = ctx(t, 'incluster')
189
+ const cap = c.nodes.find((n) => n.isOrigin)!
190
+ expect(buildSidebar(cap.id, c).hops.some((h) => h.id === cap.id)).toBe(false)
191
+ })
192
+ })
193
+
194
+ describe('verdict band', () => {
195
+ // These rules used to be asserted through the header's fact strip. The strip
196
+ // is gone - every one of its four facts is now shown by the graph or the
197
+ // panel - so each rule is pinned where it actually renders.
198
+ it('reports nothing proven when no origin has run', () => {
199
+ const t = mk([pod('a', true, '10.0.0.1')], [])
200
+ const v = buildVerdict(t, route({ outcome: 'not-tested' }))
201
+ expect(v.tone).toBe('unknown')
202
+ expect(v.chipText).toMatch(/not tested/i)
203
+ })
204
+
205
+ it('an apiserver-only pass never claims the backend was proven', () => {
206
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
207
+ const v = buildVerdict(t, route({ confidence: 'indirect' }))
208
+ expect(v.tone).toBe('degraded')
209
+ const s = buildSidebar(undefined, ctx(t, 'apiserver', route({ confidence: 'indirect' })))
210
+ // It may say something is SERVING; it must also say that is not the same as
211
+ // the normal path working.
212
+ expect(s.path.body).toMatch(/serving/i)
213
+ expect(s.path.body).toMatch(/not that the normal path works/i)
214
+ })
215
+
216
+ it('points the next step at something Radar can actually do', () => {
217
+ // The real caller is the strongest missing origin AND permanently
218
+ // unavailable. Offering it would give every resource the same
219
+ // un-actionable next step.
220
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
221
+ const s = buildSidebar(undefined, ctx(t, 'apiserver', route({ confidence: 'indirect' })))
222
+ expect(s.path.next.body).toMatch(/in-cluster probe/i)
223
+ expect(s.path.next.header).toMatch(/run this next/i)
224
+ })
225
+
226
+ it('says so plainly when nothing stronger can be run', () => {
227
+ const t = mk([pod('a', true, '10.0.0.1')], [p({}), p({ path: 'apiserver' }), p({ vantage: 'local', path: 'data' })])
228
+ const s = buildSidebar(undefined, ctx(t, 'incluster'))
229
+ expect(s.path.next.header).toMatch(/no stronger test/i)
230
+ })
231
+
232
+ it('falls back to the backend verdict when there is no route to derive a tone from', () => {
233
+ // A config fault found without probing still has a verdict; a grey
234
+ // "unknown" dot there under-reports what the tracer already concluded.
235
+ const t = mk([pod('a', true, '10.0.0.1')], [])
236
+ t.verdict = 'degraded'
237
+ t.routes = []
238
+ const v = buildVerdict(t, undefined, buildOrigins(t))
239
+ expect(v.tone).toBe('degraded')
240
+ })
241
+
242
+ it('leads with the named fault rather than burying it under a coverage headline', () => {
243
+ const t = mk([pod('a', true, '10.0.0.1')], [])
244
+ t.headline = 'Configuration only - not yet tested'
245
+ t.diagnosis = { summary: 'Accepted: NoMatchingListenerHostname - no hostname intersections' }
246
+ const v = buildVerdict(t, undefined, buildOrigins(t))
247
+ expect(v.problem).toMatch(/NoMatchingListenerHostname/)
248
+ // and it is not duplicated into the body
249
+ expect(v.body).toBe('')
250
+ })
251
+
252
+ it('a running test is informational, never green', () => {
253
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
254
+ const v = buildVerdict(t, route(), { running: true })
255
+ expect(v.tone).toBe('info')
256
+ expect(v.chipText).toBe('testing')
257
+ })
258
+ })
259
+
260
+ describe('an in-cluster test that could not run says so first', () => {
261
+ // A route whose probes were all skipped never gets a concrete request, so the
262
+ // server answers "not supported for this subject". The panel used to
263
+ // recommend the run as the strongest evidence available and let the operator
264
+ // discover the failure by spending a Job on it.
265
+ const noRunnableRoute = (): Trace => {
266
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })])
267
+ t.routes = [{ route: 'port 7000', target: 'shop:7000', outcome: 'not-tested', evidence: 'port looks non-HTTP' }]
268
+ return t
269
+ }
270
+
271
+ // The button moved to the vantage capsule in the graph, so the panel must not
272
+ // offer a third copy of it - it keeps the reasoning instead.
273
+ it('offers no run button of its own', () => {
274
+ const s = buildSidebar(undefined, ctx(noRunnableRoute(), 'apiserver'))
275
+ expect(s.path.next.ctas.some((c) => c.action === 'run-in-cluster')).toBe(false)
276
+ })
277
+
278
+ it('explains it in the body rather than promising stronger evidence', () => {
279
+ const s = buildSidebar(undefined, ctx(noRunnableRoute(), 'apiserver'))
280
+ expect(s.path.next.body).not.toMatch(/strongest evidence/i)
281
+ expect(s.path.next.body).toMatch(/skipped before a request could be formed/i)
282
+ })
283
+
284
+ it('still explains the run is available when a route carries a request', () => {
285
+ const s = buildSidebar(undefined, ctx(mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver' })]), 'apiserver'))
286
+ expect(s.path.next.body).toMatch(/strongest evidence/i)
287
+ })
288
+ })
289
+
290
+ describe('the report is ordered around the break', () => {
291
+ // The break is the answer, so it opens; what came before it worked and is
292
+ // context; what came after was never tried and must not read as healthy.
293
+ const brokenTrace = () => mk([pod('a', true, '10.0.0.1')], [p({ ok: false, tone: 'unhealthy' })])
294
+ const brokenRoute = () => route({ outcome: 'unreachable', confidence: 'real', evidence: 'connection refused' })
295
+
296
+ it('opens the hop where the request stopped, not the last one', () => {
297
+ const t = brokenTrace()
298
+ const c = ctx(t, 'incluster', brokenRoute())
299
+ const s = buildSidebar(undefined, c)
300
+ expect(c.breakNodeId).toBeDefined()
301
+ const open = s.hops.filter((h) => h.expanded)
302
+ expect(open).toHaveLength(1)
303
+ expect(open[0].id).toBe(c.breakNodeId)
304
+ expect(open[0].state).toBe('break')
305
+ })
306
+
307
+ it('opens exactly one hop when nothing is selected', () => {
308
+ const s = buildSidebar(undefined, ctx(mk([pod('a', true, '10.0.0.1')], [p({})]), 'incluster'))
309
+ expect(s.hops.filter((h) => h.expanded)).toHaveLength(1)
310
+ })
311
+
312
+ it('labels hops past the break as never tried, not as fine', () => {
313
+ const t = brokenTrace()
314
+ const c = ctx(t, 'incluster', brokenRoute())
315
+ const s = buildSidebar(undefined, c)
316
+ expect(c.breakNodeId).toBeDefined()
317
+ const after = s.hops.slice(s.hops.findIndex((h) => h.id === c.breakNodeId) + 1)
318
+ for (const h of after) expect(h.state).toBe('after')
319
+ })
320
+
321
+ it('keeps hops in path order', () => {
322
+ const s = buildSidebar(undefined, ctx(mk([pod('a', true, '10.0.0.1')], [p({})]), 'incluster'))
323
+ expect(s.hops.map((h) => h.kind)).toEqual(['SERVICE', 'PODS'])
324
+ })
325
+ })
326
+
327
+ describe('a derived break says which KIND it is', () => {
328
+ const derived = (basis: 'declared-config' | 'cluster-state'): RouteResult => ({
329
+ route: 'GET /', target: 'web:80', outcome: 'unreachable', basis,
330
+ })
331
+
332
+ // Calling "no ready endpoints" a configuration failure sends the reader to
333
+ // edit YAML when the fix is to get Pods running.
334
+ it('does not call a cluster-state break a configuration failure', () => {
335
+ const t = mk([pod('a', true, '10.0.0.1')], [])
336
+ const s = buildSidebar(undefined, ctx(t, 'incluster', derived('cluster-state')))
337
+ expect(s.path.body).not.toMatch(/configuration/i)
338
+ expect(s.path.body).toMatch(/ready to serve/i)
339
+ expect(s.path.body).toMatch(/changes when the workload does/i)
340
+ })
341
+
342
+ it('still calls a declared-config break a configuration failure', () => {
343
+ const t = mk([pod('a', true, '10.0.0.1')], [])
344
+ const s = buildSidebar(undefined, ctx(t, 'incluster', derived('declared-config')))
345
+ expect(s.path.body).toMatch(/configuration itself is broken/i)
346
+ })
347
+
348
+ // Both are DERIVED: neither was dialled, so neither may read as an
349
+ // observation of the selected vantage.
350
+ it('attributes neither to the selected vantage', () => {
351
+ const t = mk([pod('a', true, '10.0.0.1')], [])
352
+ for (const b of ['declared-config', 'cluster-state'] as const) {
353
+ const s = buildSidebar(undefined, ctx(t, 'local', derived(b)))
354
+ expect(s.path.body).not.toMatch(/from here/i)
355
+ }
356
+ })
357
+ })
358
+
359
+ describe('the verdict band follows the selected origin', () => {
360
+ // The band sits directly above the inspector, which reads the SELECTED
361
+ // origin's own result. On the merged rollup the two contradicted each other
362
+ // in adjacent panes - "could not get through" over "got through" - on exactly
363
+ // the disagreeing traces per-vantage evidence exists to represent. This seam
364
+ // regressed once already and had no pin.
365
+ const disagreeing = (): RouteResult =>
366
+ route({
367
+ outcome: 'unreachable',
368
+ confidence: 'real',
369
+ byVantage: [
370
+ { vantage: 'local', path: 'data', outcome: 'unreachable' },
371
+ { vantage: 'in-cluster', path: 'data', outcome: 'verified', confidence: 'real' },
372
+ ],
373
+ })
374
+
375
+ it('shows the origin-scoped result, not the rollup, and names its scope', () => {
376
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
377
+ const v = buildVerdict(t, disagreeing(), { originId: 'incluster', originName: 'In-cluster probe' })
378
+ expect(v.tone).not.toBe('unhealthy')
379
+ expect(v.chipText).toBe('got through')
380
+ expect(v.chipScope).toContain('from In-cluster probe')
381
+ })
382
+
383
+ it('an origin absent from a produced breakdown reads not tested, never the rollup chip', () => {
384
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
385
+ const only = route({
386
+ outcome: 'verified',
387
+ confidence: 'real',
388
+ byVantage: [{ vantage: 'in-cluster', path: 'data', outcome: 'verified', confidence: 'real' }],
389
+ })
390
+ const v = buildVerdict(t, only, { originId: 'local', originName: 'Radar on your machine' })
391
+ expect(v.chipText).toBe('not tested')
392
+ })
393
+ })
394
+
395
+ describe('localization facts belong to the vantage that produced them', () => {
396
+ // route.localization rows are apiserver-relay observations (direct-pod dials
397
+ // past the entry point). Crediting them to the in-cluster origin would claim
398
+ // observations that vantage never made.
399
+ const localized = (): RouteResult =>
400
+ route({
401
+ outcome: 'verified',
402
+ confidence: 'indirect',
403
+ byVantage: [{ vantage: 'local', path: 'apiserver', outcome: 'verified' }],
404
+ localization: [{ layer: 'tcp', ok: true, detail: 'Pods answered directly, Service did not' }],
405
+ })
406
+
407
+ it('shows them under the apiserver origin', () => {
408
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ vantage: 'local', path: 'apiserver' })])
409
+ const s = buildSidebar(undefined, ctx(t, 'apiserver', localized()))
410
+ const texts = s.path.evidence.map((e) => e.text).join('\n')
411
+ expect(texts).toMatch(/checked directly, past the entry point/)
412
+ })
413
+
414
+ it('never credits them to the in-cluster origin', () => {
415
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
416
+ const s = buildSidebar(undefined, ctx(t, 'incluster', localized()))
417
+ const texts = s.path.evidence.map((e) => e.text).join('\n')
418
+ expect(texts).not.toMatch(/checked directly/)
419
+ })
420
+ })
421
+
422
+
423
+ describe('a Service-routing boundary in the sidebar', () => {
424
+ const brokenRouting = (): RouteResult =>
425
+ route({
426
+ outcome: 'unreachable',
427
+ confidence: 'real',
428
+ byVantage: [{ vantage: 'in-cluster', path: 'data', outcome: 'unreachable', failedBoundary: 'service-routing' }],
429
+ })
430
+ const withWorkload = () => {
431
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
432
+ t.downstream![1].config!.workload = { kind: 'Deployment', name: 'web', namespace: 'store' }
433
+ return t
434
+ }
435
+
436
+ it('the SERVICE hop carries the break, exit-phrased', () => {
437
+ const s = buildSidebar(undefined, ctx(withWorkload(), 'incluster', brokenRouting()))
438
+ const svc = s.hops.find((h) => h.kind.startsWith('SERVICE'))!
439
+ expect(svc.state).toBe('break')
440
+ expect(svc.chipText).toMatch(/just past here/)
441
+ })
442
+
443
+ it('the workload hop is never a journey participant', () => {
444
+ const s = buildSidebar(undefined, ctx(withWorkload(), 'incluster', brokenRouting()))
445
+ const wl = s.hops.find((h) => h.kind.startsWith('DEPLOYMENT'))!
446
+ expect(wl.state).toBe('plain')
447
+ })
448
+ })
449
+
450
+ describe('bypassed and parallel entries are context, not journey', () => {
451
+ const withEntries = (n = 1): Trace => {
452
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
453
+ t.upstreams = Array.from({ length: n }, (_, i) => ({
454
+ resource: { kind: 'Ingress', name: `web-${i}`, namespace: 'store' },
455
+ edge: 'ingress->service',
456
+ findings: [],
457
+ config: { hostnames: [`h${i}.example.com`], addresses: ['34.0.0.1'] },
458
+ }))
459
+ return t
460
+ }
461
+
462
+ it('the apiserver vantage does not list entries as its path', () => {
463
+ const s = buildSidebar(undefined, ctx(withEntries(), 'apiserver'))
464
+ expect(s.hops.some((h) => h.kind.startsWith('INGRESS'))).toBe(false)
465
+ expect(s.context?.hops.some((h) => h.kind.startsWith('INGRESS'))).toBe(true)
466
+ expect(s.context?.label).toMatch(/RELAYED/)
467
+ })
468
+
469
+ it('the laptop vantage keeps entries on the path', () => {
470
+ const s = buildSidebar(undefined, ctx(withEntries(), 'local'))
471
+ expect(s.hops.some((h) => h.kind.startsWith('INGRESS'))).toBe(true)
472
+ expect(s.context).toBeUndefined()
473
+ })
474
+
475
+ it('two entries the route cannot be pinned to read as one parallel stage, never a sequence', () => {
476
+ const s = buildSidebar(undefined, ctx(withEntries(2), 'local'))
477
+ const entries = s.hops.filter((h) => h.kind.startsWith('INGRESS'))
478
+ expect(entries).toHaveLength(2)
479
+ for (const e of entries) expect(e.parallelCount).toBe(2)
480
+ })
481
+ })
482
+
483
+ describe("'testing' is scoped to the vantage that is testing", () => {
484
+ // The Job runs from the in-cluster vantage. Starting it while the LAPTOP is
485
+ // selected must not flip the laptop-scoped band or sidebar to "testing" -
486
+ // that is one vantage's state under another's name, in motion.
487
+ it('the verdict band for a non-running origin never says testing', () => {
488
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
489
+ // The view computes running && origin.id === 'incluster'; for the laptop
490
+ // that is false even mid-run.
491
+ const v = buildVerdict(t, route(), { running: false, originId: 'local', originName: 'Radar on your machine' })
492
+ expect(v.chipText).not.toBe('testing')
493
+ })
494
+
495
+ it('the sidebar for a non-running origin never says testing now', () => {
496
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ vantage: 'local', path: 'data' })])
497
+ const c = ctx(t, 'local')
498
+ const s = buildSidebar(undefined, { ...c, running: false })
499
+ expect(JSON.stringify(s.path)).not.toMatch(/testing now/)
500
+ })
501
+ })
502
+
503
+ describe('a break at one parallel entry orders nothing', () => {
504
+ // Two entries with no host attribution stay in the journey as one parallel
505
+ // stage. A failed dial of entry A must not make sibling B read "before the
506
+ // break", nor the Service behind them "never tried - something earlier
507
+ // stopped": the request may have gone through B.
508
+ it('siblings and downstream hops read plain, never before/after', () => {
509
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
510
+ t.upstreams = [
511
+ {
512
+ resource: { kind: 'Ingress', name: 'entry-a', namespace: 'store' },
513
+ edge: 'ingress->service',
514
+ findings: [],
515
+ config: { addresses: ['34.0.0.1'] },
516
+ probes: [{ layer: 'tcp', target: '34.0.0.1:443', vantage: 'local', path: 'data', ok: false, tone: 'unhealthy' }],
517
+ },
518
+ {
519
+ resource: { kind: 'Ingress', name: 'entry-b', namespace: 'store' },
520
+ edge: 'ingress->service',
521
+ findings: [],
522
+ config: { addresses: ['34.0.0.2'] },
523
+ probes: [{ layer: 'http', target: '34.0.0.2:443', vantage: 'local', path: 'data', ok: true, tone: 'healthy' }],
524
+ },
525
+ ]
526
+ const s = buildSidebar(undefined, ctx(t, 'local'))
527
+ const a = s.hops.find((h) => h.name === 'entry-a')
528
+ const b = s.hops.find((h) => h.name === 'entry-b')
529
+ const svc = s.hops.find((h) => h.kind.startsWith('SERVICE'))
530
+ if (a?.state === 'break') {
531
+ expect(b?.state).toBe('plain')
532
+ expect(svc?.state).toBe('plain')
533
+ } else {
534
+ // No break anchored at the stage in this fixture shape - the serial
535
+ // machine must still not be claiming order across parallel entries.
536
+ expect(b?.state).not.toBe('before')
537
+ expect(svc?.state).not.toBe('after')
538
+ }
539
+ })
540
+ })
541
+
542
+ describe('a skipped route says why, from the exact vantage that skipped it', () => {
543
+ const reason = "HTTPS backend - the API-server proxy speaks plain HTTP and can't verify TLS on this port. Test it directly."
544
+ const skipped = (target = 'shop:443'): RouteResult =>
545
+ route({ target, outcome: 'not-tested', confidence: undefined, evidence: reason, byVantage: undefined })
546
+ const mkSkipped = (skipProbe: Partial<ProbeResult>, runVantage: 'local' | 'in-cluster' = 'local'): Trace => {
547
+ const t = mk([pod('shop-1', true, '10.0.0.1')], [])
548
+ t.verdict = 'unknown'
549
+ t.runVantage = runVantage
550
+ t.coverage = { tested: 0, passed: 0, failed: 0, skipped: 1 }
551
+ t.routes = [skipped()]
552
+ t.downstream![0].probes = [p({ target: 'shop:443', port: 443, skipped: true, reason, ok: false, ...skipProbe })]
553
+ return t
554
+ }
555
+ const proxySkip = { vantage: 'local' as const, path: 'apiserver' as const }
556
+
557
+ it('surfaces the skip reason under the origin whose dial was skipped', () => {
558
+ const s = buildSidebar(undefined, ctx(mkSkipped(proxySkip), 'apiserver', skipped()))
559
+ expect(s.path.evidence.map((e) => e.text).join('\n')).toContain('API-server proxy speaks plain HTTP')
560
+ })
561
+
562
+ it('keeps the proxy reason under the proxy even when the run came from inside the cluster', () => {
563
+ const s = buildSidebar(undefined, ctx(mkSkipped(proxySkip, 'in-cluster'), 'apiserver', skipped()))
564
+ expect(s.path.evidence.map((e) => e.text).join('\n')).toContain('API-server proxy speaks plain HTTP')
565
+ })
566
+
567
+ it('never charges a vantage whose own dial was not the one skipped', () => {
568
+ const t = mkSkipped(proxySkip)
569
+ for (const id of ['local', 'incluster']) {
570
+ const texts = buildSidebar(undefined, ctx(t, id, skipped())).path.evidence.map((e) => e.text).join('\n')
571
+ expect(texts).not.toContain('speaks plain HTTP')
572
+ }
573
+ })
574
+
575
+ it("never borrows another port's reason", () => {
576
+ const t = mkSkipped({ ...proxySkip, port: 80, target: 'shop:80' })
577
+ const texts = buildSidebar(undefined, ctx(t, 'apiserver', skipped('shop:443'))).path.evidence.map((e) => e.text).join('\n')
578
+ expect(texts).not.toContain('API-server proxy')
579
+ })
580
+
581
+ it('a portless skip speaks for every port', () => {
582
+ const t = mkSkipped({ ...proxySkip, port: undefined })
583
+ const texts = buildSidebar(undefined, ctx(t, 'apiserver', skipped())).path.evidence.map((e) => e.text).join('\n')
584
+ expect(texts).toContain('API-server proxy speaks plain HTTP')
585
+ })
586
+
587
+ it('says out loud that dots are health, not results, when nothing anywhere was tested', () => {
588
+ const s = buildSidebar(undefined, ctx(mkSkipped(proxySkip), 'apiserver', skipped()))
589
+ expect(s.path.body).toContain('cluster state, not a test result')
590
+ })
591
+ })
592
+
593
+ describe('an attempted in-cluster run that never started keeps its attempt', () => {
594
+ it('WHAT WE SAW carries the execution error, not "no test has been run"', () => {
595
+ const t = mk([pod('shop-1', true, '10.0.0.1')], [])
596
+ const r = route({ outcome: 'not-tested', confidence: undefined, byVantage: undefined })
597
+ t.routes = [r]
598
+ const origins = buildOrigins(t, { inClusterRunError: 'the probe image could not be pulled' })
599
+ const origin = origins.find((o) => o.id === 'incluster')!
600
+ const g = buildGraph({ trace: t, route: r, origin })
601
+ const s = buildSidebar(undefined, {
602
+ trace: t, route: r, origin, origins,
603
+ nodes: g.nodes, breakNodeId: g.breakNodeId, breakAtExitOf: g.breakAtExitOf,
604
+ nonNetworkNodeIds: g.nonNetworkNodeIds, contextNodeIds: g.contextNodeIds, interleave: g.interleave,
605
+ entryParallelCount: g.entryParallelCount, journeyEntryNodeIds: g.journeyEntryNodeIds, pathNodeIds: g.pathNodeIds,
606
+ })
607
+ const texts = s.path.evidence.map((e) => e.text).join('\n')
608
+ expect(texts).toContain('image could not be pulled')
609
+ expect(texts).not.toContain('no test has been run from here')
610
+ })
611
+ })
612
+
613
+ describe('a vantage that cannot be used explains itself in WHAT WE SAW', () => {
614
+ it('the laptop with nothing to dial shows the structural reason, not "no test has been run"', () => {
615
+ const t = mk([pod('a', true, '10.0.0.1')], [])
616
+ t.verdict = 'unknown'
617
+ t.routes = [route({ outcome: 'not-tested', confidence: undefined, byVantage: undefined })]
618
+ t.downstream![0].probes = [p({ target: 'shop:80', port: 80, vantage: 'local', path: 'apiserver', skipped: true, reason: 'proxy said no', ok: false })]
619
+ const s = buildSidebar(undefined, ctx(t, 'local', t.routes[0]))
620
+ const texts = s.path.evidence.map((e) => e.text).join('\n')
621
+ expect(texts).toMatch(/no entry point .* from your machine|no Ingress/i)
622
+ expect(texts).not.toContain('no test has been run from here')
623
+ })
624
+ })
625
+
626
+ describe('a proxy-only failure never claims the target answered', () => {
627
+ it('the body states the relayed dial failed and the real path stays untested', () => {
628
+ const t = mk([pod('a', true, '10.0.0.1')], [p({ path: 'apiserver', ok: false, tone: 'unhealthy' })])
629
+ const r = route({ outcome: 'unreachable', confidence: 'indirect' })
630
+ t.routes = [r]
631
+ const s = buildSidebar(undefined, ctx(t, 'apiserver', r))
632
+ expect(s.path.body).toMatch(/relayed dial failed/i)
633
+ expect(s.path.body).not.toMatch(/target answered/i)
634
+ })
635
+ })
636
+
637
+ describe('the terminal "nothing to do" state is quiet', () => {
638
+ it('no call-to-action styling, no duplicate Re-run, no repeated ceiling caveat', () => {
639
+ const t = mk([pod('a', true, '10.0.0.1')], [p({})])
640
+ const s = buildSidebar(undefined, ctx(t, 'incluster'))
641
+ expect(s.path.next.quiet).toBe(true)
642
+ expect(s.path.next.ctas).toHaveLength(0)
643
+ expect(s.path.next.body).toMatch(/strongest evidence/i)
644
+ expect(s.path.next.blocked).toBeUndefined()
645
+ })
646
+ })
647
+
648
+ describe('a fresh failed attempt outranks leftover skip rows', () => {
649
+ it("WHAT WE SAW shows this run's execution error, not last run's skip", () => {
650
+ const t = mk([pod('a', true, '10.0.0.1')], [])
651
+ const r = route({ target: 'shop:443', outcome: 'not-tested', confidence: undefined, byVantage: undefined })
652
+ t.routes = [r]
653
+ t.downstream![1].probes = [
654
+ 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 }),
655
+ ]
656
+ const origins = buildOrigins(t, { inClusterRunError: 'the probe image could not be pulled', route: r })
657
+ const origin = origins.find((o) => o.id === 'incluster')!
658
+ const g = buildGraph({ trace: t, route: r, origin })
659
+ const s = buildSidebar(undefined, {
660
+ trace: t, route: r, origin, origins,
661
+ nodes: g.nodes, breakNodeId: g.breakNodeId, breakAtExitOf: g.breakAtExitOf,
662
+ nonNetworkNodeIds: g.nonNetworkNodeIds, contextNodeIds: g.contextNodeIds, interleave: g.interleave,
663
+ entryParallelCount: g.entryParallelCount, journeyEntryNodeIds: g.journeyEntryNodeIds, pathNodeIds: g.pathNodeIds,
664
+ })
665
+ const texts = s.path.evidence.map((e) => e.text).join('\n')
666
+ expect(texts).toContain('image could not be pulled')
667
+ expect(texts).not.toContain('stale skip')
668
+ })
669
+ })