@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,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end reproducer for "navigation broken after changing sort".
|
|
3
|
+
*
|
|
4
|
+
* Strategy:
|
|
5
|
+
* 1. Seed 5 traces with distinct durations so `recent` and `slowest` give
|
|
6
|
+
* obviously different orderings.
|
|
7
|
+
* 2. Launch the TUI, capture the default (recent) order and the selected row.
|
|
8
|
+
* 3. Press `s` to switch to `slowest`, capture the new order.
|
|
9
|
+
* 4. Drive `j` / `k` and make sure the highlighted row steps through the
|
|
10
|
+
* *sorted* list, not the underlying raw order.
|
|
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-sort-${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
|
+
/**
|
|
47
|
+
* Extract the ordered list of operation names that appear as trace rows in
|
|
48
|
+
* the left pane, and the currently-selected operation inferred from the
|
|
49
|
+
* TRACE DETAILS pane on the right (whose second row shows the selected
|
|
50
|
+
* trace's root operation name). tuistory's plain-text snapshots don't
|
|
51
|
+
* preserve background color, so the visual "selected row" marker in
|
|
52
|
+
* TraceList isn't visible — we use the right pane as the source of truth.
|
|
53
|
+
*/
|
|
54
|
+
const listRows = (snap: string): { readonly rows: readonly string[]; readonly selected: string | null } => {
|
|
55
|
+
const rows: string[] = []
|
|
56
|
+
let selected: string | null = null
|
|
57
|
+
let inDetailsPane = false
|
|
58
|
+
let detailsLinesConsumed = 0
|
|
59
|
+
for (const raw of snap.split("\n")) {
|
|
60
|
+
const leftHalf = raw.split("\u2502")[0] ?? raw
|
|
61
|
+
const rightHalf = raw.includes("\u2502") ? raw.split("\u2502").slice(1).join("\u2502") : ""
|
|
62
|
+
|
|
63
|
+
// Trace rows: left pane, `·` then `op #hash`.
|
|
64
|
+
const rowMatch = leftHalf.match(/^\s+\u00b7\s+(\S+)\s+#/)
|
|
65
|
+
if (rowMatch) rows.push(rowMatch[1]!)
|
|
66
|
+
|
|
67
|
+
// Selected trace: right pane, line immediately after `TRACE DETAILS`
|
|
68
|
+
// header holds the selected root operation name.
|
|
69
|
+
if (rightHalf.includes("TRACE DETAILS") || leftHalf.includes("TRACE DETAILS")) {
|
|
70
|
+
inDetailsPane = true
|
|
71
|
+
detailsLinesConsumed = 0
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
if (inDetailsPane) {
|
|
75
|
+
detailsLinesConsumed++
|
|
76
|
+
if (detailsLinesConsumed === 1) {
|
|
77
|
+
// The op name row: right pane (if wide) or left pane (if narrow).
|
|
78
|
+
const source = rightHalf || leftHalf
|
|
79
|
+
const opMatch = source.match(/^\s*(\S+)/)
|
|
80
|
+
if (opMatch && opMatch[1] !== "No" && opMatch[1] !== "waiting") {
|
|
81
|
+
selected = opMatch[1]!
|
|
82
|
+
}
|
|
83
|
+
inDetailsPane = false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { rows, selected }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const SERVICE_NAME = "sort-nav-repro"
|
|
91
|
+
|
|
92
|
+
describe("trace navigation after changing sort", () => {
|
|
93
|
+
const tempDir = mkdtempSync(join(tmpdir(), "motel-sort-repro-"))
|
|
94
|
+
const dbPath = join(tempDir, "telemetry.sqlite")
|
|
95
|
+
const lastServicePath = join(tempDir, "last-service.txt")
|
|
96
|
+
let canRun = false
|
|
97
|
+
|
|
98
|
+
beforeAll(async () => {
|
|
99
|
+
canRun = await hasTuistory()
|
|
100
|
+
if (!canRun) return
|
|
101
|
+
|
|
102
|
+
writeFileSync(lastServicePath, SERVICE_NAME)
|
|
103
|
+
|
|
104
|
+
// Seed in a child process so config.ts picks up our DB path fresh.
|
|
105
|
+
const seed = Bun.spawn({
|
|
106
|
+
cmd: ["bun", "run", "src/ui/traceSortNav.repro.seed.ts"],
|
|
107
|
+
cwd: process.cwd(),
|
|
108
|
+
env: { ...process.env, MOTEL_OTEL_DB_PATH: dbPath, MOTEL_OTEL_ENABLED: "false" },
|
|
109
|
+
stdout: "pipe",
|
|
110
|
+
stderr: "pipe",
|
|
111
|
+
})
|
|
112
|
+
const seedCode = await seed.exited
|
|
113
|
+
if (seedCode !== 0) {
|
|
114
|
+
const err = await new Response(seed.stderr).text()
|
|
115
|
+
throw new Error(`seed failed: ${err}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await tui(["close", "--session", SESSION])
|
|
119
|
+
// Use a modest height (20 rows) where the 15-trace list must scroll to
|
|
120
|
+
// reveal the bottom rows while still leaving room for the details pane
|
|
121
|
+
// on the right (needed by the test's selected-trace extraction).
|
|
122
|
+
const launch = await tui([
|
|
123
|
+
"launch",
|
|
124
|
+
"bun run src/index.tsx",
|
|
125
|
+
"--session", SESSION,
|
|
126
|
+
"--cols", "120",
|
|
127
|
+
"--rows", "20",
|
|
128
|
+
"--cwd", process.cwd(),
|
|
129
|
+
"--env", `MOTEL_OTEL_DB_PATH=${dbPath}`,
|
|
130
|
+
"--env", "MOTEL_OTEL_ENABLED=false",
|
|
131
|
+
"--timeout", "15000",
|
|
132
|
+
])
|
|
133
|
+
if (launch.code !== 0) throw new Error(`launch failed: ${launch.stderr}`)
|
|
134
|
+
await tui(["wait", "opE", "--session", SESSION, "--timeout", "10000"])
|
|
135
|
+
await tui(["wait-idle", "--session", SESSION, "--timeout", "5000"])
|
|
136
|
+
}, 60_000)
|
|
137
|
+
|
|
138
|
+
afterAll(async () => {
|
|
139
|
+
if (canRun) await tui(["close", "--session", SESSION])
|
|
140
|
+
try { rmSync(tempDir, { recursive: true, force: true }) } catch {}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// Expected orders given the seed (A..O):
|
|
144
|
+
// recent : O N M L K J I H G F E D C B A (newest first)
|
|
145
|
+
// slowest: H L D F J B M E N I A O C G K (durations 200..1 ms)
|
|
146
|
+
const RECENT_ORDER = ["opO", "opN", "opM", "opL", "opK", "opJ", "opI", "opH", "opG", "opF", "opE", "opD", "opC", "opB", "opA"]
|
|
147
|
+
const SLOWEST_ORDER = ["opH", "opL", "opD", "opF", "opJ", "opB", "opM", "opE", "opN", "opI", "opA", "opO", "opC", "opG", "opK"]
|
|
148
|
+
|
|
149
|
+
it("default sort is 'recent' (most recent first), selection starts at the top", async () => {
|
|
150
|
+
if (!canRun) return
|
|
151
|
+
const snap = await snapshot()
|
|
152
|
+
const parsed = listRows(snap)
|
|
153
|
+
if (parsed.selected === null) {
|
|
154
|
+
// Dump raw snapshot to aid diagnosis when the extraction helper misses.
|
|
155
|
+
console.error("--- RAW SNAPSHOT ---\n" + snap + "\n--- END ---")
|
|
156
|
+
}
|
|
157
|
+
expect(parsed.selected).toBe("opO")
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it("after `s`, the visible order is `slowest` and selection stays on the same trace", async () => {
|
|
161
|
+
if (!canRun) return
|
|
162
|
+
await press("s")
|
|
163
|
+
const { selected } = listRows(await snapshot())
|
|
164
|
+
// opO moves from recent #0 to slowest #11. Selection follows the trace.
|
|
165
|
+
expect(selected).toBe("opO")
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("j moves through the SORTED order (not raw data order)", async () => {
|
|
169
|
+
if (!canRun) return
|
|
170
|
+
// Currently selected = opO at slowest index 11 (SLOWEST_ORDER[11] = "opO").
|
|
171
|
+
// Press j repeatedly and each step should land on the next sorted row.
|
|
172
|
+
const startIdx = SLOWEST_ORDER.indexOf("opO")
|
|
173
|
+
for (let offset = 1; offset <= 3; offset++) {
|
|
174
|
+
await press("j")
|
|
175
|
+
const expected = SLOWEST_ORDER[startIdx + offset]
|
|
176
|
+
const { selected } = listRows(await snapshot())
|
|
177
|
+
expect(selected).toBe(expected)
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("k moves backward through the sorted order", async () => {
|
|
182
|
+
if (!canRun) return
|
|
183
|
+
// We are 3 rows past opO in SLOWEST_ORDER.
|
|
184
|
+
for (let i = 0; i < 3; i++) await press("k")
|
|
185
|
+
expect(listRows(await snapshot()).selected).toBe("opO")
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("G jumps to the bottom of the sorted list, gg to the top", async () => {
|
|
189
|
+
if (!canRun) return
|
|
190
|
+
await press("G")
|
|
191
|
+
expect(listRows(await snapshot()).selected).toBe(SLOWEST_ORDER[SLOWEST_ORDER.length - 1]!)
|
|
192
|
+
|
|
193
|
+
await press("g", "g")
|
|
194
|
+
expect(listRows(await snapshot()).selected).toBe(SLOWEST_ORDER[0]!)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("switching sort back to 'recent' restores recency order and keeps selection on the same trace", async () => {
|
|
198
|
+
if (!canRun) return
|
|
199
|
+
// Currently on SLOWEST_ORDER[0] = "opH". Press `s` twice: slowest → errors → recent.
|
|
200
|
+
await press("s")
|
|
201
|
+
await press("s")
|
|
202
|
+
const { selected } = listRows(await snapshot())
|
|
203
|
+
expect(selected).toBe("opH")
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it("scrolling: selecting a trace near the bottom brings it into view, and changing sort keeps it in view", async () => {
|
|
207
|
+
if (!canRun) return
|
|
208
|
+
// Jump to the bottom of the recent list (opA), then change sort to slowest.
|
|
209
|
+
// opA is at slowest index 10 — well down the list. Selection must stay on opA
|
|
210
|
+
// AND the viewport must scroll to show it.
|
|
211
|
+
await press("G")
|
|
212
|
+
expect(listRows(await snapshot()).selected).toBe("opA")
|
|
213
|
+
|
|
214
|
+
await press("s")
|
|
215
|
+
const after = listRows(await snapshot())
|
|
216
|
+
expect(after.selected).toBe("opA")
|
|
217
|
+
// The row for opA must be visible in the rendered list.
|
|
218
|
+
expect(after.rows).toContain("opA")
|
|
219
|
+
})
|
|
220
|
+
})
|