@spunto/design-system 0.34.0 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,255 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { fireEvent, render, screen } from "@testing-library/react"
3
+ import {
4
+ byRoute,
5
+ byService,
6
+ computeStats,
7
+ exampleTraces,
8
+ filterTraces,
9
+ formatLatency,
10
+ latencyTone,
11
+ methodOf,
12
+ orderSpans,
13
+ percentile,
14
+ routeOf,
15
+ severityClass,
16
+ SpanWaterfall,
17
+ spanTree,
18
+ timeBuckets,
19
+ TracesOverview,
20
+ type TraceItem,
21
+ type TraceSpan,
22
+ } from "./index"
23
+
24
+ const NOW = Date.parse("2026-09-28T12:00:00Z")
25
+ const MIN = 60_000
26
+
27
+ function trace(id: string, name: string, durationMs: number, agoMs: number, extra: Partial<TraceItem> = {}): TraceItem {
28
+ return { traceId: id, name, service: "spunto-api", startTimeMs: NOW - agoMs, durationMs, ...extra }
29
+ }
30
+
31
+ describe("analytics", () => {
32
+ it("sépare méthode et route, et laisse entier un nom qui n'est pas HTTP", () => {
33
+ expect(methodOf("GET /api/orgs/:orgId")).toBe("GET")
34
+ expect(routeOf("GET /api/orgs/:orgId")).toBe("/api/orgs/:orgId")
35
+ expect(methodOf("job.worker.create")).toBe("—")
36
+ expect(routeOf("job.worker.create")).toBe("job.worker.create")
37
+ })
38
+
39
+ it("interpole les percentiles", () => {
40
+ expect(percentile([], 95)).toBe(0)
41
+ expect(percentile([10], 95)).toBe(10)
42
+ expect(percentile([0, 100], 50)).toBe(50)
43
+ expect(percentile([1, 2, 3, 4, 5], 100)).toBe(5)
44
+ })
45
+
46
+ it("compte les erreurs et le max", () => {
47
+ const s = computeStats([trace("a", "GET /x", 10, 0), trace("b", "GET /x", 30, 0, { error: true })])
48
+ expect(s).toMatchObject({ count: 2, errorCount: 1, errorRate: 0.5, max: 30 })
49
+ })
50
+
51
+ it("trie les routes par p95 décroissant, les services par volume", () => {
52
+ const set = [
53
+ trace("a", "GET /fast", 5, 0),
54
+ trace("b", "GET /fast", 7, 0),
55
+ trace("c", "POST /slow", 900, 0, { service: "spunto-proxy" }),
56
+ ]
57
+ expect(byRoute(set).map((r) => r.name)).toEqual(["POST /slow", "GET /fast"])
58
+ expect(byService(set).map((s) => s.service)).toEqual(["spunto-api", "spunto-proxy"])
59
+ expect(byService([trace("x", "GET /", 1, 0, { service: null })])[0].service).toBe("(unknown)")
60
+ })
61
+
62
+ it("répartit dans la fenêtre, et ramène au bord ce qui en déborde au lieu de le perdre", () => {
63
+ const b = timeBuckets([trace("old", "GET /", 1, 90 * MIN), trace("new", "GET /", 1, 1 * MIN)], 60, 6, NOW)
64
+ expect(b).toHaveLength(6)
65
+ expect(b[0].total).toBe(1)
66
+ expect(b[5].total).toBe(1)
67
+ expect(b.reduce((n, x) => n + x.total, 0)).toBe(2)
68
+ })
69
+
70
+ it("choisit les exemples : erreurs, puis lentes, puis récentes, sans doublon", () => {
71
+ const set = [trace("e", "GET /", 1, 5, { error: true }), trace("slow", "GET /", 500, 10), trace("recent", "GET /", 2, 0)]
72
+ const ids = exampleTraces(set, 4).map((t) => t.traceId)
73
+ expect(ids[0]).toBe("e")
74
+ expect(new Set(ids).size).toBe(ids.length)
75
+ expect(ids).toContain("slow")
76
+ expect(ids).toContain("recent")
77
+ })
78
+
79
+ it("filtre côté client sur statut, méthode et route", () => {
80
+ const set = [trace("a", "GET /x", 1, 0), trace("b", "POST /x", 1, 0, { error: true }), trace("c", "GET /y", 1, 0)]
81
+ expect(filterTraces(set, { status: "errors", method: "all", route: "all" }).map((t) => t.traceId)).toEqual(["b"])
82
+ expect(filterTraces(set, { status: "all", method: "GET", route: "/x" }).map((t) => t.traceId)).toEqual(["a"])
83
+ })
84
+ })
85
+
86
+ describe("latence", () => {
87
+ it("garde la précision d'un span sous la milliseconde", () => {
88
+ expect(formatLatency(0.42)).toBe("0.42ms")
89
+ expect(formatLatency(38.4)).toBe("38ms")
90
+ expect(formatLatency(1234)).toBe("1.23s")
91
+ })
92
+
93
+ it("a un seul jeu de seuils", () => {
94
+ expect(latencyTone(299)).toBe("fast")
95
+ expect(latencyTone(300)).toBe("slow")
96
+ expect(latencyTone(1000)).toBe("critical")
97
+ })
98
+ })
99
+
100
+ const SPANS: TraceSpan[] = [
101
+ { spanId: "db", parentSpanId: "root", name: "pg.query", startTimeMs: 20, durationMs: 5, attributes: { "db.system": "postgresql" } },
102
+ { spanId: "root", parentSpanId: null, name: "GET /api/x", startTimeMs: 0, durationMs: 100, service: "spunto-api" },
103
+ { spanId: "rpc", parentSpanId: "root", name: "agent.cmd spawn", startTimeMs: 10, durationMs: 50 },
104
+ { spanId: "leaf", parentSpanId: "rpc", name: "docker.create", startTimeMs: 12, durationMs: 30 },
105
+ { spanId: "orphan", parentSpanId: "not-in-trace", name: "late", startTimeMs: 90, durationMs: 1 },
106
+ ]
107
+
108
+ describe("orderSpans", () => {
109
+ it("met chaque enfant sous son parent, les frères par heure de début", () => {
110
+ expect(orderSpans(SPANS).map((o) => `${o.span.spanId}:${o.depth}`)).toEqual([
111
+ "root:0",
112
+ "rpc:1",
113
+ "leaf:2",
114
+ "db:1",
115
+ "orphan:0",
116
+ ])
117
+ })
118
+
119
+ it("ne boucle pas sur un span qui est son propre parent", () => {
120
+ const loop: TraceSpan[] = [{ spanId: "a", parentSpanId: "a", name: "a", startTimeMs: 0, durationMs: 1 }]
121
+ // Son parent est dans le jeu, donc ce n'est pas une racine : rien à dessiner, mais pas de boucle.
122
+ expect(orderSpans(loop)).toEqual([])
123
+ })
124
+ })
125
+
126
+ describe("SpanWaterfall", () => {
127
+ it("dessine les spans, et montre les attributs d'un span cliqué", () => {
128
+ render(<SpanWaterfall traceId="abc123" spans={SPANS} />)
129
+ expect(screen.getByText("abc123")).toBeTruthy()
130
+ expect(screen.getByText("5 spans")).toBeTruthy()
131
+ expect(screen.queryByText("postgresql")).toBeNull()
132
+ fireEvent.click(screen.getByText("pg.query"))
133
+ expect(screen.getByText("postgresql")).toBeTruthy()
134
+ })
135
+
136
+ it("relie une ligne de log à son span", () => {
137
+ render(
138
+ <SpanWaterfall
139
+ spans={SPANS}
140
+ logs={[{ timestamp: new Date(25).toISOString(), body: "\u001b[31mslow query\u001b[0m", spanId: "db", severity: "WARN" }]}
141
+ />
142
+ )
143
+ expect(screen.getByText("1 line with this trace id")).toBeTruthy()
144
+ fireEvent.click(screen.getByText("slow query"))
145
+ // La ligne a sélectionné le span : son détail est ouvert.
146
+ expect(screen.getByText("postgresql")).toBeTruthy()
147
+ })
148
+
149
+ it("dit quand aucun log ne porte ce trace id", () => {
150
+ render(<SpanWaterfall spans={SPANS} logs={[]} />)
151
+ expect(screen.getByText(/No log line carries this trace id/)).toBeTruthy()
152
+ })
153
+ })
154
+
155
+ describe("TracesOverview", () => {
156
+ it("n'a de lignes cliquables que si on lui donne onRouteClick", () => {
157
+ const set = [trace("a", "GET /x", 10, MIN)]
158
+ const { rerender } = render(<TracesOverview traces={set} lookbackMinutes={60} now={NOW} />)
159
+ expect(screen.getByText("/x").closest("button")).toBeNull()
160
+ let clicked = ""
161
+ rerender(<TracesOverview traces={set} lookbackMinutes={60} now={NOW} onRouteClick={(n) => (clicked = n)} />)
162
+ fireEvent.click(screen.getByText("/x"))
163
+ expect(clicked).toBe("GET /x")
164
+ })
165
+
166
+ it("n'affiche les services que sur demande", () => {
167
+ const set = [trace("a", "GET /x", 10, MIN, { service: "spunto-proxy" })]
168
+ const { rerender } = render(<TracesOverview traces={set} lookbackMinutes={60} now={NOW} />)
169
+ expect(screen.queryByText("spunto-proxy")).toBeNull()
170
+ rerender(<TracesOverview traces={set} lookbackMinutes={60} now={NOW} showServices />)
171
+ expect(screen.getByText("spunto-proxy")).toBeTruthy()
172
+ })
173
+ })
174
+
175
+ describe("SpanWaterfall — repli", () => {
176
+ it("sait ce que replier cacherait, erreurs comprises", () => {
177
+ const t = spanTree(orderSpans([...SPANS, { spanId: "boom", parentSpanId: "leaf", name: "x", startTimeMs: 13, durationMs: 1, error: true }]))
178
+ expect(t.get("root")).toMatchObject({ parentId: null, descendants: 4, descendantError: true })
179
+ expect(t.get("rpc")).toMatchObject({ parentId: "root", descendants: 2, descendantError: true })
180
+ expect(t.get("db")).toMatchObject({ parentId: "root", descendants: 0, descendantError: false })
181
+ })
182
+
183
+ it("replie un sous-arbre au chevron, sans sélectionner le span", () => {
184
+ render(<SpanWaterfall spans={SPANS} />)
185
+ expect(screen.getByText("docker.create")).toBeTruthy()
186
+ fireEvent.click(screen.getByLabelText("Collapse agent.cmd spawn"))
187
+ expect(screen.queryByText("docker.create")).toBeNull()
188
+ expect(screen.getByText("+1")).toBeTruthy()
189
+ // Le clic sur le chevron n'a pas ouvert le détail du span.
190
+ expect(screen.queryByText("kind")).toBeNull()
191
+ fireEvent.click(screen.getByLabelText("Expand agent.cmd spawn"))
192
+ expect(screen.getByText("docker.create")).toBeTruthy()
193
+ })
194
+
195
+ it("replie et déplie tout", () => {
196
+ render(<SpanWaterfall spans={SPANS} />)
197
+ fireEvent.click(screen.getByText("Collapse all"))
198
+ expect(screen.queryByText("pg.query")).toBeNull()
199
+ expect(screen.getByText("GET /api/x")).toBeTruthy()
200
+ expect(screen.getByText("late")).toBeTruthy()
201
+ fireEvent.click(screen.getByText("Expand all"))
202
+ expect(screen.getByText("docker.create")).toBeTruthy()
203
+ })
204
+
205
+ it("part replié à partir d'une profondeur", () => {
206
+ render(<SpanWaterfall spans={SPANS} defaultCollapsedDepth={1} />)
207
+ expect(screen.getByText("agent.cmd spawn")).toBeTruthy()
208
+ expect(screen.queryByText("docker.create")).toBeNull()
209
+ })
210
+
211
+ it("se replie au clavier", () => {
212
+ render(<SpanWaterfall spans={SPANS} />)
213
+ const row = screen.getByText("agent.cmd spawn").closest("[role=button]") as HTMLElement
214
+ fireEvent.keyDown(row, { key: "ArrowLeft" })
215
+ expect(screen.queryByText("docker.create")).toBeNull()
216
+ fireEvent.keyDown(row, { key: "ArrowRight" })
217
+ expect(screen.getByText("docker.create")).toBeTruthy()
218
+ fireEvent.keyDown(row, { key: "Enter" })
219
+ expect(row.getAttribute("aria-pressed")).toBe("true")
220
+ })
221
+
222
+ it("déplie les ancêtres d'un span choisi depuis une ligne de log", () => {
223
+ render(
224
+ <SpanWaterfall
225
+ spans={SPANS}
226
+ defaultCollapsedDepth={0}
227
+ logs={[{ timestamp: new Date(20).toISOString(), body: "creating container", spanId: "leaf" }]}
228
+ />
229
+ )
230
+ expect(screen.queryByText("docker.create")).toBeNull()
231
+ fireEvent.click(screen.getByText("creating container"))
232
+ expect(screen.getAllByText("docker.create").length).toBeGreaterThan(0)
233
+ expect(screen.getByText("agent.cmd spawn")).toBeTruthy()
234
+ })
235
+
236
+ it("se laisse contrôler", () => {
237
+ let last: string[] = []
238
+ render(<SpanWaterfall spans={SPANS} collapsedSpanIds={["root"]} onCollapsedSpanIdsChange={(ids) => (last = ids)} />)
239
+ expect(screen.queryByText("agent.cmd spawn")).toBeNull()
240
+ fireEvent.click(screen.getByLabelText("Expand GET /api/x"))
241
+ expect(last).toEqual([])
242
+ // Contrôlé : tant que le parent ne change pas la prop, rien ne bouge.
243
+ expect(screen.queryByText("agent.cmd spawn")).toBeNull()
244
+ })
245
+ })
246
+
247
+ describe("severityClass", () => {
248
+ it("colore en rouge ce qui est grave, quel que soit le vocabulaire du logger", () => {
249
+ for (const sev of ["ERROR", "error", "ERROR2", "FATAL", "CRITICAL", "critical"]) expect(severityClass(sev)).toBe("text-destructive")
250
+ expect(severityClass("WARN")).toContain("amber")
251
+ expect(severityClass("WARNING")).toContain("amber")
252
+ expect(severityClass("INFO")).toBe("text-muted-foreground")
253
+ expect(severityClass(null)).toBe("text-muted-foreground")
254
+ })
255
+ })