@kitlangton/motel 0.1.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 (53) hide show
  1. package/AGENTS.md +142 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -0
  4. package/package.json +92 -0
  5. package/src/App.tsx +217 -0
  6. package/src/cli.ts +258 -0
  7. package/src/config.ts +39 -0
  8. package/src/daemon.test.ts +59 -0
  9. package/src/daemon.ts +398 -0
  10. package/src/domain.ts +233 -0
  11. package/src/httpApi.ts +384 -0
  12. package/src/index.tsx +18 -0
  13. package/src/instructions.ts +72 -0
  14. package/src/localServer.ts +699 -0
  15. package/src/locator.ts +138 -0
  16. package/src/mcp.ts +260 -0
  17. package/src/motel.ts +86 -0
  18. package/src/motelClient.ts +201 -0
  19. package/src/otlp.ts +142 -0
  20. package/src/queryFilters.ts +39 -0
  21. package/src/registry.ts +86 -0
  22. package/src/runtime.ts +38 -0
  23. package/src/server.ts +10 -0
  24. package/src/services/LogQueryService.ts +43 -0
  25. package/src/services/TelemetryStore.ts +1821 -0
  26. package/src/services/TraceQueryService.ts +71 -0
  27. package/src/telemetry.test.ts +726 -0
  28. package/src/ui/ServiceLogs.tsx +112 -0
  29. package/src/ui/SpanDetail.tsx +134 -0
  30. package/src/ui/SpanDetailFull.tsx +224 -0
  31. package/src/ui/SpanDetailPane.tsx +91 -0
  32. package/src/ui/TraceDetailsPane.tsx +169 -0
  33. package/src/ui/TraceList.tsx +128 -0
  34. package/src/ui/Waterfall.tsx +412 -0
  35. package/src/ui/app/TraceListPane.tsx +34 -0
  36. package/src/ui/app/TraceWorkspace.tsx +254 -0
  37. package/src/ui/app/useAppLayout.ts +79 -0
  38. package/src/ui/app/useTraceScreenData.ts +411 -0
  39. package/src/ui/format.ts +119 -0
  40. package/src/ui/primitives.tsx +170 -0
  41. package/src/ui/state.ts +137 -0
  42. package/src/ui/theme.ts +153 -0
  43. package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
  44. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  45. package/src/ui/traceSortNav.repro.test.ts +220 -0
  46. package/src/ui/useKeyboardNav.ts +532 -0
  47. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  48. package/src/ui/waterfallNav.repro.test.ts +263 -0
  49. package/src/ui/waterfallNav.test.ts +422 -0
  50. package/src/ui/waterfallNav.ts +75 -0
  51. package/web/dist/assets/index-BEKIiisE.js +27 -0
  52. package/web/dist/assets/index-DzuHNBGV.css +2 -0
  53. package/web/dist/index.html +13 -0
