@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,170 @@
1
+ import { RGBA, TextAttributes } from "@opentui/core"
2
+ import { colors } from "./theme.ts"
3
+ import { fitCell, truncateText } from "./format.ts"
4
+ import type { DetailView } from "./state.ts"
5
+
6
+ export const BlankRow = () => <box height={1} />
7
+
8
+ export const PlainLine = ({ text, fg = colors.text, bold = false }: { text: string; fg?: string; bold?: boolean }) => (
9
+ <box height={1}>
10
+ {bold ? (
11
+ <text wrapMode="none" truncate fg={fg} attributes={TextAttributes.BOLD}>
12
+ {text}
13
+ </text>
14
+ ) : (
15
+ <text wrapMode="none" truncate fg={fg}>
16
+ {text}
17
+ </text>
18
+ )}
19
+ </box>
20
+ )
21
+
22
+ export const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => (
23
+ <box height={1}>
24
+ {bg ? (
25
+ <text wrapMode="none" truncate fg={fg} bg={bg}>
26
+ {children}
27
+ </text>
28
+ ) : (
29
+ <text wrapMode="none" truncate fg={fg}>
30
+ {children}
31
+ </text>
32
+ )}
33
+ </box>
34
+ )
35
+
36
+ export const AlignedHeaderLine = ({ left, right, width, rightFg = colors.muted }: { left: string; right: string; width: number; rightFg?: string }) => {
37
+ const availableRightWidth = Math.max(8, width - left.length - 2)
38
+ const rightText = truncateText(right, availableRightWidth)
39
+ const gap = Math.max(2, width - left.length - rightText.length)
40
+
41
+ return (
42
+ <TextLine>
43
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{left}</span>
44
+ <span fg={colors.muted}>{" ".repeat(gap)}</span>
45
+ <span fg={rightFg}>{rightText}</span>
46
+ </TextLine>
47
+ )
48
+ }
49
+
50
+ export const Divider = ({ width }: { width: number }) => (
51
+ <PlainLine text={"\u2500".repeat(Math.max(1, width))} fg={colors.separator} />
52
+ )
53
+
54
+ /** Horizontal divider split into left ─ junction ─ right, using flex row so
55
+ * the junction character lands at exactly the same column as the SeparatorColumn. */
56
+ export const SplitDivider = ({ leftWidth, junction, rightWidth }: { leftWidth: number; junction: string; rightWidth: number }) => (
57
+ <box flexDirection="row" height={1}>
58
+ <box width={leftWidth}><text fg={colors.separator} wrapMode="none" truncate>{"\u2500".repeat(leftWidth)}</text></box>
59
+ <box width={1}><text fg={colors.separator}>{junction}</text></box>
60
+ <box width={rightWidth}><text fg={colors.separator} wrapMode="none" truncate>{"\u2500".repeat(rightWidth)}</text></box>
61
+ </box>
62
+ )
63
+
64
+ /** Row index → junction character override. Callers supply exactly the
65
+ * glyph they want at each row so the separator lines up with whatever
66
+ * divider geometry the neighboring panes happen to have:
67
+ * - `\u251c` (├) when only the right pane has a divider at that row
68
+ * - `\u2524` (┤) when only the left pane has a divider at that row
69
+ * - `\u253c` (┼) when both do
70
+ */
71
+ export const SeparatorColumn = ({ height, junctionChars }: { height: number; junctionChars?: ReadonlyMap<number, string> }) => {
72
+ const lines: string[] = []
73
+ for (let i = 0; i < Math.max(1, height); i++) {
74
+ lines.push(junctionChars?.get(i) ?? "\u2502")
75
+ }
76
+ return (
77
+ <box width={1} height={height} overflow="hidden">
78
+ <text fg={colors.separator}>{lines.join("\n")}</text>
79
+ </box>
80
+ )
81
+ }
82
+
83
+ export const FilterBar = ({ text, width }: { text: string; width: number }) => (
84
+ <TextLine fg={colors.accent}>
85
+ <span fg={colors.muted}>{"/"}</span>
86
+ <span fg={colors.text}>{fitCell(text, width - 2)}</span>
87
+ <span fg={colors.accent}>{"\u2588"}</span>
88
+ </TextLine>
89
+ )
90
+
91
+ const FooterKey = ({ label }: { label: string }) => <span fg={colors.count} attributes={TextAttributes.BOLD}>{label}</span>
92
+
93
+ export const HelpModal = ({ width, height, autoRefresh, themeLabel, onClose }: { width: number; height: number; autoRefresh: boolean; themeLabel: string; onClose: () => void }) => {
94
+ const panelWidth = Math.min(76, Math.max(52, width - 10))
95
+ const left = Math.max(2, Math.floor((width - panelWidth) / 2))
96
+ const top = Math.max(1, Math.floor(height / 5))
97
+ const sectionGap = Math.max(1, panelWidth - 24)
98
+ const row = (key: string, desc: string) => (
99
+ <TextLine>
100
+ <span fg={colors.count} attributes={TextAttributes.BOLD}>{key.padEnd(11)}</span>
101
+ <span fg={colors.muted}>{desc}</span>
102
+ </TextLine>
103
+ )
104
+
105
+ return (
106
+ <box position="absolute" zIndex={3000} left={0} top={0} width={width} height={height} backgroundColor={RGBA.fromInts(0, 0, 0, 110)} onMouseUp={onClose}>
107
+ <box position="absolute" left={left} top={top} width={panelWidth} flexDirection="column" backgroundColor={RGBA.fromInts(20, 20, 28, 255)}>
108
+ <box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} flexDirection="column">
109
+ <TextLine>
110
+ <span fg={colors.count} attributes={TextAttributes.BOLD}>Help</span>
111
+ <span fg={colors.muted}>{" ".repeat(sectionGap)}</span>
112
+ <span fg={colors.muted}>esc / enter / ? close</span>
113
+ </TextLine>
114
+ <BlankRow />
115
+ {row("j k ↑ ↓", "move selection")}
116
+ {row("enter", "select spans / open detail / jump from logs")}
117
+ {row("esc", "back out of detail or span selection")}
118
+ {row("t", `cycle theme (${themeLabel})`)}
119
+ {row("tab", "toggle service logs")}
120
+ {row("[ ]", "switch service")}
121
+ {row("/", "filter traces")}
122
+ {row("s", "cycle sort mode")}
123
+ {row("a", `auto refresh ${autoRefresh ? "on" : "off"}`)}
124
+ {row("r", "refresh traces")}
125
+ {row("y", "copy selected trace/span ids")}
126
+ {row("o / O", "open trace / open web UI")}
127
+ {row("gg / G", "jump to first / last")}
128
+ {row("^d / ^u", "page down / up")}
129
+ {row("q", "quit")}
130
+ </box>
131
+ </box>
132
+ </box>
133
+ )
134
+ }
135
+
136
+ export const FooterHints = ({ spanNavActive, detailView, autoRefresh, width: _width }: { spanNavActive: boolean; detailView: DetailView; autoRefresh: boolean; width: number }) => {
137
+ const enterAction = detailView === "service-logs"
138
+ ? "trace"
139
+ : spanNavActive && detailView === "waterfall"
140
+ ? "detail"
141
+ : "spans"
142
+ const escAction = spanNavActive
143
+ ? (detailView === "span-detail" ? "back" : "traces")
144
+ : null
145
+ const items: Array<[string, string]> = [
146
+ ["j/k", "move"],
147
+ ["enter", enterAction],
148
+ ...(escAction ? [["esc", escAction] as [string, string]] : []),
149
+ ["t", "theme"],
150
+ ["tab", "logs"],
151
+ ["/", "filter"],
152
+ ["s", "sort"],
153
+ ["a", autoRefresh ? "live" : "paused"],
154
+ ["?", "help"],
155
+ ["q", "quit"],
156
+ ]
157
+ const renderItems = (items: ReadonlyArray<readonly [string, string]>) => (
158
+ items.flatMap(([key, label], index) => [
159
+ <FooterKey key={`${key}-key`} label={key} />,
160
+ <span key={`${key}-label`} fg={colors.muted}>{` ${label}`}</span>,
161
+ index < items.length - 1 ? <span key={`${key}-sep`} fg={colors.separator}>{" · "}</span> : null,
162
+ ])
163
+ )
164
+
165
+ return (
166
+ <TextLine>
167
+ {renderItems(items)}
168
+ </TextLine>
169
+ )
170
+ }
@@ -0,0 +1,137 @@
1
+ import { Effect } from "effect"
2
+ import * as Atom from "effect/unstable/reactivity/Atom"
3
+ import { readFileSync } from "node:fs"
4
+ import { dirname } from "node:path"
5
+ import { config } from "../config.ts"
6
+ import type { LogItem, TraceItem, TraceSummaryItem } from "../domain.ts"
7
+ import { queryRuntime } from "../runtime.ts"
8
+ import { LogQueryService } from "../services/LogQueryService.ts"
9
+ import { TraceQueryService } from "../services/TraceQueryService.ts"
10
+ import type { ThemeName } from "./theme.ts"
11
+
12
+ export type LoadStatus = "loading" | "ready" | "error"
13
+ export type DetailView = "waterfall" | "span-detail" | "service-logs"
14
+
15
+ export interface TraceState {
16
+ readonly status: LoadStatus
17
+ readonly services: readonly string[]
18
+ readonly data: readonly TraceSummaryItem[]
19
+ readonly error: string | null
20
+ readonly fetchedAt: Date | null
21
+ }
22
+
23
+ export interface TraceDetailState {
24
+ readonly status: LoadStatus
25
+ readonly traceId: string | null
26
+ readonly data: TraceItem | null
27
+ readonly error: string | null
28
+ readonly fetchedAt: Date | null
29
+ }
30
+
31
+ export interface LogState {
32
+ readonly status: LoadStatus
33
+ readonly traceId: string | null
34
+ readonly data: readonly LogItem[]
35
+ readonly error: string | null
36
+ readonly fetchedAt: Date | null
37
+ }
38
+
39
+ export interface ServiceLogState {
40
+ readonly status: LoadStatus
41
+ readonly serviceName: string | null
42
+ readonly data: readonly LogItem[]
43
+ readonly error: string | null
44
+ readonly fetchedAt: Date | null
45
+ }
46
+
47
+ export const initialTraceState: TraceState = {
48
+ status: "loading",
49
+ services: [],
50
+ data: [],
51
+ error: null,
52
+ fetchedAt: null,
53
+ }
54
+
55
+ export const initialLogState: LogState = {
56
+ status: "ready",
57
+ traceId: null,
58
+ data: [],
59
+ error: null,
60
+ fetchedAt: null,
61
+ }
62
+
63
+ export const initialTraceDetailState: TraceDetailState = {
64
+ status: "ready",
65
+ traceId: null,
66
+ data: null,
67
+ error: null,
68
+ fetchedAt: null,
69
+ }
70
+
71
+ export const initialServiceLogState: ServiceLogState = {
72
+ status: "ready",
73
+ serviceName: null,
74
+ data: [],
75
+ error: null,
76
+ fetchedAt: null,
77
+ }
78
+
79
+ export const traceStateAtom = Atom.make(initialTraceState).pipe(Atom.keepAlive)
80
+ export const traceDetailStateAtom = Atom.make(initialTraceDetailState).pipe(Atom.keepAlive)
81
+ export const logStateAtom = Atom.make(initialLogState).pipe(Atom.keepAlive)
82
+ export const serviceLogStateAtom = Atom.make(initialServiceLogState).pipe(Atom.keepAlive)
83
+ export const selectedServiceLogIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
84
+ export const selectedTraceIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
85
+ const lastServicePath = `${dirname(config.otel.databasePath)}/last-service.txt`
86
+ const readLastService = (): string | null => {
87
+ try { return readFileSync(lastServicePath, "utf-8").trim() || null }
88
+ catch { return null }
89
+ }
90
+
91
+ let lastPersistedService = readLastService()
92
+
93
+ export const persistSelectedService = (service: string) => {
94
+ if (service === lastPersistedService) return
95
+ lastPersistedService = service
96
+ Bun.write(lastServicePath, service).catch(() => {})
97
+ }
98
+
99
+ export const selectedTraceServiceAtom = Atom.make<string | null>(readLastService() ?? config.otel.serviceName).pipe(Atom.keepAlive)
100
+ export const refreshNonceAtom = Atom.make(0).pipe(Atom.keepAlive)
101
+ export const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
102
+ export const selectedSpanIndexAtom = Atom.make<number | null>(null).pipe(Atom.keepAlive)
103
+ export const detailViewAtom = Atom.make<DetailView>("waterfall").pipe(Atom.keepAlive)
104
+ export const showHelpAtom = Atom.make(false).pipe(Atom.keepAlive)
105
+ export const autoRefreshAtom = Atom.make(false).pipe(Atom.keepAlive)
106
+ export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
107
+ export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
108
+
109
+ const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
110
+ const readLastTheme = (): ThemeName => {
111
+ try {
112
+ const raw = readFileSync(lastThemePath, "utf-8").trim()
113
+ return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : "motel-default"
114
+ } catch {
115
+ return "motel-default"
116
+ }
117
+ }
118
+
119
+ let lastPersistedTheme = readLastTheme()
120
+
121
+ export const persistSelectedTheme = (theme: ThemeName) => {
122
+ if (theme === lastPersistedTheme) return
123
+ lastPersistedTheme = theme
124
+ Bun.write(lastThemePath, theme).catch(() => {})
125
+ }
126
+
127
+ export const selectedThemeAtom = Atom.make<ThemeName>(readLastTheme()).pipe(Atom.keepAlive)
128
+
129
+ export type TraceSortMode = "recent" | "slowest" | "errors"
130
+ export const traceSortAtom = Atom.make<TraceSortMode>("recent").pipe(Atom.keepAlive)
131
+ export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<string>).pipe(Atom.keepAlive)
132
+
133
+ export const loadTraceServices = () => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
134
+ export const loadRecentTraceSummaries = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listTraceSummaries(serviceName)))
135
+ export const loadTraceDetail = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.getTrace(traceId)))
136
+ export const loadTraceLogs = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listTraceLogs(traceId)))
137
+ export const loadServiceLogs = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listRecentLogs(serviceName)))
@@ -0,0 +1,153 @@
1
+ export interface ThemeColors {
2
+ readonly screenBg: string
3
+ readonly text: string
4
+ readonly muted: string
5
+ readonly separator: string
6
+ readonly accent: string
7
+ readonly error: string
8
+ readonly selectedBg: string
9
+ readonly warning: string
10
+ readonly selectedText: string
11
+ readonly count: string
12
+ readonly passing: string
13
+ readonly defaultService: string
14
+ readonly footerBg: string
15
+ readonly treeLine: string
16
+ readonly previewKey: string
17
+ }
18
+
19
+ export interface ThemeWaterfallColors {
20
+ readonly bar: string
21
+ readonly barError: string
22
+ readonly barBg: string
23
+ readonly barLane: string
24
+ readonly barSelected: string
25
+ readonly barSelectedError: string
26
+ }
27
+
28
+ export interface ThemeDefinition {
29
+ readonly name: ThemeName
30
+ readonly label: string
31
+ readonly colors: ThemeColors
32
+ readonly waterfall: ThemeWaterfallColors
33
+ }
34
+
35
+ const motelDefaultTheme: ThemeDefinition = {
36
+ name: "motel-default",
37
+ label: "Motel Default",
38
+ colors: {
39
+ screenBg: "#1c1b29",
40
+ text: "#ede7da",
41
+ muted: "#9f9788",
42
+ separator: "#6f685d",
43
+ accent: "#f4a51c",
44
+ error: "#f97316",
45
+ selectedBg: "#263044",
46
+ warning: "#facc15",
47
+ selectedText: "#f8fafc",
48
+ count: "#d7c5a1",
49
+ passing: "#7dd3a3",
50
+ defaultService: "#93c5fd",
51
+ footerBg: "#000000",
52
+ treeLine: "#524d45",
53
+ previewKey: "#6a6358",
54
+ },
55
+ waterfall: {
56
+ bar: "#f4a51c",
57
+ barError: "#f97316",
58
+ barBg: "#2a2520",
59
+ barLane: "#4a4338",
60
+ barSelected: "#e8c547",
61
+ barSelectedError: "#ff8c42",
62
+ },
63
+ }
64
+
65
+ const tokyoNightTheme: ThemeDefinition = {
66
+ name: "tokyo-night",
67
+ label: "Tokyo Night",
68
+ colors: {
69
+ screenBg: "#1a1b26",
70
+ text: "#c0caf5",
71
+ muted: "#7a88b6",
72
+ separator: "#565f89",
73
+ accent: "#7aa2f7",
74
+ error: "#f7768e",
75
+ selectedBg: "#283457",
76
+ warning: "#e0af68",
77
+ selectedText: "#f8fbff",
78
+ count: "#bb9af7",
79
+ passing: "#9ece6a",
80
+ defaultService: "#73daca",
81
+ footerBg: "#000000",
82
+ treeLine: "#414868",
83
+ previewKey: "#6b739c",
84
+ },
85
+ waterfall: {
86
+ bar: "#7aa2f7",
87
+ barError: "#f7768e",
88
+ barBg: "#1f2335",
89
+ barLane: "#2a3050",
90
+ barSelected: "#bb9af7",
91
+ barSelectedError: "#ff9eaf",
92
+ },
93
+ }
94
+
95
+ const catppuccinTheme: ThemeDefinition = {
96
+ name: "catppuccin",
97
+ label: "Catppuccin Mocha",
98
+ colors: {
99
+ screenBg: "#11111b",
100
+ text: "#cdd6f4",
101
+ muted: "#a6adc8",
102
+ separator: "#6c7086",
103
+ accent: "#f5c2e7",
104
+ error: "#f38ba8",
105
+ selectedBg: "#313244",
106
+ warning: "#f9e2af",
107
+ selectedText: "#f5f7ff",
108
+ count: "#fab387",
109
+ passing: "#a6e3a1",
110
+ defaultService: "#89dceb",
111
+ footerBg: "#000000",
112
+ treeLine: "#585b70",
113
+ previewKey: "#9399b2",
114
+ },
115
+ waterfall: {
116
+ bar: "#f5c2e7",
117
+ barError: "#f38ba8",
118
+ barBg: "#1e1e2e",
119
+ barLane: "#313244",
120
+ barSelected: "#fab387",
121
+ barSelectedError: "#eba0ac",
122
+ },
123
+ }
124
+
125
+ export const themes = {
126
+ "motel-default": motelDefaultTheme,
127
+ "tokyo-night": tokyoNightTheme,
128
+ catppuccin: catppuccinTheme,
129
+ } as const
130
+
131
+ export type ThemeName = keyof typeof themes
132
+
133
+ export const themeOrder: readonly ThemeName[] = ["motel-default", "tokyo-night", "catppuccin"]
134
+
135
+ export const colors: ThemeColors = { ...motelDefaultTheme.colors }
136
+ export const waterfallColors: ThemeWaterfallColors = { ...motelDefaultTheme.waterfall }
137
+
138
+ export const applyTheme = (name: ThemeName) => {
139
+ const theme = themes[name] ?? motelDefaultTheme
140
+ Object.assign(colors, theme.colors)
141
+ Object.assign(waterfallColors, theme.waterfall)
142
+ return theme
143
+ }
144
+
145
+ export const cycleThemeName = (current: ThemeName) => {
146
+ const nextIndex = (themeOrder.indexOf(current) + 1) % themeOrder.length
147
+ return themeOrder[nextIndex] ?? themeOrder[0]
148
+ }
149
+
150
+ export const themeLabel = (name: ThemeName) => themes[name]?.label ?? motelDefaultTheme.label
151
+
152
+ export const SEPARATOR = " \u00b7 "
153
+ export const G_PREFIX_TIMEOUT_MS = 500
@@ -0,0 +1,115 @@
1
+ /**
2
+ * End-to-end reproducer for waterfall underfilling the trace-details pane.
3
+ *
4
+ * Strategy:
5
+ * 1. Seed a deterministic trace into a fresh SQLite database.
6
+ * 2. Launch the motel TUI under tuistory in narrow mode so trace details take
7
+ * the full screen width.
8
+ * 3. Drill into the trace details view.
9
+ * 4. Assert the root waterfall row reaches the right-side duration column
10
+ * instead of stopping several cells early.
11
+ */
12
+
13
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test"
14
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
15
+ import { tmpdir } from "node:os"
16
+ import { join } from "node:path"
17
+
18
+ const TUISTORY_BIN = "tuistory"
19
+ const SESSION = `motel-trace-width-${Date.now()}`
20
+
21
+ const hasTuistory = async () => {
22
+ try {
23
+ const proc = Bun.spawn({ cmd: ["which", TUISTORY_BIN], stdout: "pipe", stderr: "ignore" })
24
+ return (await proc.exited) === 0
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ const tui = async (args: readonly string[]) => {
31
+ const proc = Bun.spawn({ cmd: [TUISTORY_BIN, ...args], stdout: "pipe", stderr: "pipe" })
32
+ const [stdout, stderr] = await Promise.all([
33
+ new Response(proc.stdout).text(),
34
+ new Response(proc.stderr).text(),
35
+ ])
36
+ return { code: await proc.exited, stdout, stderr }
37
+ }
38
+
39
+ const snapshot = async () => (await tui(["snapshot", "--session", SESSION])).stdout
40
+
41
+ const press = async (...keys: string[]) => {
42
+ await tui(["press", "--session", SESSION, ...keys])
43
+ await Bun.sleep(120)
44
+ }
45
+
46
+ const dividerWidth = (snap: string) =>
47
+ snap.split("\n").find((line) => line.startsWith("─"))?.length ?? 0
48
+
49
+ const rootWaterfallRow = (snap: string) =>
50
+ snap.split("\n").find((line) => line.startsWith(" ▾ root.op") || line.startsWith(" ▸ root.op") || line.startsWith(" · root.op")) ?? null
51
+
52
+ describe("trace details waterfall width (end-to-end TUI)", () => {
53
+ const tempDir = mkdtempSync(join(tmpdir(), "motel-trace-width-"))
54
+ const dbPath = join(tempDir, "telemetry.sqlite")
55
+ const lastServicePath = join(tempDir, "last-service.txt")
56
+ let canRun = false
57
+
58
+ beforeAll(async () => {
59
+ canRun = await hasTuistory()
60
+ if (!canRun) return
61
+
62
+ writeFileSync(lastServicePath, "waterfall-repro")
63
+
64
+ const seed = Bun.spawn({
65
+ cmd: ["bun", "run", "src/ui/waterfallNav.repro.seed.ts"],
66
+ cwd: process.cwd(),
67
+ env: {
68
+ ...process.env,
69
+ MOTEL_OTEL_DB_PATH: dbPath,
70
+ MOTEL_OTEL_ENABLED: "false",
71
+ },
72
+ stdout: "pipe",
73
+ stderr: "pipe",
74
+ })
75
+ const seedCode = await seed.exited
76
+ if (seedCode !== 0) {
77
+ const err = await new Response(seed.stderr).text()
78
+ throw new Error(`seed failed: ${err}`)
79
+ }
80
+
81
+ await tui(["close", "--session", SESSION])
82
+ const launch = await tui([
83
+ "launch",
84
+ "bun run src/index.tsx",
85
+ "--session", SESSION,
86
+ "--cols", "96",
87
+ "--rows", "40",
88
+ "--cwd", process.cwd(),
89
+ "--env", `MOTEL_OTEL_DB_PATH=${dbPath}`,
90
+ "--env", "MOTEL_OTEL_ENABLED=false",
91
+ "--timeout", "15000",
92
+ ])
93
+ if (launch.code !== 0) throw new Error(`launch failed: ${launch.stderr}`)
94
+ await tui(["wait", "root.op", "--session", SESSION, "--timeout", "10000"])
95
+ await tui(["wait-idle", "--session", SESSION, "--timeout", "5000"])
96
+ }, 60_000)
97
+
98
+ afterAll(async () => {
99
+ if (canRun) await tui(["close", "--session", SESSION])
100
+ try { rmSync(tempDir, { recursive: true, force: true }) } catch {}
101
+ })
102
+
103
+ it("fills the full-width trace details pane in narrow mode", async () => {
104
+ if (!canRun) return
105
+
106
+ await press("return")
107
+ const snap = await snapshot()
108
+ const divider = dividerWidth(snap)
109
+ const row = rootWaterfallRow(snap)
110
+
111
+ expect(divider).toBe(96)
112
+ expect(row).not.toBeNull()
113
+ expect(row!.length).toBeGreaterThanOrEqual(divider - 1)
114
+ }, 60_000)
115
+ })
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Seed script for traceSortNav.repro.test.ts. Invoked as a child process so
3
+ * `config.ts` picks up our test DB path at module-load time.
4
+ */
5
+
6
+ import { Effect } from "effect"
7
+ import { storeRuntime } from "../runtime.ts"
8
+ import { TelemetryStore } from "../services/TelemetryStore.ts"
9
+
10
+ const SERVICE_NAME = "sort-nav-repro"
11
+ const base = BigInt(Date.now()) * 1_000_000n
12
+ const ms = (n: number) => String(base + BigInt(n) * 1_000_000n)
13
+
14
+ // A is the oldest, E is the newest. Durations are arranged so the three
15
+ // sort orders we care about all differ:
16
+ // recent = [E, D, C, B, A]
17
+ // slowest = [D, B, E, A, C]
18
+ // errors = none set, falls back to recency
19
+ // Enough traces that the list scrolls in a typical 40-row terminal, and
20
+ // enough duration spread that `slowest` order is meaningfully different
21
+ // from `recent`.
22
+ const specs: ReadonlyArray<{ id: string; op: string; startMsAgo: number; durMs: number }> = [
23
+ { id: "a", op: "opA", startMsAgo: 1500, durMs: 10 },
24
+ { id: "b", op: "opB", startMsAgo: 1400, durMs: 50 },
25
+ { id: "c", op: "opC", startMsAgo: 1300, durMs: 5 },
26
+ { id: "d", op: "opD", startMsAgo: 1200, durMs: 100 },
27
+ { id: "e", op: "opE", startMsAgo: 1100, durMs: 25 },
28
+ { id: "f", op: "opF", startMsAgo: 1000, durMs: 75 },
29
+ { id: "g", op: "opG", startMsAgo: 900, durMs: 3 },
30
+ { id: "h", op: "opH", startMsAgo: 800, durMs: 200 },
31
+ { id: "i", op: "opI", startMsAgo: 700, durMs: 15 },
32
+ { id: "j", op: "opJ", startMsAgo: 600, durMs: 60 },
33
+ { id: "k", op: "opK", startMsAgo: 500, durMs: 1 },
34
+ { id: "l", op: "opL", startMsAgo: 400, durMs: 150 },
35
+ { id: "m", op: "opM", startMsAgo: 300, durMs: 40 },
36
+ { id: "n", op: "opN", startMsAgo: 200, durMs: 20 },
37
+ { id: "o", op: "opO", startMsAgo: 100, durMs: 7 },
38
+ ]
39
+
40
+ const resourceSpans = specs.map((spec) => ({
41
+ resource: { attributes: [{ key: "service.name", value: { stringValue: SERVICE_NAME } }] },
42
+ scopeSpans: [
43
+ {
44
+ scope: { name: "s" },
45
+ spans: [
46
+ {
47
+ traceId: spec.id.repeat(32),
48
+ spanId: spec.id.repeat(16),
49
+ name: spec.op,
50
+ kind: 1,
51
+ startTimeUnixNano: ms(-spec.startMsAgo),
52
+ endTimeUnixNano: ms(-spec.startMsAgo + spec.durMs),
53
+ },
54
+ ],
55
+ },
56
+ ],
57
+ }))
58
+
59
+ await storeRuntime.runPromise(
60
+ Effect.flatMap(TelemetryStore.asEffect(), (store) => store.ingestTraces({ resourceSpans })),
61
+ )
62
+ process.exit(0)