@skyhook-io/radar-app 1.13.4 → 1.13.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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/RadarApp.tsx +5 -0
  3. package/src/api/client.ts +4 -0
  4. package/src/api/diagnose.ts +61 -15
  5. package/src/components/ConnectionErrorView.test.tsx +30 -0
  6. package/src/components/ConnectionErrorView.tsx +31 -19
  7. package/src/components/diagnose/AgentCase.tsx +131 -0
  8. package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
  9. package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
  10. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
  11. package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
  12. package/src/components/diagnose/InvestigationView.tsx +222 -5
  13. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  14. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  15. package/src/components/diagnose/investigationCase.ts +439 -0
  16. package/src/components/diagnose/investigationEvidence.test.ts +1566 -151
  17. package/src/components/diagnose/investigationEvidence.ts +831 -43
  18. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  19. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  20. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  21. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  22. package/src/components/diagnose/investigationMetrics.ts +393 -0
  23. package/src/components/diagnose/investigationSourceFocus.ts +2 -0
  24. package/src/components/diagnose/investigationState.test.ts +322 -0
  25. package/src/components/diagnose/investigationState.ts +147 -25
  26. package/src/components/diagnose/parts.test.tsx +482 -0
  27. package/src/components/diagnose/parts.tsx +418 -41
  28. package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
  29. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  30. package/src/components/resources/PodFilePreview.tsx +394 -0
  31. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  32. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  33. package/src/context/DiagnoseCustomization.tsx +14 -2
  34. package/src/index.ts +1 -0
  35. package/src/utils/shell-safe.test.ts +25 -1
  36. package/src/utils/shell-safe.ts +16 -0
@@ -1,12 +1,17 @@
1
- import { useMemo, useState } from 'react'
2
- import { Loader2, Wifi, WifiOff } from 'lucide-react'
1
+ import { useMemo, useState } from "react";
2
+ import { Loader2, Wifi, WifiOff } from "lucide-react";
3
3
  import {
4
4
  AreaChart,
5
5
  SeriesLegend,
6
6
  computeSaturation,
7
+ describePodCoverage,
7
8
  type ReferenceLine,
8
- } from '@skyhook-io/k8s-ui/components/charts'
9
- import { SEVERITY_BADGE, SEVERITY_TEXT, type Severity } from '@skyhook-io/k8s-ui/utils/badge-colors'
9
+ } from "@skyhook-io/k8s-ui/components/charts";
10
+ import {
11
+ SEVERITY_BADGE,
12
+ SEVERITY_TEXT,
13
+ type Severity,
14
+ } from "@skyhook-io/k8s-ui/utils/badge-colors";
10
15
  import {
11
16
  usePrometheusStatus,
12
17
  usePrometheusConnect,
@@ -14,7 +19,7 @@ import {
14
19
  useAutoPromConnect,
15
20
  type PrometheusMetricCategory,
16
21
  type PrometheusTimeRange,
17
- } from '../../api/client'
22
+ } from "../../api/client";
18
23
  import {
19
24
  MetricsSummary,
20
25
  TIME_RANGES,
@@ -22,24 +27,31 @@ import {
22
27
  NODE_CATEGORIES,
23
28
  computeRequestLimitLines,
24
29
  type CategoryDef,
25
- } from './PrometheusCharts'
26
- import { RestartEventLane } from './RestartChart'
27
- import { Tooltip } from '../ui/Tooltip'
30
+ } from "./PrometheusCharts";
31
+ import { RestartEventLane } from "./RestartChart";
32
+ import { Tooltip } from "../ui/Tooltip";
28
33
 
29
34
  // Used when MetricsTabContent is in expanded (full-screen) mode. Drawer mode
30
35
  // uses the single-chart tabbed `PrometheusCharts` instead — drawer width
31
36
  // can't fit the grid cleanly.
32
37
  export interface PrometheusChartsGridProps {
33
- kind: string
34
- namespace: string
35
- name: string
38
+ kind: string;
39
+ namespace: string;
40
+ name: string;
36
41
  /** Optional full K8s resource for request/limit overlay derivation. */
37
- resource?: any
42
+ resource?: any;
38
43
  }
39
44
 
