@aryasaatvik/motel 0.3.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 (99) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/package.json +119 -0
  5. package/skills/motel-debug/SKILL.md +203 -0
  6. package/skills/motel-debug/references/effect.md +38 -0
  7. package/src/App.tsx +414 -0
  8. package/src/StartupGate.test.ts +80 -0
  9. package/src/StartupGate.tsx +263 -0
  10. package/src/cli.ts +205 -0
  11. package/src/config.ts +45 -0
  12. package/src/daemon.test.ts +547 -0
  13. package/src/daemon.ts +486 -0
  14. package/src/domain.test.ts +62 -0
  15. package/src/domain.ts +291 -0
  16. package/src/httpApi.ts +391 -0
  17. package/src/httpListPolicy.test.ts +76 -0
  18. package/src/httpListPolicy.ts +129 -0
  19. package/src/index.tsx +25 -0
  20. package/src/instructions.ts +72 -0
  21. package/src/launchAgent.test.ts +430 -0
  22. package/src/launchAgent.ts +462 -0
  23. package/src/localServer.ts +568 -0
  24. package/src/locator.ts +138 -0
  25. package/src/mcp.ts +433 -0
  26. package/src/motel.test.ts +176 -0
  27. package/src/motel.ts +88 -0
  28. package/src/motelClient.ts +353 -0
  29. package/src/opentui-jsx.d.ts +11 -0
  30. package/src/otlp.test.ts +65 -0
  31. package/src/otlp.ts +162 -0
  32. package/src/otlpProtobuf.ts +35 -0
  33. package/src/queryFilters.ts +39 -0
  34. package/src/registry.ts +115 -0
  35. package/src/runtime.ts +40 -0
  36. package/src/server.ts +11 -0
  37. package/src/services/AsyncIngest.ts +82 -0
  38. package/src/services/LogQueryService.ts +29 -0
  39. package/src/services/TelemetryQuery.ts +62 -0
  40. package/src/services/TelemetryStore.ts +2554 -0
  41. package/src/services/TraceQueryService.ts +43 -0
  42. package/src/services/ingestRpc.ts +39 -0
  43. package/src/services/queryRpc.ts +15 -0
  44. package/src/services/telemetryQueryWorker.ts +32 -0
  45. package/src/services/telemetryWorker.ts +59 -0
  46. package/src/startupBench.ts +19 -0
  47. package/src/startupLifecycle.ts +57 -0
  48. package/src/storybook/aiChatStory.tsx +244 -0
  49. package/src/storybook/fixtures/errorState.ts +44 -0
  50. package/src/storybook/fixtures/imagePaste.ts +34 -0
  51. package/src/storybook/fixtures/index.ts +62 -0
  52. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  53. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  54. package/src/storybook/fixtures/short.ts +27 -0
  55. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  56. package/src/telemetry.test.ts +1053 -0
  57. package/src/ui/AiChatView.tsx +308 -0
  58. package/src/ui/AttrFilterModal.tsx +120 -0
  59. package/src/ui/ServiceLogs.tsx +115 -0
  60. package/src/ui/SpanContentView.tsx +184 -0
  61. package/src/ui/SpanDetail.tsx +215 -0
  62. package/src/ui/SpanDetailFull.tsx +224 -0
  63. package/src/ui/SpanDetailPane.tsx +90 -0
  64. package/src/ui/TraceDetailsPane.tsx +175 -0
  65. package/src/ui/TraceList.tsx +254 -0
  66. package/src/ui/Waterfall.tsx +389 -0
  67. package/src/ui/aiChatModel.test.ts +391 -0
  68. package/src/ui/aiChatModel.ts +773 -0
  69. package/src/ui/aiState.ts +59 -0
  70. package/src/ui/app/TraceListPane.tsx +39 -0
  71. package/src/ui/app/TraceWorkspace.tsx +452 -0
  72. package/src/ui/app/useAppLayout.ts +101 -0
  73. package/src/ui/app/useTraceScreenData.ts +590 -0
  74. package/src/ui/atoms.ts +131 -0
  75. package/src/ui/cachedLoader.test.ts +23 -0
  76. package/src/ui/cachedLoader.ts +60 -0
  77. package/src/ui/filterParser.test.ts +56 -0
  78. package/src/ui/filterParser.ts +45 -0
  79. package/src/ui/format.ts +128 -0
  80. package/src/ui/loaders.ts +101 -0
  81. package/src/ui/persistence.ts +41 -0
  82. package/src/ui/primitives.tsx +198 -0
  83. package/src/ui/state.ts +6 -0
  84. package/src/ui/theme.ts +160 -0
  85. package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
  86. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  87. package/src/ui/traceSortNav.repro.test.ts +231 -0
  88. package/src/ui/useAttrFilterPicker.ts +89 -0
  89. package/src/ui/useKeyboardNav.ts +1023 -0
  90. package/src/ui/waterfallFilter.test.ts +84 -0
  91. package/src/ui/waterfallFilter.ts +59 -0
  92. package/src/ui/waterfallModel.ts +130 -0
  93. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  94. package/src/ui/waterfallNav.repro.test.ts +271 -0
  95. package/src/ui/waterfallNav.test.ts +453 -0
  96. package/src/ui/waterfallNav.ts +75 -0
  97. package/web/dist/assets/index-B01z9BaO.css +2 -0
  98. package/web/dist/assets/index-CYlBaY9O.js +22 -0
  99. package/web/dist/index.html +13 -0
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import type { TraceSpanItem } from "../domain.ts"
3
+ import { computeMatchingSpanIds, findAdjacentMatch, spanMatchesFilter } from "./waterfallFilter.ts"
4
+
5
+ const span = (spanId: string, operationName: string, tags: Record<string, string> = {}): TraceSpanItem => ({
6
+ spanId,
7
+ parentSpanId: null,
8
+ serviceName: "test",
9
+ scopeName: null,
10
+ kind: null,
11
+ operationName,
12
+ startTime: new Date(0),
13
+ isRunning: false,
14
+ durationMs: 1,
15
+ status: "ok",
16
+ depth: 0,
17
+ tags,
18
+ warnings: [],
19
+ events: [],
20
+ })
21
+
22
+ describe("spanMatchesFilter", () => {
23
+ it("matches operation name case-insensitively", () => {
24
+ expect(spanMatchesFilter(span("a", "ai.StreamText"), "stream")).toBe(true)
25
+ expect(spanMatchesFilter(span("a", "ai.StreamText"), "nope")).toBe(false)
26
+ })
27
+
28
+ it("matches tag values but not keys", () => {
29
+ expect(spanMatchesFilter(span("a", "op", { "ai.model.id": "claude" }), "claude")).toBe(true)
30
+ // Key-only match should not count, otherwise searching "ai" dims nothing.
31
+ expect(spanMatchesFilter(span("a", "op", { "ai.model.id": "claude" }), "model.id")).toBe(false)
32
+ })
33
+
34
+ it("returns true when the needle is empty", () => {
35
+ expect(spanMatchesFilter(span("a", "op"), "")).toBe(true)
36
+ })
37
+ })
38
+
39
+ describe("computeMatchingSpanIds", () => {
40
+ it("returns null for empty/whitespace filter", () => {
41
+ expect(computeMatchingSpanIds([span("a", "op")], "")).toBeNull()
42
+ expect(computeMatchingSpanIds([span("a", "op")], " ")).toBeNull()
43
+ })
44
+
45
+ it("returns only matching span ids", () => {
46
+ const spans = [span("a", "ai.streamText"), span("b", "Agent.get"), span("c", "ai.toolCall")]
47
+ const ids = computeMatchingSpanIds(spans, "ai")
48
+ expect(ids).not.toBeNull()
49
+ expect(Array.from(ids!)).toEqual(["a", "c"])
50
+ })
51
+ })
52
+
53
+ describe("findAdjacentMatch", () => {
54
+ const spans = [span("a", "one"), span("b", "two"), span("c", "three"), span("d", "four")]
55
+ const matches = new Set(["b", "d"])
56
+
57
+ it("finds next from current selection", () => {
58
+ expect(findAdjacentMatch(spans, matches, 0, 1)).toBe(1) // a -> b
59
+ expect(findAdjacentMatch(spans, matches, 1, 1)).toBe(3) // b -> d
60
+ })
61
+
62
+ it("wraps forward past the end", () => {
63
+ expect(findAdjacentMatch(spans, matches, 3, 1)).toBe(1) // d -> b (wrap)
64
+ })
65
+
66
+ it("finds previous from current selection", () => {
67
+ expect(findAdjacentMatch(spans, matches, 3, -1)).toBe(1) // d -> b
68
+ expect(findAdjacentMatch(spans, matches, 1, -1)).toBe(3) // b -> d (wrap)
69
+ })
70
+
71
+ it("starts from beginning/end when nothing is selected", () => {
72
+ expect(findAdjacentMatch(spans, matches, null, 1)).toBe(1) // forward → first match
73
+ expect(findAdjacentMatch(spans, matches, null, -1)).toBe(3) // backward → last match
74
+ })
75
+
76
+ it("returns null when there are no matches", () => {
77
+ expect(findAdjacentMatch(spans, new Set(), 0, 1)).toBeNull()
78
+ })
79
+
80
+ it("handles a single match by returning it regardless of direction", () => {
81
+ expect(findAdjacentMatch(spans, new Set(["c"]), 0, 1)).toBe(2)
82
+ expect(findAdjacentMatch(spans, new Set(["c"]), 0, -1)).toBe(2)
83
+ })
84
+ })
@@ -0,0 +1,59 @@
1
+ import type { TraceSpanItem } from "../domain.ts"
2
+
3
+ /**
4
+ * Case-insensitive substring match against a span's operation name and
5
+ * any of its tag values. Keys aren't checked — they're dotted
6
+ * identifiers the user never types, so matching them just creates false
7
+ * positives on bare tokens like "ai" or "response".
8
+ */
9
+ export const spanMatchesFilter = (span: TraceSpanItem, needle: string): boolean => {
10
+ if (!needle) return true
11
+ if (span.operationName.toLowerCase().includes(needle)) return true
12
+ for (const value of Object.values(span.tags)) {
13
+ if (typeof value === "string" && value.toLowerCase().includes(needle)) return true
14
+ }
15
+ return false
16
+ }
17
+
18
+ /**
19
+ * Compute the set of span IDs that match the given filter text. Returns
20
+ * null when the filter is empty so callers can skip dimming entirely
21
+ * (hot path during waterfall scrolling).
22
+ */
23
+ export const computeMatchingSpanIds = (
24
+ spans: readonly TraceSpanItem[],
25
+ filterText: string,
26
+ ): ReadonlySet<string> | null => {
27
+ const needle = filterText.trim().toLowerCase()
28
+ if (!needle) return null
29
+ const matches = new Set<string>()
30
+ for (const span of spans) {
31
+ if (spanMatchesFilter(span, needle)) matches.add(span.spanId)
32
+ }
33
+ return matches
34
+ }
35
+
36
+ /**
37
+ * Find the next (direction=1) or previous (direction=-1) matching span
38
+ * index in `filteredSpans` relative to `currentIndex`. Wraps around the
39
+ * ends. Returns `null` when the list has no matches at all. When
40
+ * `currentIndex` is null (nothing selected), starts from either end
41
+ * based on direction.
42
+ */
43
+ export const findAdjacentMatch = (
44
+ filteredSpans: readonly TraceSpanItem[],
45
+ matchingSpanIds: ReadonlySet<string>,
46
+ currentIndex: number | null,
47
+ direction: 1 | -1,
48
+ ): number | null => {
49
+ if (filteredSpans.length === 0 || matchingSpanIds.size === 0) return null
50
+ const start = currentIndex ?? (direction === 1 ? -1 : filteredSpans.length)
51
+ const n = filteredSpans.length
52
+ // Walk the ring exactly once so we wrap but never loop forever.
53
+ for (let step = 1; step <= n; step++) {
54
+ const idx = ((start + direction * step) % n + n) % n
55
+ const span = filteredSpans[idx]
56
+ if (span && matchingSpanIds.has(span.spanId)) return idx
57
+ }
58
+ return null
59
+ }
@@ -0,0 +1,130 @@
1
+ import type { LogItem, TraceSpanItem } from "../domain.ts"
2
+ import { formatDuration } from "./format.ts"
3
+
4
+ /** Filter spans to only those visible given a set of collapsed span IDs. */
5
+ export const getVisibleSpans = (spans: readonly TraceSpanItem[], collapsedIds: ReadonlySet<string>): readonly TraceSpanItem[] => {
6
+ if (collapsedIds.size === 0) return spans
7
+ const result: TraceSpanItem[] = []
8
+ let skipDepth = -1
9
+ for (const span of spans) {
10
+ if (skipDepth >= 0 && span.depth > skipDepth) continue
11
+ skipDepth = -1
12
+ result.push(span)
13
+ if (collapsedIds.has(span.spanId)) {
14
+ skipDepth = span.depth
15
+ }
16
+ }
17
+ return result
18
+ }
19
+
20
+ /** Find the index of a span's parent in the visible list. */
21
+ export const findParentIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
22
+ const span = spans[index]
23
+ if (!span || span.depth === 0) return null
24
+ for (let i = index - 1; i >= 0; i--) {
25
+ if (spans[i]!.depth < span.depth) return i
26
+ }
27
+ return null
28
+ }
29
+
30
+ /** Find the index of a span's first child in the visible list. */
31
+ export const findFirstChildIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
32
+ const span = spans[index]
33
+ const next = spans[index + 1]
34
+ if (span && next && next.depth > span.depth) return index + 1
35
+ return null
36
+ }
37
+
38
+ export const buildTreePrefix = (spans: readonly TraceSpanItem[], index: number): string => {
39
+ const span = spans[index]
40
+ if (span.depth === 0) return ""
41
+
42
+ const parts: string[] = []
43
+
44
+ const isLastChild = (spanIndex: number, depth: number): boolean => {
45
+ for (let i = spanIndex + 1; i < spans.length; i++) {
46
+ if (spans[i].depth < depth) return true
47
+ if (spans[i].depth === depth) return false
48
+ }
49
+ return true
50
+ }
51
+
52
+ parts.push(isLastChild(index, span.depth) ? "\u2514\u2500" : "\u251c\u2500")
53
+
54
+ for (let d = span.depth - 1; d >= 1; d--) {
55
+ let parentIndex = index
56
+ for (let i = index - 1; i >= 0; i--) {
57
+ if (spans[i].depth === d) {
58
+ parentIndex = i
59
+ break
60
+ }
61
+ if (spans[i].depth < d) break
62
+ }
63
+ parts.push(isLastChild(parentIndex, d) ? " " : "\u2502 ")
64
+ }
65
+
66
+ return parts.reverse().join("")
67
+ }
68
+
69
+ const INTERESTING_TAGS = [
70
+ "http.method", "http.url", "http.status_code", "http.route",
71
+ "db.system", "db.statement", "db.name",
72
+ "messaging.system", "messaging.destination",
73
+ "error", "error.message",
74
+ "net.peer.name", "net.peer.port",
75
+ ] as const
76
+
77
+ export const getWaterfallLayout = (contentWidth: number, suffixWidth: number) => {
78
+ const gapsAndSuffix = suffixWidth + 2
79
+ const remaining = Math.max(4, contentWidth - gapsAndSuffix)
80
+ const labelMaxWidth = Math.max(4, Math.min(Math.floor(remaining * 0.5), 32))
81
+ const barWidth = Math.max(1, contentWidth - labelMaxWidth - gapsAndSuffix)
82
+ return { labelMaxWidth, barWidth } as const
83
+ }
84
+
85
+ export type WaterfallSuffixMetrics = {
86
+ readonly maxDurationWidth: number
87
+ readonly suffixWidth: number
88
+ }
89
+
90
+ export const getWaterfallSuffixMetrics = (
91
+ spans: readonly { readonly durationMs: number; readonly spanId: string }[],
92
+ ): WaterfallSuffixMetrics => {
93
+ let maxDurationWidth = 0
94
+ for (const span of spans) {
95
+ const d = formatDuration(Math.max(0, span.durationMs)).length
96
+ if (d > maxDurationWidth) maxDurationWidth = d
97
+ }
98
+ return { maxDurationWidth, suffixWidth: maxDurationWidth }
99
+ }
100
+
101
+ export const getWaterfallColumns = (
102
+ contentWidth: number,
103
+ metrics: WaterfallSuffixMetrics,
104
+ ) => {
105
+ const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, metrics.suffixWidth)
106
+ return { labelMaxWidth, barWidth, suffixWidth: metrics.suffixWidth } as const
107
+ }
108
+
109
+ export const spanPreviewEntries = (span: TraceSpanItem, logs: readonly LogItem[], maxEntries: number): Array<{ key: string; value: string; isWarning?: boolean }> => {
110
+ const entries = Object.entries(span.tags)
111
+ const interesting = entries.filter(([key]) =>
112
+ INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) || key.startsWith("error"),
113
+ )
114
+ const rest = entries.filter(([key]) =>
115
+ !INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) && !key.startsWith("error") && !key.startsWith("otel.") && key !== "span.kind",
116
+ )
117
+ const tagResults: Array<{ key: string; value: string; isWarning?: boolean }> = []
118
+ if (logs.length > 0) {
119
+ tagResults.push({ key: "logs", value: `${logs.length} correlated` })
120
+ tagResults.push({ key: "log", value: logs[0]!.body.replace(/\s+/g, " ") })
121
+ }
122
+
123
+ tagResults.push(...[...interesting, ...rest]
124
+ .slice(0, maxEntries - span.warnings.length)
125
+ .map(([key, value]) => ({ key, value })))
126
+ for (const warning of span.warnings) {
127
+ tagResults.push({ key: "warning", value: warning, isWarning: true })
128
+ }
129
+ return tagResults.slice(0, maxEntries)
130
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Seed a deterministic trace into the SQLite store. Invoked as a child process
3
+ * by the reproducer test so it gets a fresh module graph (config.ts caches the
4
+ * DB path at module-load time).
5
+ *
6
+ * Usage:
7
+ * bun run src/ui/waterfallNav.repro.seed.ts
8
+ *
9
+ * Reads MOTEL_OTEL_DB_PATH from the environment.
10
+ */
11
+
12
+ import { Effect } from "effect"
13
+ import { storeRuntime } from "../runtime.ts"
14
+ import { TelemetryStore } from "../services/TelemetryStore.ts"
15
+
16
+ const SERVICE_NAME = "waterfall-repro"
17
+ const TRACE_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
18
+ const ROOT = "1111111111111111"
19
+ const SIB_BEFORE = "2222222222222222"
20
+ const PARENT = "3333333333333333"
21
+ const CHILD_A = "aaaaaaaaaaaaaaaa"
22
+ const CHILD_B = "bbbbbbbbbbbbbbbb"
23
+ const CHILD_C = "cccccccccccccccc"
24
+ const CHILD_D = "dddddddddddddddd"
25
+ const CHILD_E = "eeeeeeeeeeeeeeee"
26
+ const CHILD_F = "ffffffffffffffff"
27
+ const SIB_AFTER = "4444444444444444"
28
+ const TAIL = "5555555555555555"
29
+ const TAIL_CHILD = "6666666666666666"
30
+ const TAIL_GRAND = "7777777777777777"
31
+
32
+ const nowNanos = BigInt(Date.now()) * 1_000_000n
33
+ const ms = (n: number) => String(nowNanos + BigInt(n) * 1_000_000n)
34
+
35
+ const span = (
36
+ spanId: string,
37
+ parent: string | null,
38
+ name: string,
39
+ startMs: number,
40
+ endMs: number,
41
+ ) => ({
42
+ traceId: TRACE_ID,
43
+ spanId,
44
+ parentSpanId: parent ?? undefined,
45
+ name,
46
+ kind: 1,
47
+ startTimeUnixNano: ms(startMs),
48
+ endTimeUnixNano: ms(endMs),
49
+ })
50
+
51
+ const program = Effect.flatMap(TelemetryStore, (store) =>
52
+ store.ingestTraces({
53
+ resourceSpans: [
54
+ {
55
+ resource: {
56
+ attributes: [
57
+ { key: "service.name", value: { stringValue: SERVICE_NAME } },
58
+ ],
59
+ },
60
+ scopeSpans: [
61
+ {
62
+ scope: { name: "test-scope" },
63
+ spans: [
64
+ span(ROOT, null, "root.op", 0, 100),
65
+ span(SIB_BEFORE, ROOT, "siblingBefore.op", 1, 2),
66
+ span(PARENT, ROOT, "parent.op", 5, 60),
67
+ span(CHILD_A, PARENT, "childA.op", 6, 8),
68
+ span(CHILD_B, PARENT, "childB.op", 10, 12),
69
+ span(CHILD_C, PARENT, "childC.op", 14, 18),
70
+ span(CHILD_D, PARENT, "childD.op", 20, 25),
71
+ span(CHILD_E, PARENT, "childE.op", 28, 35),
72
+ span(CHILD_F, PARENT, "childF.op", 40, 55),
73
+ span(SIB_AFTER, ROOT, "siblingAfter.op", 65, 68),
74
+ span(TAIL, ROOT, "tail.op", 70, 95),
75
+ span(TAIL_CHILD, TAIL, "tailChild.op", 72, 90),
76
+ span(TAIL_GRAND, TAIL_CHILD, "tailGrandchild.op", 75, 85),
77
+ ],
78
+ },
79
+ ],
80
+ },
81
+ ],
82
+ }),
83
+ )
84
+
85
+ await storeRuntime.runPromise(program)
86
+ process.exit(0)
@@ -0,0 +1,271 @@
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 level-1 mode the waterfall is rendered in the left pane and span
64
+ // detail is rendered on the right. Determine the pane separator from the
65
+ // stable header line rather than mistaking tree branch glyphs for it.
66
+ const waterfallBody = (snap: string): readonly string[] => {
67
+ const lines = snap.split("\n")
68
+ const header = lines.find((line) => line.includes("TRACE DETAILS") && line.includes("SPAN"))
69
+ const paneSeparator = header?.indexOf("\u2502") ?? -1
70
+ const dividerIdxs: number[] = []
71
+ for (let i = 0; i < lines.length; i++) {
72
+ if (lines[i]!.startsWith("─")) dividerIdxs.push(i)
73
+ }
74
+ if (dividerIdxs.length < 2) return []
75
+ const start = dividerIdxs[dividerIdxs.length - 2]! + 1
76
+ const end = dividerIdxs[dividerIdxs.length - 1]!
77
+ return lines.slice(start, end).map((line) => {
78
+ const sliced = paneSeparator >= 0 ? line.slice(0, paneSeparator) : 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 port = 40_000 + Math.floor(Math.random() * 5_000)
107
+ const daemonEnv = { MOTEL_RUNTIME_DIR: tempDir, MOTEL_OTEL_DB_PATH: dbPath, MOTEL_OTEL_BASE_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_QUERY_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_PORT: String(port) }
108
+ const lastServicePath = join(tempDir, "last-service.txt")
109
+ let canRun = false
110
+
111
+ beforeAll(async () => {
112
+ canRun = await hasTuistory()
113
+ if (!canRun) return
114
+
115
+ // Pin selected service so the TUI lands on our seeded trace immediately.
116
+ writeFileSync(lastServicePath, SERVICE_NAME)
117
+
118
+ // Seed the DB in a child process. Doing it in-process collides with
119
+ // other tests that imported config.ts first (databasePath is captured at
120
+ // module load time and ESM cache busts on the runtime entry alone do not
121
+ // invalidate the transitively cached config module).
122
+ const seed = Bun.spawn({
123
+ cmd: ["bun", "run", "src/ui/waterfallNav.repro.seed.ts"],
124
+ cwd: process.cwd(),
125
+ env: {
126
+ ...process.env,
127
+ ...daemonEnv,
128
+ MOTEL_OTEL_DB_PATH: dbPath,
129
+ MOTEL_OTEL_RETENTION_HOURS: "24",
130
+ MOTEL_OTEL_ENABLED: "false",
131
+ },
132
+ stdout: "pipe",
133
+ stderr: "pipe",
134
+ })
135
+ const seedCode = await seed.exited
136
+ if (seedCode !== 0) {
137
+ const [out, err] = await Promise.all([
138
+ new Response(seed.stdout).text(),
139
+ new Response(seed.stderr).text(),
140
+ ])
141
+ throw new Error(`Seed subprocess failed (${seedCode})\nstdout: ${out}\nstderr: ${err}`)
142
+ }
143
+
144
+ // Make sure no stale session is hanging around with the same name.
145
+ await tui(["close", "--session", SESSION])
146
+
147
+ // Launch wide enough to exercise the waterfall + span-detail split.
148
+ const launch = await tui([
149
+ "launch",
150
+ "bun run src/index.tsx",
151
+ "--session", SESSION,
152
+ "--cols", "160",
153
+ "--rows", "40",
154
+ "--cwd", process.cwd(),
155
+ "--env", `MOTEL_OTEL_DB_PATH=${dbPath}`,
156
+ "--env", `MOTEL_RUNTIME_DIR=${tempDir}`,
157
+ "--env", `MOTEL_OTEL_BASE_URL=${daemonEnv.MOTEL_OTEL_BASE_URL}`,
158
+ "--env", `MOTEL_OTEL_QUERY_URL=${daemonEnv.MOTEL_OTEL_QUERY_URL}`,
159
+ "--env", `MOTEL_OTEL_PORT=${port}`,
160
+ "--env", "MOTEL_OTEL_ENABLED=false",
161
+ "--timeout", "15000",
162
+ ])
163
+ if (launch.code !== 0) {
164
+ throw new Error(`tuistory launch failed: ${launch.stderr || launch.stdout}`)
165
+ }
166
+
167
+ // Wait for the trace row to appear.
168
+ const waitResult = await tui(["wait", "root.op", "--session", SESSION, "--timeout", "10000"])
169
+ if (waitResult.code !== 0) {
170
+ const snap = (await tui(["snapshot", "--session", SESSION])).stdout
171
+ throw new Error(`Trace did not appear in TUI after seed.\nstderr: ${waitResult.stderr}\nstdout: ${waitResult.stdout}\nSnapshot:\n${snap}`)
172
+ }
173
+ await tui(["wait-idle", "--session", SESSION, "--timeout", "5000"])
174
+ }, 60_000)
175
+
176
+ afterAll(async () => {
177
+ if (canRun) {
178
+ await tui(["close", "--session", SESSION])
179
+ const stop = Bun.spawn({ cmd: ["bun", "run", "src/motel.ts", "stop"], cwd: process.cwd(), env: { ...process.env, ...daemonEnv }, stdout: "ignore", stderr: "ignore" })
180
+ await stop.exited
181
+ }
182
+ try {
183
+ rmSync(tempDir, { recursive: true, force: true })
184
+ } catch {}
185
+ })
186
+
187
+ it("collapse → expand cycle preserves the visible span list", async () => {
188
+ if (!canRun) return // tuistory unavailable: skip
189
+
190
+ // Enter the span navigation pane.
191
+ await press("return")
192
+ // Visible order: root, siblingBefore, parent, childA..F, siblingAfter, tail, tailChild, tailGrandchild
193
+ // Move down to parent.op (index 2): two j presses.
194
+ await press("j")
195
+ await press("j")
196
+
197
+ const before = waterfallBody(await snapshot())
198
+ const beforeText = before.join("\n")
199
+ // Sanity: every operation should be visible.
200
+ for (const op of [
201
+ "parent.op", "childA.op", "childB.op", "childC.op",
202
+ "childD.op", "childE.op", "childF.op",
203
+ "siblingBefore.op", "siblingAfter.op",
204
+ "tail.op", "tailChild.op", "tailGrandchild.op",
205
+ ]) {
206
+ expect(beforeText).toContain(op)
207
+ }
208
+
209
+ // Collapse parent.op
210
+ await press("h")
211
+ const collapsedText = waterfallBody(await snapshot()).join("\n")
212
+ expect(collapsedText).toContain("parent.op")
213
+ for (const child of ["childA.op", "childB.op", "childC.op", "childD.op", "childE.op", "childF.op"]) {
214
+ expect(collapsedText).not.toContain(child)
215
+ }
216
+
217
+ // Expand parent.op
218
+ await press("l")
219
+ const after = waterfallBody(await snapshot())
220
+ const afterText = after.join("\n")
221
+
222
+ // Every child must reappear after expand.
223
+ for (const child of ["childA.op", "childB.op", "childC.op", "childD.op", "childE.op", "childF.op"]) {
224
+ expect(afterText).toContain(child)
225
+ }
226
+ // Sibling structure must remain intact.
227
+ expect(afterText).toContain("siblingBefore.op")
228
+ expect(afterText).toContain("siblingAfter.op")
229
+ expect(afterText).toContain("tailGrandchild.op")
230
+
231
+ // And the waterfall body should be byte-identical to baseline.
232
+ expect(after).toEqual(before)
233
+ }, 60_000)
234
+
235
+ it("a SINGLE collapse/expand cycle does not drift the visible list", async () => {
236
+ if (!canRun) return
237
+
238
+ await press("escape")
239
+ await press("escape")
240
+ await press("return")
241
+ await press("j")
242
+ await press("j")
243
+
244
+ const baseline = waterfallBody(await snapshot())
245
+ await press("h")
246
+ await press("l")
247
+ const after = waterfallBody(await snapshot())
248
+ expect(after).toEqual(baseline)
249
+ }, 60_000)
250
+
251
+ it("repeated h/l cycles do not drift the visible list", async () => {
252
+ if (!canRun) return
253
+
254
+ // Reset by re-entering nav: esc out of any sub-mode then re-enter.
255
+ await press("escape")
256
+ await press("escape")
257
+ await press("return")
258
+ await press("j") // siblingBefore
259
+ await press("j") // parent
260
+
261
+ const baseline = waterfallBody(await snapshot())
262
+
263
+ for (let i = 0; i < 5; i++) {
264
+ await press("h")
265
+ await press("l")
266
+ }
267
+
268
+ const after = waterfallBody(await snapshot())
269
+ expect(after).toEqual(baseline)
270
+ }, 60_000)
271
+ })