@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.
- package/AGENTS.md +142 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/package.json +92 -0
- package/src/App.tsx +217 -0
- package/src/cli.ts +258 -0
- package/src/config.ts +39 -0
- package/src/daemon.test.ts +59 -0
- package/src/daemon.ts +398 -0
- package/src/domain.ts +233 -0
- package/src/httpApi.ts +384 -0
- package/src/index.tsx +18 -0
- package/src/instructions.ts +72 -0
- package/src/localServer.ts +699 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +260 -0
- package/src/motel.ts +86 -0
- package/src/motelClient.ts +201 -0
- package/src/otlp.ts +142 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +86 -0
- package/src/runtime.ts +38 -0
- package/src/server.ts +10 -0
- package/src/services/LogQueryService.ts +43 -0
- package/src/services/TelemetryStore.ts +1821 -0
- package/src/services/TraceQueryService.ts +71 -0
- package/src/telemetry.test.ts +726 -0
- package/src/ui/ServiceLogs.tsx +112 -0
- package/src/ui/SpanDetail.tsx +134 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +91 -0
- package/src/ui/TraceDetailsPane.tsx +169 -0
- package/src/ui/TraceList.tsx +128 -0
- package/src/ui/Waterfall.tsx +412 -0
- package/src/ui/app/TraceListPane.tsx +34 -0
- package/src/ui/app/TraceWorkspace.tsx +254 -0
- package/src/ui/app/useAppLayout.ts +79 -0
- package/src/ui/app/useTraceScreenData.ts +411 -0
- package/src/ui/format.ts +119 -0
- package/src/ui/primitives.tsx +170 -0
- package/src/ui/state.ts +137 -0
- package/src/ui/theme.ts +153 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +220 -0
- package/src/ui/useKeyboardNav.ts +532 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +263 -0
- package/src/ui/waterfallNav.test.ts +422 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-BEKIiisE.js +27 -0
- package/web/dist/assets/index-DzuHNBGV.css +2 -0
- 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
|
+
})
|