40
45
  const SUPPORTED_KINDS = new Set([
41
- 'Pod', 'Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet', 'Job', 'CronJob', 'Node',
42
- ])
46
+ "Pod",
47
+ "Deployment",
48
+ "StatefulSet",
49
+ "DaemonSet",
50
+ "ReplicaSet",
51
+ "Job",
52
+ "CronJob",
53
+ "Node",
54
+ ]);
43
55
 
44
56
  export function PrometheusChartsGrid({
45
57
  kind,
@@ -47,29 +59,31 @@ export function PrometheusChartsGrid({
47
59
  name,
48
60
  resource,
49
61
  }: PrometheusChartsGridProps) {
50
- useAutoPromConnect()
51
- const { data: status, isLoading: statusLoading } = usePrometheusStatus()
52
- const connectMutation = usePrometheusConnect()
53
- const isConnected = status?.connected === true
54
- const isSupported = SUPPORTED_KINDS.has(kind)
55
- const showRestartLane = isSupported && kind !== 'Node'
62
+ useAutoPromConnect();
63
+ const { data: status, isLoading: statusLoading } = usePrometheusStatus();
64
+ const connectMutation = usePrometheusConnect();
65
+ const isConnected = status?.connected === true;
66
+ const isSupported = SUPPORTED_KINDS.has(kind);
67
+ const showRestartLane = isSupported && kind !== "Node";
56
68
 
57
- const [timeRange, setTimeRange] = useState<PrometheusTimeRange>('1h')
69
+ const [timeRange, setTimeRange] = useState<PrometheusTimeRange>("1h");
58
70
 
59
- const categories = kind === 'Node' ? NODE_CATEGORIES : WORKLOAD_CATEGORIES
71
+ const categories = kind === "Node" ? NODE_CATEGORIES : WORKLOAD_CATEGORIES;
60
72
 
61
73
  // CPU + memory get reference-line overlays when a resource is provided.
62
74
  // Computed once at the parent so each panel can stay otherwise generic.
63
75
  const cpuRefLines = useMemo<ReferenceLine[] | undefined>(
64
- () => (resource ? computeRequestLimitLines(resource, kind, 'cpu') : undefined),
76
+ () =>
77
+ resource ? computeRequestLimitLines(resource, kind, "cpu") : undefined,
65
78
  [resource, kind],
66
- )
79
+ );
67
80
  const memRefLines = useMemo<ReferenceLine[] | undefined>(
68
- () => (resource ? computeRequestLimitLines(resource, kind, 'memory') : undefined),
81
+ () =>
82
+ resource ? computeRequestLimitLines(resource, kind, "memory") : undefined,
69
83
  [resource, kind],
70
- )
84
+ );
71
85
 
72
- if (!isSupported) return null
86
+ if (!isSupported) return null;
73
87
 
74
88
  if (statusLoading) {
75
89
  return (
@@ -77,7 +91,7 @@ export function PrometheusChartsGrid({
77
91
  <Loader2 className="w-5 h-5 animate-spin mr-2" />
78
92
  Checking Prometheus availability...
79
93
  </div>
80
- )
94
+ );
81
95
  }
82
96
 
83
97
  if (!isConnected) {
@@ -85,9 +99,12 @@ export function PrometheusChartsGrid({
85
99
  <div className="flex flex-col items-center justify-center py-12 gap-4">
86
100
  <WifiOff className="w-10 h-10 text-theme-text-quaternary" />
87
101
  <div className="text-center">
88
- <p className="text-sm text-theme-text-secondary mb-1">Prometheus not connected</p>
102
+ <p className="text-sm text-theme-text-secondary mb-1">
103
+ Prometheus not connected
104
+ </p>
89
105
  <p className="text-xs text-theme-text-tertiary mb-4">
90
- {status?.error || 'Connect to view historical CPU, memory, and network metrics'}
106
+ {status?.error ||
107
+ "Connect to view historical CPU, memory, and network metrics"}
91
108
  </p>
92
109
  <button
93
110
  onClick={() => connectMutation.mutate()}
@@ -103,25 +120,26 @@ export function PrometheusChartsGrid({
103
120
  </button>
104
121
  </div>
105
122
  </div>
106
- )
123
+ );
107
124
  }
108
125
 
109
- const findCategory = (key: PrometheusMetricCategory): CategoryDef | undefined =>
110
- categories.find(c => c.key === key)
126
+ const findCategory = (
127
+ key: PrometheusMetricCategory,
128
+ ): CategoryDef | undefined => categories.find((c) => c.key === key);
111
129
 
112
- const primaryCats: { def: CategoryDef; refLines?: ReferenceLine[] }[] = []
113
- const cpu = findCategory('cpu')
114
- if (cpu) primaryCats.push({ def: cpu, refLines: cpuRefLines })
115
- const mem = findCategory('memory')
116
- if (mem) primaryCats.push({ def: mem, refLines: memRefLines })
117
- if (kind !== 'Node') {
118
- const rx = findCategory('network_rx')
119
- if (rx) primaryCats.push({ def: rx })
120
- const tx = findCategory('network_tx')
121
- if (tx) primaryCats.push({ def: tx })
130
+ const primaryCats: { def: CategoryDef; refLines?: ReferenceLine[] }[] = [];
131
+ const cpu = findCategory("cpu");
132
+ if (cpu) primaryCats.push({ def: cpu, refLines: cpuRefLines });
133
+ const mem = findCategory("memory");
134
+ if (mem) primaryCats.push({ def: mem, refLines: memRefLines });
135
+ if (kind !== "Node") {
136
+ const rx = findCategory("network_rx");
137
+ if (rx) primaryCats.push({ def: rx });
138
+ const tx = findCategory("network_tx");
139
+ if (tx) primaryCats.push({ def: tx });
122
140
  }
123
- const disk = findCategory('filesystem')
124
- const chartPanels = disk ? [...primaryCats, { def: disk }] : primaryCats
141
+ const disk = findCategory("filesystem");
142
+ const chartPanels = disk ? [...primaryCats, { def: disk }] : primaryCats;
125
143
 
126
144
  return (
127
145
  <div className="flex flex-col h-full overflow-auto">
@@ -129,11 +147,13 @@ export function PrometheusChartsGrid({
129
147
  <select
130
148
  aria-label="Metrics time range"
131
149
  value={timeRange}
132
- onChange={e => setTimeRange(e.target.value as PrometheusTimeRange)}
150
+ onChange={(e) => setTimeRange(e.target.value as PrometheusTimeRange)}
133
151
  className="rounded-md border border-theme-border bg-theme-elevated px-2 py-1 text-xs text-theme-text-secondary shadow-theme-sm focus:outline-none focus:ring-1 focus:ring-accent/50"
134
152
  >
135
- {TIME_RANGES.map(tr => (
136
- <option key={tr.value} value={tr.value}>{tr.label}</option>
153
+ {TIME_RANGES.map((tr) => (
154
+ <option key={tr.value} value={tr.value}>
155
+ {tr.label}
156
+ </option>
137
157
  ))}
138
158
  </select>
139
159
  </div>
@@ -142,11 +162,16 @@ export function PrometheusChartsGrid({
142
162
  the time axis of the charts below. */}
143
163
  {showRestartLane && (
144
164
  <div className="px-4 pt-3">
145
- <RestartEventLane kind={kind} namespace={namespace} name={name} range={timeRange} />
165
+ <RestartEventLane
166
+ kind={kind}
167
+ namespace={namespace}
168
+ name={name}
169
+ range={timeRange}
170
+ />
146
171
  </div>
147
172
  )}
148
173
 
149
- <div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-4">
174
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3 px-4 pt-4">
150
175
  {chartPanels.map(({ def, refLines }) => (
151
176
  <MetricsPanel
152
177
  key={def.key}
@@ -159,33 +184,85 @@ export function PrometheusChartsGrid({
159
184
  />
160
185
  ))}
161
186
  </div>
187
+
188
+ {/* Every chart here covers the same pods, so which pods those are is
189
+ stated once for the grid rather than repeated under each one. */}
190
+ <PodCoverageNote
191
+ kind={kind}
192
+ namespace={namespace}
193
+ name={name}
194
+ timeRange={timeRange}
195
+ />
162
196
  </div>
163
- )
197
+ );
198
+ }
199
+
200
+ function PodCoverageNote({
201
+ kind,
202
+ namespace,
203
+ name,
204
+ timeRange,
205
+ }: {
206
+ kind: string;
207
+ namespace: string;
208
+ name: string;
209
+ timeRange: PrometheusTimeRange;
210
+ }) {
211
+ // Any category answers this: the pod scope is per workload, not per metric.
212
+ // This is the same query key the CPU panel uses, so it is already cached.
213
+ const { data } = usePrometheusResourceMetrics(
214
+ kind,
215
+ namespace,
216
+ name,
217
+ "cpu",
218
+ timeRange,
219
+ true,
220
+ );
221
+ const note = data ? describePodCoverage(data) : undefined;
222
+ if (!note) return null;
223
+ return <p className="px-4 pb-4 text-xs text-theme-text-tertiary">{note}</p>;
164
224
  }
165
225
 
166
226
  interface MetricsPanelProps {
167
- category: CategoryDef
168
- kind: string
169
- namespace: string
170
- name: string
171
- timeRange: PrometheusTimeRange
172
- referenceLines?: ReferenceLine[]
227
+ category: CategoryDef;
228
+ kind: string;
229
+ namespace: string;
230
+ name: string;
231
+ timeRange: PrometheusTimeRange;
232
+ referenceLines?: ReferenceLine[];
173
233
  }
174
234
 
175
- function MetricsPanel({ category, kind, namespace, name, timeRange, referenceLines }: MetricsPanelProps) {
176
- const { data: metrics, isLoading, error } = usePrometheusResourceMetrics(
177
- kind, namespace, name, category.key, timeRange, true,
178
- )
235
+ function MetricsPanel({
236
+ category,
237
+ kind,
238
+ namespace,
239
+ name,
240
+ timeRange,
241
+ referenceLines,
242
+ }: MetricsPanelProps) {
243
+ const {
244
+ data: metrics,
245
+ isLoading,
246
+ error,
247
+ } = usePrometheusResourceMetrics(
248
+ kind,
249
+ namespace,
250
+ name,
251
+ category.key,
252
+ timeRange,
253
+ true,
254
+ );
179
255
 
180
- const series = metrics?.result?.series
181
- const hasData = (series?.length ?? 0) > 0
256
+ const series = metrics?.result?.series;
257
+ const hasData = (series?.length ?? 0) > 0;
182
258
 
183
259
  // "% of limit / request" derived from current peak vs reference lines.
184
260
  // Without this, a low-utilization workload with a high limit looks like
185
261
  // an empty chart — the user can't tell healthy from starved at a glance.
186
- const saturation = hasData && series && referenceLines
187
- ? computeSaturation(series, referenceLines)
188
- : undefined
262
+ const saturation =
263
+ hasData && series && referenceLines
264
+ ? computeSaturation(series, referenceLines)
265
+ : undefined;
189
266
 
190
267
  return (
191
268
  <section className="rounded-lg border border-theme-border bg-theme-surface/30 p-3 flex flex-col min-h-[260px]">
@@ -197,7 +274,11 @@ function MetricsPanel({ category, kind, namespace, name, timeRange, referenceLin
197
274
  {saturation && <SaturationChip {...saturation} />}
198
275
  </div>
199
276
  {hasData && series && (
200
- <MetricsSummary series={series} category={category} unit={metrics!.unit} />
277
+ <MetricsSummary
278
+ series={series}
279
+ category={category}
280
+ unit={metrics!.unit}
281
+ />
201
282
  )}
202
283
  </header>
203
284
 
@@ -226,16 +307,31 @@ function MetricsPanel({ category, kind, namespace, name, timeRange, referenceLin
226
307
  )}
227
308
  </div>
228
309
  </section>
229
- )
310
+ );
230
311
  }
231
312
 
232
- function SaturationChip({ ratio, against }: { ratio: number; against: 'limit' | 'request' }) {
313
+ function SaturationChip({
314
+ ratio,
315
+ against,
316
+ }: {
317
+ ratio: number;
318
+ against: "limit" | "request";
319
+ }) {
233
320
  // Thresholds match the rightsizing tone vocabulary: amber from 75% (start
234
321
  // watching), red at 90% (the same OOM-risk boundary the backend uses for
235
322
  // memory in classifyRightsizing).
236
- const tone: Severity = ratio >= 0.9 ? 'error' : ratio >= 0.75 ? 'warning' : ratio < 0.05 ? 'info' : 'neutral'
237
- const label = `${(ratio * 100).toFixed(ratio < 0.1 ? 1 : 0)}% of ${against}`
238
- return <span className={`badge badge-sm ${SEVERITY_BADGE[tone]}`}>{label}</span>
323
+ const tone: Severity =
324
+ ratio >= 0.9
325
+ ? "error"
326
+ : ratio >= 0.75
327
+ ? "warning"
328
+ : ratio < 0.05
329
+ ? "info"
330
+ : "neutral";
331
+ const label = `${(ratio * 100).toFixed(ratio < 0.1 ? 1 : 0)}% of ${against}`;
332
+ return (
333
+ <span className={`badge badge-sm ${SEVERITY_BADGE[tone]}`}>{label}</span>
334
+ );
239
335
  }
240
336
 
241
337
  function PanelLoading() {
@@ -244,27 +340,33 @@ function PanelLoading() {
244
340
  <Loader2 className="w-4 h-4 animate-spin mr-2" />
245
341
  Loading…
246
342
  </div>
247
- )
343
+ );
248
344
  }
249
345
 
250
346
  function PanelError({ message }: { message: string }) {
251
347
  return (
252
- <div className={`flex flex-col items-center justify-center h-full min-h-[160px] ${SEVERITY_TEXT.warning} text-xs px-3 text-center`}>
348
+ <div
349
+ className={`flex flex-col items-center justify-center h-full min-h-[160px] ${SEVERITY_TEXT.warning} text-xs px-3 text-center`}
350
+ >
253
351
  Query failed
254
352
  <Tooltip content={message} wrapperClassName="!block w-full">
255
- <span className="text-theme-text-quaternary mt-0.5 line-clamp-2">{message}</span>
353
+ <span className="text-theme-text-quaternary mt-0.5 line-clamp-2">
354
+ {message}
355
+ </span>
256
356
  </Tooltip>
257
357
  </div>
258
- )
358
+ );
259
359
  }
260
360
 
261
361
  function PanelNoData({ hint }: { hint?: string }) {
262
362
  return (
263
363
  <div className="flex flex-col items-center justify-center h-full min-h-[160px] text-theme-text-tertiary text-xs px-3 text-center">
264
364
  No data
265
- {hint && <span className="text-theme-text-quaternary mt-1 max-w-xs">{hint}</span>}
365
+ {hint && (
366
+ <span className="text-theme-text-quaternary mt-1 max-w-xs">{hint}</span>
367
+ )}
266
368
  </div>
267
- )
369
+ );
268
370
  }
269
371
 
270
- export { isPrometheusSupported } from './PrometheusCharts'
372
+ export { isPrometheusSupported } from "./PrometheusCharts";
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { curateError, detectLanguage } from './PodFilePreview'
4
+
5
+ // Every backend error code becomes a curated screen. What matters here is
6
+ // that each code carries the right action affordances (Download vs Retry)
7
+ // and never leaks a raw stderr line as the primary description.
8
+
9
+ describe('curateError — action affordances per code', () => {
10
+ it('offers Download for file_too_large and names the actual size', () => {
11
+ const shape = curateError({
12
+ ok: false,
13
+ code: 'file_too_large',
14
+ message: 'File is 12582912 bytes; preview is limited to 1048576 bytes.',
15
+ size: 12 * 1024 * 1024,
16
+ })
17
+ expect(shape.actions).toEqual({ download: true, retry: false })
18
+ expect(shape.description).toMatch(/MiB|MB/i)
19
+ // The raw byte-count message must not be the primary description.
20
+ expect(shape.description).not.toContain('12582912')
21
+ })
22
+
23
+ it('offers Download for binary_file and names the detected mime type', () => {
24
+ const shape = curateError({
25
+ ok: false,
26
+ code: 'binary_file',
27
+ message: 'server-side detail',
28
+ mimeType: 'application/x-executable',
29
+ })
30
+ expect(shape.actions).toEqual({ download: true, retry: false })
31
+ expect(shape.description).toContain('application/x-executable')
32
+ expect(shape.description).not.toContain('server-side detail')
33
+ })
34
+
35
+ it('offers Retry for not_found — the file may have been rotated', () => {
36
+ const shape = curateError({ ok: false, code: 'not_found', message: 'x' })
37
+ expect(shape.actions.retry).toBe(true)
38
+ expect(shape.actions.download).toBe(false)
39
+ })
40
+
41
+ it('offers neither Download nor Retry for permission_denied', () => {
42
+ const shape = curateError({ ok: false, code: 'permission_denied', message: 'x' })
43
+ expect(shape.actions).toEqual({ download: false, retry: false })
44
+ })
45
+
46
+ it('offers neither for no_shell — the container cannot be read this way', () => {
47
+ const shape = curateError({ ok: false, code: 'no_shell', message: 'x' })
48
+ expect(shape.actions).toEqual({ download: false, retry: false })
49
+ expect(shape.description).toMatch(/distroless|scratch|no shell/i)
50
+ })
51
+
52
+ it('offers Retry for network_error and reveals details for diagnostics', () => {
53
+ const shape = curateError({
54
+ ok: false,
55
+ code: 'network_error',
56
+ message: 'Failed to fetch',
57
+ })
58
+ expect(shape.actions.retry).toBe(true)
59
+ expect(shape.details).toBe('Failed to fetch')
60
+ })
61
+
62
+ it('falls unknown/read_failed to a generic curated line with both actions', () => {
63
+ const shape = curateError({ ok: false, code: 'read_failed', message: 'stderr blob' })
64
+ expect(shape.actions).toEqual({ download: true, retry: true })
65
+ // The raw stderr belongs behind the details disclosure, not in the headline.
66
+ expect(shape.description).not.toContain('stderr blob')
67
+ expect(shape.details).toBe('stderr blob')
68
+ })
69
+ })
70
+
71
+ // The primary description is what an operator reads first. It must never be
72
+ // the raw server message on any curated code — that was the maintainer's
73
+ // specific concern ("curated error message rather than only dumping raw error").
74
+ describe('curateError — no raw messages in primary text', () => {
75
+ const rawStderr = 'sh: /etc/shadow: Permission denied — some raw text'
76
+ it.each([
77
+ 'file_too_large',
78
+ 'binary_file',
79
+ 'not_a_regular_file',
80
+ 'not_found',
81
+ 'permission_denied',
82
+ 'no_shell',
83
+ 'container_missing_tools',
84
+ 'read_failed',
85
+ ] as const)('%s does not leak the raw server message into the title or description', (code) => {
86
+ const shape = curateError({ ok: false, code, message: rawStderr })
87
+ expect(shape.title).not.toContain(rawStderr)
88
+ expect(shape.description).not.toContain(rawStderr)
89
+ })
90
+ })
91
+
92
+ describe('detectLanguage', () => {
93
+ it.each([
94
+ ['nginx.conf', 'ini'],
95
+ ['deployment.yaml', 'yaml'],
96
+ ['deployment.yml', 'yaml'],
97
+ ['schema.json', 'yaml'],
98
+ ['Dockerfile', 'dockerfile'],
99
+ ['index.html', 'html'],
100
+ ['README.md', 'markdown'],
101
+ ['app.log', 'plaintext'],
102
+ ['unknown', 'plaintext'],
103
+ ['UPPERCASE.YAML', 'yaml'],
104
+ ])('%s → %s', (name, want) => {
105
+ expect(detectLanguage(name)).toBe(want)
106
+ })
107
+ })
108
+
109
+ // The empty state and the loading state each render distinct content so an
110
+ // operator can tell them apart. These are the two states most likely to look
111
+ // like a bug if they collide.
112
+ describe('curated states are distinguishable', () => {
113
+ it('an empty file description is not confusable with a failure', () => {
114
+ const shape = curateError({ ok: false, code: 'read_failed', message: '' })
115
+ // The empty-file state is rendered separately (PreviewEmptyState) and does
116
+ // NOT come through curateError — a read_failed with an empty message must
117
+ // therefore never say "empty" in its curated description.
118
+ expect(shape.description.toLowerCase()).not.toContain('empty')
119
+ })
120
+ })
121
+
122
+ // Static markup smoke: the module imports at all and the exported pure helpers
123
+ // stay tree-shakeable / server-renderable. If curateError were to reach for
124
+ // browser-only globals this would trip.
125
+ describe('curated shapes render on the server', () => {
126
+ it('renders a title node without throwing in a server context', () => {
127
+ const shape = curateError({ ok: false, code: 'binary_file', message: 'x', mimeType: 'application/octet-stream' })
128
+ const html = renderToStaticMarkup(<span>{shape.title}</span>)
129
+ expect(html).toContain('Binary')
130
+ })
131
+ })