@@ -0,0 +1,263 @@
1
+ /**
2
+ * End-to-end reproducer for the waterfall collapse/expand bug.
3
+ *
4
+ * Strategy:
5
+ * 1. Seed a deterministic trace with a parent span and three leaf children
6
+ * into a fresh SQLite database.
7
+ * 2. Launch the motel TUI under tuistory pointing at that database.
8
+ * 3. Drive keys to navigate onto the parent span, capture a baseline snapshot.
9
+ * 4. Press `h` (collapse) then `l` (expand). Capture again.
10
+ * 5. Assert: the visible waterfall body before === after. Today this fails
11
+ * because the first child silently disappears from the visible list after
12
+ * a collapse/expand cycle.
13
+ *
14
+ * Skipped automatically when `tuistory` is not installed.
15
+ */
16
+
17
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test"
18
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
19
+ import { tmpdir } from "node:os"
20
+ import { join } from "node:path"
21
+
22
+ const TUISTORY_BIN = "tuistory"
23
+ const SESSION = `motel-repro-${Date.now()}`
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // tuistory wrappers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const hasTuistory = async () => {
30
+ try {
31
+ const proc = Bun.spawn({ cmd: ["which", TUISTORY_BIN], stdout: "pipe", stderr: "ignore" })
32
+ const code = await proc.exited
33
+ return code === 0
34
+ } catch {
35
+ return false
36
+ }
37
+ }
38
+
39
+ const tui = async (args: readonly string[]): Promise<{ code: number; stdout: string; stderr: string }> => {
40
+ const proc = Bun.spawn({ cmd: [TUISTORY_BIN, ...args], stdout: "pipe", stderr: "pipe" })
41
+ const [stdout, stderr] = await Promise.all([
42
+ new Response(proc.stdout).text(),
43
+ new Response(proc.stderr).text(),
44
+ ])
45
+ const code = await proc.exited
46
+ return { code, stdout, stderr }
47
+ }
48
+
49
+ const snapshot = async () => (await tui(["snapshot", "--session", SESSION])).stdout
50
+
51
+ const press = async (...keys: string[]) => {
52
+ await tui(["press", "--session", SESSION, ...keys])
53
+ // small settle so the next snapshot reflects the keypress
54
+ await Bun.sleep(120)
55
+ }
56
+
57
+ // Slice out just the waterfall body region from a snapshot. The waterfall
58
+ // always sits between the last two horizontal dividers (the one just below
59
+ // the trace meta / pane header, and the one above the footer). Using the
60
+ // *last* pair makes the helper robust to layout changes that add or remove
61
+ // header dividers (breadcrumbs, split-divider junctions, etc.).
62
+ //
63
+ // In wide (side-by-side) mode each line also contains the trace list on the
64
+ // left half separated by `│`. The list renders relative ages like "6s / 7s"
65
+ // that drift between snapshots; strip everything left of the first `│` so
66
+ // only the waterfall contributes to the comparison.
67
+ const waterfallBody = (snap: string): readonly string[] => {
68
+ const lines = snap.split("\n")
69
+ const dividerIdxs: number[] = []
70
+ for (let i = 0; i < lines.length; i++) {
71
+ if (lines[i]!.startsWith("─")) dividerIdxs.push(i)
72
+ }
73
+ if (dividerIdxs.length < 2) return []
74
+ const start = dividerIdxs[dividerIdxs.length - 2]! + 1
75
+ const end = dividerIdxs[dividerIdxs.length - 1]!
76
+ return lines.slice(start, end).map((line) => {
77
+ const barIdx = line.indexOf("\u2502")
78
+ const sliced = barIdx >= 0 ? line.slice(barIdx) : line
79
+ return sliced.replace(/\s+$/g, "")
80
+ })
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Test fixture: temp DB with a deterministic trace
85
+ // ---------------------------------------------------------------------------
86
+
87
+ // Structure (kept in sync with src/ui/waterfallNav.repro.seed.ts):
88
+ // root.op
89
+ // ├─ siblingBefore.op
90
+ // ├─ parent.op <- the one we collapse / expand
91
+ // │ ├─ childA.op (1st) <- the one that used to disappear after a cycle
92
+ // │ ├─ childB.op
93
+ // │ ├─ childC.op
94
+ // │ ├─ childD.op
95
+ // │ ├─ childE.op
96
+ // │ └─ childF.op
97
+ // ├─ siblingAfter.op
98
+ // └─ tail.op
99
+ // └─ tailChild.op
100
+ // └─ tailGrandchild.op
101
+ const SERVICE_NAME = "waterfall-repro"
102
+
103
+ describe("waterfall collapse/expand (end-to-end TUI)", () => {
104
+ const tempDir = mkdtempSync(join(tmpdir(), "motel-repro-"))
105
+ const dbPath = join(tempDir, "telemetry.sqlite")
106
+ const lastServicePath = join(tempDir, "last-service.txt")
107
+ let canRun = false
108
+
109
+ beforeAll(async () => {
110
+ canRun = await hasTuistory()
111
+ if (!canRun) return
112
+
113
+ // Pin selected service so the TUI lands on our seeded trace immediately.
114
+ writeFileSync(lastServicePath, SERVICE_NAME)
115
+
116
+ // Seed the DB in a child process. Doing it in-process collides with
117
+ // other tests that imported config.ts first (databasePath is captured at
118
+ // module load time and ESM cache busts on the runtime entry alone do not
119
+ // invalidate the transitively cached config module).
120
+ const seed = Bun.spawn({
121
+ cmd: ["bun", "run", "src/ui/waterfallNav.repro.seed.ts"],
122
+ cwd: process.cwd(),
123
+ env: {
124
+ ...process.env,
125
+ MOTEL_OTEL_DB_PATH: dbPath,
126
+ MOTEL_OTEL_RETENTION_HOURS: "24",
127
+ MOTEL_OTEL_ENABLED: "false",
128
+ },
129
+ stdout: "pipe",
130
+ stderr: "pipe",
131
+ })
132
+ const seedCode = await seed.exited
133
+ if (seedCode !== 0) {
134
+ const [out, err] = await Promise.all([
135
+ new Response(seed.stdout).text(),
136
+ new Response(seed.stderr).text(),
137
+ ])
138
+ throw new Error(`Seed subprocess failed (${seedCode})\nstdout: ${out}\nstderr: ${err}`)
139
+ }
140
+
141
+ // Make sure no stale session is hanging around with the same name.
142
+ await tui(["close", "--session", SESSION])
143
+
144
+ // Launch the TUI. We use a dedicated entry point (src/index.tsx) and a
145
+ // generous viewport so the waterfall isn't truncated.
146
+ const launch = await tui([
147
+ "launch",
148
+ "bun run src/index.tsx",
149
+ "--session", SESSION,
150
+ "--cols", "160",
151
+ "--rows", "40",
152
+ "--cwd", process.cwd(),
153
+ "--env", `MOTEL_OTEL_DB_PATH=${dbPath}`,
154
+ "--env", "MOTEL_OTEL_ENABLED=false",
155
+ "--timeout", "15000",
156
+ ])
157
+ if (launch.code !== 0) {
158
+ throw new Error(`tuistory launch failed: ${launch.stderr || launch.stdout}`)
159
+ }
160
+
161
+ // Wait for the trace row to appear.
162
+ const waitResult = await tui(["wait", "root.op", "--session", SESSION, "--timeout", "10000"])
163
+ if (waitResult.code !== 0) {
164
+ const snap = (await tui(["snapshot", "--session", SESSION])).stdout
165
+ throw new Error(`Trace did not appear in TUI after seed.\nstderr: ${waitResult.stderr}\nstdout: ${waitResult.stdout}\nSnapshot:\n${snap}`)
166
+ }
167
+ await tui(["wait-idle", "--session", SESSION, "--timeout", "5000"])
168
+ }, 60_000)
169
+
170
+ afterAll(async () => {
171
+ if (canRun) {
172
+ await tui(["close", "--session", SESSION])
173
+ }
174
+ try {
175
+ rmSync(tempDir, { recursive: true, force: true })
176
+ } catch {}
177
+ })
178
+
179
+ it("collapse → expand cycle preserves the visible span list", async () => {
180
+ if (!canRun) return // tuistory unavailable: skip
181
+
182
+ // Enter the span navigation pane.
183
+ await press("return")
184
+ // Visible order: root, siblingBefore, parent, childA..F, siblingAfter, tail, tailChild, tailGrandchild
185
+ // Move down to parent.op (index 2): two j presses.
186
+ await press("j")
187
+ await press("j")
188
+
189
+ const before = waterfallBody(await snapshot())
190
+ const beforeText = before.join("\n")
191
+ // Sanity: every operation should be visible.
192
+ for (const op of [
193
+ "parent.op", "childA.op", "childB.op", "childC.op",
194
+ "childD.op", "childE.op", "childF.op",
195
+ "siblingBefore.op", "siblingAfter.op",
196
+ "tail.op", "tailChild.op", "tailGrandchild.op",
197
+ ]) {
198
+ expect(beforeText).toContain(op)
199
+ }
200
+
201
+ // Collapse parent.op
202
+ await press("h")
203
+ const collapsedText = waterfallBody(await snapshot()).join("\n")
204
+ expect(collapsedText).toContain("parent.op")
205
+ for (const child of ["childA.op", "childB.op", "childC.op", "childD.op", "childE.op", "childF.op"]) {
206
+ expect(collapsedText).not.toContain(child)
207
+ }
208
+
209
+ // Expand parent.op
210
+ await press("l")
211
+ const after = waterfallBody(await snapshot())
212
+ const afterText = after.join("\n")
213
+
214
+ // Every child must reappear after expand.
215
+ for (const child of ["childA.op", "childB.op", "childC.op", "childD.op", "childE.op", "childF.op"]) {
216
+ expect(afterText).toContain(child)
217
+ }
218
+ // Sibling structure must remain intact.
219
+ expect(afterText).toContain("siblingBefore.op")
220
+ expect(afterText).toContain("siblingAfter.op")
221
+ expect(afterText).toContain("tailGrandchild.op")
222
+
223
+ // And the waterfall body should be byte-identical to baseline.
224
+ expect(after).toEqual(before)
225
+ }, 60_000)
226
+
227
+ it("a SINGLE collapse/expand cycle does not drift the visible list", async () => {
228
+ if (!canRun) return
229
+
230
+ await press("escape")
231
+ await press("escape")
232
+ await press("return")
233
+ await press("j")
234
+ await press("j")
235
+
236
+ const baseline = waterfallBody(await snapshot())
237
+ await press("h")
238
+ await press("l")
239
+ const after = waterfallBody(await snapshot())
240
+ expect(after).toEqual(baseline)
241
+ }, 60_000)
242
+
243
+ it("repeated h/l cycles do not drift the visible list", async () => {
244
+ if (!canRun) return
245
+
246
+ // Reset by re-entering nav: esc out of any sub-mode then re-enter.
247
+ await press("escape")
248
+ await press("escape")
249
+ await press("return")
250
+ await press("j") // siblingBefore
251
+ await press("j") // parent
252
+
253
+ const baseline = waterfallBody(await snapshot())
254
+
255
+ for (let i = 0; i < 5; i++) {
256
+ await press("h")
257
+ await press("l")
258
+ }
259
+
260
+ const after = waterfallBody(await snapshot())
261
+ expect(after).toEqual(baseline)
262
+ }, 60_000)
263
+ })
@@ -0,0 +1,422 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import type { TraceSpanItem } from "../domain.ts"
3
+ import {
4
+ findFirstChildIndex,
5
+ findParentIndex,
6
+ getWaterfallColumns,
7
+ getVisibleSpans,
8
+ } from "./Waterfall.tsx"
9
+ import { resolveCollapseStep } from "./waterfallNav.ts"
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Test helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const mkSpan = (spanId: string, depth: number, parentSpanId: string | null): TraceSpanItem => ({
16
+ spanId,
17
+ parentSpanId,
18
+ serviceName: "svc",
19
+ scopeName: null,
20
+ kind: null,
21
+ operationName: spanId,
22
+ startTime: new Date(0),
23
+ isRunning: false,
24
+ durationMs: 1,
25
+ status: "ok",
26
+ depth,
27
+ tags: {},
28
+ warnings: [],
29
+ events: [],
30
+ })
31
+
32
+ /**
33
+ * A reusable, depth-first ordered span tree.
34
+ *
35
+ * root (0)
36
+ * ├─ a (1)
37
+ * │ ├─ a1 (2)
38
+ * │ └─ a2 (2)
39
+ * ├─ b (1)
40
+ * │ └─ b1 (2)
41
+ * │ └─ b1a (3)
42
+ * └─ c (1)
43
+ */
44
+ const buildTree = (): readonly TraceSpanItem[] => [
45
+ mkSpan("root", 0, null),
46
+ mkSpan("a", 1, "root"),
47
+ mkSpan("a1", 2, "a"),
48
+ mkSpan("a2", 2, "a"),
49
+ mkSpan("b", 1, "root"),
50
+ mkSpan("b1", 2, "b"),
51
+ mkSpan("b1a", 3, "b1"),
52
+ mkSpan("c", 1, "root"),
53
+ ]
54
+
55
+ const idsOf = (spans: readonly TraceSpanItem[]) => spans.map((s) => s.spanId)
56
+ const indexOfId = (spans: readonly TraceSpanItem[], id: string) => spans.findIndex((s) => s.spanId === id)
57
+
58
+ // Convenience: run resolveCollapseStep with the selection identified by spanId.
59
+ const step = (
60
+ spans: readonly TraceSpanItem[],
61
+ collapsed: ReadonlySet<string>,
62
+ selectedSpanId: string | null,
63
+ direction: "left" | "right",
64
+ ) => {
65
+ const visible = getVisibleSpans(spans, collapsed)
66
+ const selectedIndex = selectedSpanId === null ? -1 : visible.findIndex((s) => s.spanId === selectedSpanId)
67
+ const out = resolveCollapseStep({
68
+ spans,
69
+ collapsed,
70
+ selectedIndex: selectedIndex < 0 ? null : selectedIndex,
71
+ direction,
72
+ })
73
+ const newVisible = getVisibleSpans(spans, out.collapsed)
74
+ return {
75
+ collapsed: out.collapsed,
76
+ selectedIndex: out.selectedIndex,
77
+ selectedSpanId: out.selectedIndex !== null ? newVisible[out.selectedIndex]?.spanId ?? null : null,
78
+ visibleIds: idsOf(newVisible),
79
+ }
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // getVisibleSpans
84
+ // ---------------------------------------------------------------------------
85
+
86
+ describe("getVisibleSpans", () => {
87
+ it("returns the full list when nothing is collapsed", () => {
88
+ const spans = buildTree()
89
+ expect(idsOf(getVisibleSpans(spans, new Set()))).toEqual([
90
+ "root", "a", "a1", "a2", "b", "b1", "b1a", "c",
91
+ ])
92
+ })
93
+
94
+ it("hides direct children of a collapsed node", () => {
95
+ const spans = buildTree()
96
+ expect(idsOf(getVisibleSpans(spans, new Set(["a"])))).toEqual([
97
+ "root", "a", "b", "b1", "b1a", "c",
98
+ ])
99
+ })
100
+
101
+ it("hides transitive descendants of a collapsed node", () => {
102
+ const spans = buildTree()
103
+ expect(idsOf(getVisibleSpans(spans, new Set(["b"])))).toEqual([
104
+ "root", "a", "a1", "a2", "b", "c",
105
+ ])
106
+ })
107
+
108
+ it("collapsing a leaf changes nothing visually (no children to hide)", () => {
109
+ const spans = buildTree()
110
+ expect(idsOf(getVisibleSpans(spans, new Set(["c"])))).toEqual(idsOf(spans))
111
+ })
112
+
113
+ it("handles multiple collapsed sibling subtrees", () => {
114
+ const spans = buildTree()
115
+ expect(idsOf(getVisibleSpans(spans, new Set(["a", "b"])))).toEqual([
116
+ "root", "a", "b", "c",
117
+ ])
118
+ })
119
+
120
+ it("collapsing root hides everything but root", () => {
121
+ const spans = buildTree()
122
+ expect(idsOf(getVisibleSpans(spans, new Set(["root"])))).toEqual(["root"])
123
+ })
124
+
125
+ it("collapsing a node and its descendant is idempotent", () => {
126
+ const spans = buildTree()
127
+ expect(idsOf(getVisibleSpans(spans, new Set(["b", "b1"])))).toEqual([
128
+ "root", "a", "a1", "a2", "b", "c",
129
+ ])
130
+ })
131
+ })
132
+
133
+ describe("getWaterfallColumns", () => {
134
+ it("pads duration and log columns to fill the reserved width", () => {
135
+ const contentWidth = 72
136
+ const columns = getWaterfallColumns(contentWidth, 153_000, 1, 0)
137
+ expect(columns.durationCell.length).toBe(columns.durationWidth)
138
+ expect(columns.logCell.length).toBe(columns.logWidth)
139
+ expect(columns.labelMaxWidth + 1 + columns.barWidth + 1 + columns.durationCell.length + columns.logCell.length).toBe(contentWidth)
140
+ })
141
+ })
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // findParentIndex
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe("findParentIndex", () => {
148
+ it("returns null for the root span", () => {
149
+ const spans = buildTree()
150
+ expect(findParentIndex(spans, indexOfId(spans, "root"))).toBeNull()
151
+ })
152
+
153
+ it("returns the immediate parent index in the same list", () => {
154
+ const spans = buildTree()
155
+ expect(findParentIndex(spans, indexOfId(spans, "a1"))).toBe(indexOfId(spans, "a"))
156
+ expect(findParentIndex(spans, indexOfId(spans, "b1a"))).toBe(indexOfId(spans, "b1"))
157
+ expect(findParentIndex(spans, indexOfId(spans, "c"))).toBe(indexOfId(spans, "root"))
158
+ })
159
+
160
+ it("works against a filtered (visible) list — parent is the nearest shallower ancestor before index", () => {
161
+ const spans = buildTree()
162
+ const visible = getVisibleSpans(spans, new Set(["b1"]))
163
+ // visible: root, a, a1, a2, b, b1, c
164
+ expect(findParentIndex(visible, indexOfId(visible, "b1"))).toBe(indexOfId(visible, "b"))
165
+ })
166
+
167
+ it("returns null on out-of-range index instead of crashing", () => {
168
+ const spans = buildTree()
169
+ expect(findParentIndex(spans, 999)).toBeNull()
170
+ expect(findParentIndex(spans, -1)).toBeNull()
171
+ })
172
+
173
+ it("returns null on empty input", () => {
174
+ expect(findParentIndex([], 0)).toBeNull()
175
+ })
176
+ })
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // findFirstChildIndex
180
+ // ---------------------------------------------------------------------------
181
+
182
+ describe("findFirstChildIndex", () => {
183
+ it("returns next index when the next span is deeper", () => {
184
+ const spans = buildTree()
185
+ expect(findFirstChildIndex(spans, indexOfId(spans, "a"))).toBe(indexOfId(spans, "a1"))
186
+ expect(findFirstChildIndex(spans, indexOfId(spans, "b1"))).toBe(indexOfId(spans, "b1a"))
187
+ expect(findFirstChildIndex(spans, indexOfId(spans, "root"))).toBe(indexOfId(spans, "a"))
188
+ })
189
+
190
+ it("returns null for leaf spans", () => {
191
+ const spans = buildTree()
192
+ expect(findFirstChildIndex(spans, indexOfId(spans, "a1"))).toBeNull()
193
+ expect(findFirstChildIndex(spans, indexOfId(spans, "c"))).toBeNull()
194
+ })
195
+
196
+ it("returns null on out-of-range index", () => {
197
+ const spans = buildTree()
198
+ expect(findFirstChildIndex(spans, 999)).toBeNull()
199
+ expect(findFirstChildIndex(spans, -1)).toBeNull()
200
+ })
201
+
202
+ it("returns null on empty input", () => {
203
+ expect(findFirstChildIndex([], 0)).toBeNull()
204
+ })
205
+ })
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // resolveCollapseStep — the heart of the keyboard logic
209
+ // ---------------------------------------------------------------------------
210
+
211
+ describe("resolveCollapseStep — `right` (l / expand-or-into)", () => {
212
+ it("expanding a collapsed node keeps the same span selected", () => {
213
+ const spans = buildTree()
214
+ const collapsed = new Set(["a"])
215
+ const r = step(spans, collapsed, "a", "right")
216
+ expect(r.collapsed.has("a")).toBe(false)
217
+ expect(r.selectedSpanId).toBe("a")
218
+ expect(r.visibleIds).toEqual(["root", "a", "a1", "a2", "b", "b1", "b1a", "c"])
219
+ })
220
+
221
+ it("on an expanded parent, walks selection into its first visible child", () => {
222
+ const spans = buildTree()
223
+ const r = step(spans, new Set(), "a", "right")
224
+ expect(r.collapsed.size).toBe(0)
225
+ expect(r.selectedSpanId).toBe("a1")
226
+ })
227
+
228
+ it("on a leaf, is a no-op", () => {
229
+ const spans = buildTree()
230
+ const r = step(spans, new Set(), "a1", "right")
231
+ expect(r.collapsed.size).toBe(0)
232
+ expect(r.selectedSpanId).toBe("a1")
233
+ })
234
+
235
+ it("on root (expanded with children) walks into first child", () => {
236
+ const spans = buildTree()
237
+ const r = step(spans, new Set(), "root", "right")
238
+ expect(r.selectedSpanId).toBe("a")
239
+ })
240
+
241
+ it("on root (collapsed) expands it", () => {
242
+ const spans = buildTree()
243
+ const r = step(spans, new Set(["root"]), "root", "right")
244
+ expect(r.collapsed.has("root")).toBe(false)
245
+ expect(r.selectedSpanId).toBe("root")
246
+ })
247
+
248
+ it("when nothing is selected, is a no-op", () => {
249
+ const spans = buildTree()
250
+ const r = step(spans, new Set(), null, "right")
251
+ expect(r.selectedSpanId).toBeNull()
252
+ expect(r.collapsed.size).toBe(0)
253
+ })
254
+
255
+ it("when index is stale (past visible end), is a no-op rather than crashing", () => {
256
+ const spans = buildTree()
257
+ const out = resolveCollapseStep({ spans, collapsed: new Set(), selectedIndex: 999, direction: "right" })
258
+ expect(out.selectedIndex).toBe(999)
259
+ expect(out.collapsed.size).toBe(0)
260
+ })
261
+ })
262
+
263
+ describe("resolveCollapseStep — `left` (h / collapse-or-up)", () => {
264
+ it("collapsing an expanded parent keeps the same span selected", () => {
265
+ const spans = buildTree()
266
+ const r = step(spans, new Set(), "a", "left")
267
+ expect(r.collapsed.has("a")).toBe(true)
268
+ expect(r.selectedSpanId).toBe("a")
269
+ expect(r.visibleIds).toEqual(["root", "a", "b", "b1", "b1a", "c"])
270
+ })
271
+
272
+ it("on a leaf, walks to its parent", () => {
273
+ const spans = buildTree()
274
+ const r = step(spans, new Set(), "a1", "left")
275
+ expect(r.selectedSpanId).toBe("a")
276
+ expect(r.collapsed.size).toBe(0)
277
+ })
278
+
279
+ it("on a deep leaf, walks to its immediate parent (not all the way to root)", () => {
280
+ const spans = buildTree()
281
+ const r = step(spans, new Set(), "b1a", "left")
282
+ expect(r.selectedSpanId).toBe("b1")
283
+ })
284
+
285
+ it("on a collapsed parent, walks to its parent (since it has no expanded kids to collapse)", () => {
286
+ const spans = buildTree()
287
+ const r = step(spans, new Set(["a"]), "a", "left")
288
+ expect(r.selectedSpanId).toBe("root")
289
+ expect(r.collapsed.has("a")).toBe(true) // staying collapsed
290
+ })
291
+
292
+ it("on root, is a no-op", () => {
293
+ const spans = buildTree()
294
+ const r = step(spans, new Set(["root"]), "root", "left")
295
+ expect(r.selectedSpanId).toBe("root")
296
+ })
297
+
298
+ it("when nothing is selected, is a no-op", () => {
299
+ const spans = buildTree()
300
+ const r = step(spans, new Set(), null, "left")
301
+ expect(r.selectedSpanId).toBeNull()
302
+ expect(r.collapsed.size).toBe(0)
303
+ })
304
+ })
305
+
306
+ describe("resolveCollapseStep — sequences (real bug scenarios)", () => {
307
+ it("press l then l: navigates root → a → a1 (no double-collapse)", () => {
308
+ const spans = buildTree()
309
+ const r1 = step(spans, new Set(), "root", "right")
310
+ expect(r1.selectedSpanId).toBe("a")
311
+ const r2 = step(spans, r1.collapsed, r1.selectedSpanId!, "right")
312
+ expect(r2.selectedSpanId).toBe("a1")
313
+ })
314
+
315
+ it("h-then-h-then-h on a leaf reaches root via collapse-then-walk pattern", () => {
316
+ const spans = buildTree()
317
+ // b1a -> h -> b1 (walk parent)
318
+ const r1 = step(spans, new Set(), "b1a", "left")
319
+ expect(r1.selectedSpanId).toBe("b1")
320
+ // b1 -> h -> collapse b1
321
+ const r2 = step(spans, r1.collapsed, r1.selectedSpanId!, "left")
322
+ expect(r2.collapsed.has("b1")).toBe(true)
323
+ expect(r2.selectedSpanId).toBe("b1")
324
+ // b1 (collapsed) -> h -> walk to parent b
325
+ const r3 = step(spans, r2.collapsed, r2.selectedSpanId!, "left")
326
+ expect(r3.selectedSpanId).toBe("b")
327
+ })
328
+
329
+ it("rapid collapse/expand on the same node converges to the original state", () => {
330
+ const spans = buildTree()
331
+ let st: { collapsed: ReadonlySet<string>; selectedSpanId: string } = {
332
+ collapsed: new Set(),
333
+ selectedSpanId: "a",
334
+ }
335
+ for (let i = 0; i < 10; i++) {
336
+ const dir = i % 2 === 0 ? "left" : "right"
337
+ const r = step(spans, st.collapsed, st.selectedSpanId, dir)
338
+ st = { collapsed: r.collapsed, selectedSpanId: r.selectedSpanId! }
339
+ }
340
+ expect([...st.collapsed]).toEqual([])
341
+ expect(st.selectedSpanId).toBe("a")
342
+ })
343
+
344
+ it("walking right past a leaf is idempotent (no crash, no state change)", () => {
345
+ const spans = buildTree()
346
+ const r1 = step(spans, new Set(), "a1", "right")
347
+ const r2 = step(spans, r1.collapsed, r1.selectedSpanId!, "right")
348
+ const r3 = step(spans, r2.collapsed, r2.selectedSpanId!, "right")
349
+ expect(r1.selectedSpanId).toBe("a1")
350
+ expect(r2.selectedSpanId).toBe("a1")
351
+ expect(r3.selectedSpanId).toBe("a1")
352
+ })
353
+
354
+ it("walking left past root is idempotent", () => {
355
+ const spans = buildTree()
356
+ const r1 = step(spans, new Set(["root"]), "root", "left")
357
+ const r2 = step(spans, r1.collapsed, r1.selectedSpanId!, "left")
358
+ expect(r1.selectedSpanId).toBe("root")
359
+ expect(r2.selectedSpanId).toBe("root")
360
+ })
361
+
362
+ it("collapse then move to another span then expand: state stays consistent", () => {
363
+ const spans = buildTree()
364
+ // Collapse `a` while on it.
365
+ const r1 = step(spans, new Set(), "a", "left")
366
+ expect(r1.collapsed.has("a")).toBe(true)
367
+ // Visible: root, a, b, b1, b1a, c
368
+ // Pretend user clicks `b1a` (so selection moves there).
369
+ // Now press h on b1a.
370
+ const r2 = step(spans, r1.collapsed, "b1a", "left")
371
+ expect(r2.selectedSpanId).toBe("b1")
372
+ expect(r2.collapsed.has("a")).toBe(true) // a stays collapsed
373
+ })
374
+ })
375
+
376
+ describe("resolveCollapseStep — invariants", () => {
377
+ it("never returns a selectedIndex that is out of the new visible range", () => {
378
+ const spans = buildTree()
379
+ // Try every (selectedSpanId, direction, collapsed-state) combination we care about.
380
+ const interesting: ReadonlyArray<{ collapsed: ReadonlySet<string>; selectedSpanId: string }> = [
381
+ { collapsed: new Set(), selectedSpanId: "root" },
382
+ { collapsed: new Set(), selectedSpanId: "a" },
383
+ { collapsed: new Set(), selectedSpanId: "a2" },
384
+ { collapsed: new Set(), selectedSpanId: "b1a" },
385
+ { collapsed: new Set(["a"]), selectedSpanId: "a" },
386
+ { collapsed: new Set(["a"]), selectedSpanId: "b" },
387
+ { collapsed: new Set(["b"]), selectedSpanId: "b" },
388
+ { collapsed: new Set(["root"]), selectedSpanId: "root" },
389
+ ]
390
+ for (const { collapsed, selectedSpanId } of interesting) {
391
+ for (const direction of ["left", "right"] as const) {
392
+ const r = step(spans, collapsed, selectedSpanId, direction)
393
+ if (r.selectedIndex !== null) {
394
+ expect(r.selectedIndex).toBeGreaterThanOrEqual(0)
395
+ expect(r.selectedIndex).toBeLessThan(r.visibleIds.length)
396
+ }
397
+ }
398
+ }
399
+ })
400
+
401
+ it("collapsing then expanding the same node restores the original state", () => {
402
+ const spans = buildTree()
403
+ const a = step(spans, new Set(), "a", "left") // collapse a
404
+ const b = step(spans, a.collapsed, a.selectedSpanId!, "right") // expand a
405
+ expect([...b.collapsed]).toEqual([])
406
+ expect(b.selectedSpanId).toBe("a")
407
+ expect(b.visibleIds).toEqual(idsOf(spans))
408
+ })
409
+
410
+ it("collapsing an ancestor of the currently selected span keeps the ancestor visible (selection moves up)", () => {
411
+ // Start: selected = b1a (depth 3). Collapse `b` (its grandparent).
412
+ const spans = buildTree()
413
+ // We collapse `b` while `b1a` is selected. The keyboard handler only collapses
414
+ // the currently selected span, so we simulate the click-to-collapse path by
415
+ // asserting the helper handles a selection that became invisible afterward.
416
+ const collapsed = new Set(["b"])
417
+ const visible = getVisibleSpans(spans, collapsed)
418
+ // `b1a` is no longer visible. Its nearest visible ancestor is `b`.
419
+ expect(visible.some((s) => s.spanId === "b1a")).toBe(false)
420
+ expect(visible.some((s) => s.spanId === "b")).toBe(true)
421
+ })
422
+ })