| 1 | /** |
| 2 | * Interactive opentui reporter — a k9s-style live view of the run. |
| 3 | * |
| 4 | * Layout: |
| 5 | * header — run stats (pass/fail/running/queued, elapsed, import progress) |
| 6 | * list — files with their tests nested inside (expand with enter / l) |
| 7 | * detail — Enter on a test opens its error + captured output |
| 8 | * footer — key hints / filter input / status toggles |
| 9 | * |
| 10 | * Keys (vim-style): |
| 11 | * j/k or arrows move one line space / ^d ^u / ^f ^b page / half page |
| 12 | * gg / G top / bottom / live type-to-filter |
| 13 | * enter open test / toggle h / l collapse / expand file |
| 14 | * e / c expand / collapse all visible files |
| 15 | * esc back / clear filter p f n s toggle pass/fail/pending/skipped |
| 16 | * r retry test / file R retry all failed tests |
| 17 | * x kill running test |
| 18 | * y copy error+output q quit |
| 19 | * |
| 20 | * Mouse: the wheel scrolls the viewable content without moving the selected |
| 21 | * line; clicking a row selects it. Drag-to-select text works in the detail |
| 22 | * pane (enter) and copies to the clipboard on release (OSC52 + pbcopy); |
| 23 | * list rows are not text-selectable — they're a recycled pool, and an |
| 24 | * anchored selection highlight would leave residue as rows repaint (use `y` |
| 25 | * to copy a row's details instead). |
| 26 | * |
| 27 | * Colors follow the terminal theme: default foreground/background |
| 28 | * everywhere, ANSI palette colors only for status glyphs, and a subtle |
| 29 | * theme-aware background for the selected row (neovim-style). |
| 30 | * |
| 31 | * Performance: the list is a fixed pool of Text rows (one per terminal |
| 32 | * line) painted from the visible window only — updates are O(window), never |
| 33 | * O(total tests). |
| 34 | */ |
| 35 | import { |
| 36 | BoxRenderable, |
| 37 | bg, |
| 38 | CliRenderEvents, |
| 39 | createCliRenderer, |
| 40 | dim, |
| 41 | green, |
| 42 | red, |
| 43 | ScrollBoxRenderable, |
| 44 | strikethrough, |
| 45 | StyledText, |
| 46 | stringToStyledText, |
| 47 | TextRenderable, |
| 48 | yellow, |
| 49 | type CliRenderer, |
| 50 | type KeyEvent, |
| 51 | type MouseEvent, |
| 52 | type Selection, |
| 53 | type TextChunk, |
| 54 | } from "@opentui/core"; |
| 55 | import * as Effect from "effect/Effect"; |
| 56 | import * as Layer from "effect/Layer"; |
| 57 | |
| 58 | import type { LogEntry } from "./Model.ts"; |
| 59 | import { |
| 60 | Reporter, |
| 61 | type RunController, |
| 62 | type RunSummary, |
| 63 | type TestEvent, |
| 64 | type TestMeta, |
| 65 | type TestResult, |
| 66 | } from "./Reporter.ts"; |
| 67 | import { captureStrayOutput } from "./StrayOutput.ts"; |
| 68 | |
| 69 | type Status = "queued" | "running" | "pass" | "fail" | "skip" | "todo"; |
| 70 | |
| 71 | /** Visibility toggle groups (`p` / `f` / `n` / `s`). */ |
| 72 | type StatusGroup = "pass" | "fail" | "pending" | "skipped"; |
| 73 | |
| 74 | const groupOf = (status: Status): StatusGroup => { |
| 75 | switch (status) { |
| 76 | case "pass": |
| 77 | return "pass"; |
| 78 | case "fail": |
| 79 | return "fail"; |
| 80 | case "queued": |
| 81 | case "running": |
| 82 | return "pending"; |
| 83 | case "skip": |
| 84 | case "todo": |
| 85 | return "skipped"; |
| 86 | } |
| 87 | }; |
| 88 | |
| 89 | const GLYPH: Record<Status, string> = { |
| 90 | queued: "·", |
| 91 | running: "◐", |
| 92 | pass: "✓", |
| 93 | fail: "✗", |
| 94 | skip: "↓", |
| 95 | todo: "○", |
| 96 | }; |
| 97 | |
| 98 | /** ANSI palette chunk for a status glyph — follows the terminal theme. */ |
| 99 | const statusChunk = (status: Status, text: string): TextChunk => { |
| 100 | switch (status) { |
| 101 | case "pass": |
| 102 | return green(text); |
| 103 | case "fail": |
| 104 | return red(text); |
| 105 | case "running": |
| 106 | case "todo": |
| 107 | // todo is warning-colored: unimplemented coverage must not blend in. |
| 108 | return yellow(text); |
| 109 | case "queued": |
| 110 | case "skip": |
| 111 | return dim(text); |
| 112 | } |
| 113 | }; |
| 114 | |
| 115 | const formatDuration = (ms: number): string => |
| 116 | ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`; |
| 117 | |
| 118 | // Captured output is replayed verbatim (ANSI is stripped at capture time). |
| 119 | const formatLogs = (logs: ReadonlyArray<LogEntry>): string => |
| 120 | logs.map((log) => log.message).join("\n"); |
| 121 | |
| 122 | // --------------------------------------------------------------------------- |
| 123 | // Model: files with nested tests |
| 124 | // --------------------------------------------------------------------------- |
| 125 | |
| 126 | interface Entry { |
| 127 | readonly meta: TestMeta; |
| 128 | readonly file: FileNode; |
| 129 | status: Status; |
| 130 | result?: TestResult; |
| 131 | /** Set on TestStart; drives the live elapsed timer on running rows. */ |
| 132 | startedAt?: number; |
| 133 | /** LIVE captured-output buffer while running (runner appends in place). */ |
| 134 | liveLogs?: ReadonlyArray<LogEntry>; |
| 135 | /** Cached plain-text row label — recomputed only on status/result change. */ |
| 136 | label: string; |
| 137 | } |
| 138 | |
| 139 | interface FileNode { |
| 140 | readonly file: string; |
| 141 | readonly tests: Array<Entry>; |
| 142 | readonly counts: Record<Status, number>; |
| 143 | /** Manual expand/collapse state; collapsed until deliberately expanded. */ |
| 144 | expanded: boolean | undefined; |
| 145 | /** FileStart seen — the file's fiber is executing. */ |
| 146 | started: boolean; |
| 147 | /** Currently-running file hook ("deploying…" feedback), if any. */ |
| 148 | hook: "beforeAll" | "afterAll" | undefined; |
| 149 | hookStartedAt: number; |
| 150 | } |
| 151 | |
| 152 | type Node = |
| 153 | | { |
| 154 | readonly kind: "file"; |
| 155 | readonly node: FileNode; |
| 156 | /** Aggregate status of the tests that survived the active filters. */ |
| 157 | readonly status: Status; |
| 158 | } |
| 159 | | { readonly kind: "test"; readonly entry: Entry }; |
| 160 | |
| 161 | const makeCounts = (): Record<Status, number> => ({ |
| 162 | queued: 0, |
| 163 | running: 0, |
| 164 | pass: 0, |
| 165 | fail: 0, |
| 166 | skip: 0, |
| 167 | todo: 0, |
| 168 | }); |
| 169 | |
| 170 | const entryLabel = (entry: Entry): string => |
| 171 | `${entry.meta.titlePath.join(" > ")}` + |
| 172 | (entry.result !== undefined && |
| 173 | entry.status !== "queued" && |
| 174 | entry.status !== "running" |
| 175 | ? ` (${formatDuration(entry.result.durationMs)})` |
| 176 | : ""); |
| 177 | |
| 178 | const fileSummary = (node: FileNode): string => { |
| 179 | const c = node.counts; |
| 180 | const parts = [ |
| 181 | ...(c.fail > 0 ? [`${c.fail} failed`] : []), |
| 182 | ...(c.running > 0 ? [`${c.running} running`] : []), |
| 183 | `${c.pass}/${node.tests.length - c.skip - c.todo} passed`, |
| 184 | ...(c.skip > 0 ? [`${c.skip} skipped`] : []), |
| 185 | ...(c.todo > 0 ? [`${c.todo} todo`] : []), |
| 186 | ]; |
| 187 | return parts.join(" · "); |
| 188 | }; |
| 189 | |
| 190 | const visibleFileStatus = ( |
| 191 | node: FileNode, |
| 192 | tests: ReadonlyArray<Entry>, |
| 193 | ): Status => { |
| 194 | if (tests.some((test) => test.status === "fail")) return "fail"; |
| 195 | if ( |
| 196 | node.hook !== undefined && |
| 197 | tests.some((test) => test.status === "running" || test.status === "queued") |
| 198 | ) { |
| 199 | return "running"; |
| 200 | } |
| 201 | if (tests.some((test) => test.status === "running")) return "running"; |
| 202 | if (tests.some((test) => test.status === "queued")) return "queued"; |
| 203 | if (tests.some((test) => test.status === "pass")) return "pass"; |
| 204 | if (tests.some((test) => test.status === "todo")) return "todo"; |
| 205 | return "skip"; |
| 206 | }; |
| 207 | |
| 208 | /** Why a file's tests are still gray — surfaced on the file row. */ |
| 209 | const filePhase = (node: FileNode): string | undefined => { |
| 210 | if (node.hook !== undefined) { |
| 211 | return `${node.hook} running (${formatDuration(Date.now() - node.hookStartedAt)})…`; |
| 212 | } |
| 213 | if (!node.started && node.counts.queued > 0) { |
| 214 | return "waiting for a worker slot…"; |
| 215 | } |
| 216 | return undefined; |
| 217 | }; |
| 218 | |
| 219 | class TuiState { |
| 220 | readonly byId = new Map<string, Entry>(); |
| 221 | readonly files = new Map<string, FileNode>(); |
| 222 | readonly fileOrder: Array<FileNode> = []; |
| 223 | readonly fileLogs = new Map<string, ReadonlyArray<LogEntry>>(); |
| 224 | /** Independent status groups; only failed and pending show by default. */ |
| 225 | readonly show: Record<StatusGroup, boolean> = { |
| 226 | pass: false, |
| 227 | fail: true, |
| 228 | pending: true, |
| 229 | skipped: false, |
| 230 | }; |
| 231 | /** Active filter query ("" = none). Applied live on every keystroke. */ |
| 232 | filter = ""; |
| 233 | /** True while the footer is capturing filter keystrokes. */ |
| 234 | filterInput = false; |
| 235 | detailOpen = false; |
| 236 | selectedIndex = 0; |
| 237 | topIndex = 0; |
| 238 | /** |
| 239 | * When true the viewport tracks the selection (keyboard navigation). |
| 240 | * Mouse-wheel scrolling detaches it; the next keyboard move re-attaches. |
| 241 | */ |
| 242 | viewportFollows = true; |
| 243 | summary: RunSummary | undefined; |
| 244 | controller: RunController | undefined; |
| 245 | startedAt = Date.now(); |
| 246 | collectTotal = 0; |
| 247 | collectDone = 0; |
| 248 | dirty = true; |
| 249 | readonly counts = makeCounts(); |
| 250 | |
| 251 | fileNode(file: string): FileNode { |
| 252 | let node = this.files.get(file); |
| 253 | if (node === undefined) { |
| 254 | node = { |
| 255 | file, |
| 256 | tests: [], |
| 257 | counts: makeCounts(), |
| 258 | expanded: undefined, |
| 259 | started: false, |
| 260 | hook: undefined, |
| 261 | hookStartedAt: 0, |
| 262 | }; |
| 263 | this.files.set(file, node); |
| 264 | this.fileOrder.push(node); |
| 265 | } |
| 266 | return node; |
| 267 | } |
| 268 | |
| 269 | upsert(meta: TestMeta, status: Status, result?: TestResult): void { |
| 270 | let entry = this.byId.get(meta.id); |
| 271 | if (entry === undefined) { |
| 272 | const file = this.fileNode(meta.file); |
| 273 | entry = { meta, file, status, label: "" }; |
| 274 | this.byId.set(meta.id, entry); |
| 275 | file.tests.push(entry); |
| 276 | } else { |
| 277 | this.counts[entry.status]--; |
| 278 | entry.file.counts[entry.status]--; |
| 279 | } |
| 280 | this.counts[status]++; |
| 281 | entry.file.counts[status]++; |
| 282 | entry.status = status; |
| 283 | if (status === "running") { |
| 284 | // (Re)started — retries included: drop the stale result so the row |
| 285 | // shows a fresh live timer instead of the previous run's duration. |
| 286 | entry.result = undefined; |
| 287 | entry.startedAt = Date.now(); |
| 288 | } |
| 289 | if (result !== undefined) entry.result = result; |
| 290 | entry.label = entryLabel(entry); |
| 291 | this.dirty = true; |
| 292 | } |
| 293 | |
| 294 | allGroupsShown(): boolean { |
| 295 | return ( |
| 296 | this.show.pass && this.show.fail && this.show.pending && this.show.skipped |
| 297 | ); |
| 298 | } |
| 299 | |
| 300 | /** |
| 301 | * Toggle a status group on/off, independently of the others (pressing `p` |
| 302 | * hides passing tests and NOTHING else). Hiding every group is allowed — |
| 303 | * the list is simply empty and the footer's struck-through toggle bar |
| 304 | * shows why. |
| 305 | */ |
| 306 | toggleGroup(group: StatusGroup): void { |
| 307 | this.show[group] = !this.show[group]; |
| 308 | this.dirty = true; |
| 309 | } |
| 310 | |
| 311 | private matches(entry: Entry): boolean { |
| 312 | if (!this.show[groupOf(entry.status)]) return false; |
| 313 | if (this.filter === "") return true; |
| 314 | const haystack = |
| 315 | `${entry.meta.file} ${entry.meta.titlePath.join(" ")}`.toLowerCase(); |
| 316 | // Every whitespace-separated word must match somewhere (AND semantics). |
| 317 | return this.filter |
| 318 | .toLowerCase() |
| 319 | .split(/\s+/) |
| 320 | .filter((w) => w !== "") |
| 321 | .every((word) => haystack.includes(word)); |
| 322 | } |
| 323 | |
| 324 | isExpanded(node: FileNode): boolean { |
| 325 | // Expansion is ALWAYS a deliberate choice (enter / l) — failures don't |
| 326 | // auto-expand their file (the red glyph + count already flag it), and |
| 327 | // filtering narrows WHICH files are listed without expanding them. |
| 328 | return node.expanded === true; |
| 329 | } |
| 330 | |
| 331 | /** Flattened visible tree: file rows with expanded tests nested inside. */ |
| 332 | visible(): Array<Node> { |
| 333 | const out: Array<Node> = []; |
| 334 | for (const node of this.fileOrder) { |
| 335 | const tests = node.tests.filter((t) => this.matches(t)); |
| 336 | if (tests.length === 0) continue; |
| 337 | const expanded = this.isExpanded(node); |
| 338 | out.push({ kind: "file", node, status: visibleFileStatus(node, tests) }); |
| 339 | if (expanded) { |
| 340 | for (const entry of tests) out.push({ kind: "test", entry }); |
| 341 | } |
| 342 | } |
| 343 | return out; |
| 344 | } |
| 345 | |
| 346 | visibleTestCount(): number { |
| 347 | let count = 0; |
| 348 | for (const node of this.visible()) { |
| 349 | if (node.kind === "test") count++; |
| 350 | } |
| 351 | return count; |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | // --------------------------------------------------------------------------- |
| 356 | // Clipboard |
| 357 | // --------------------------------------------------------------------------- |
| 358 | |
| 359 | /** OSC52 first (works over ssh in modern terminals), then a system tool. */ |
| 360 | const copyToClipboard = (renderer: CliRenderer, text: string): boolean => { |
| 361 | let copied = false; |
| 362 | try { |
| 363 | copied = renderer.copyToClipboardOSC52(text); |
| 364 | } catch { |
| 365 | copied = false; |
| 366 | } |
| 367 | try { |
| 368 | const cmd = |
| 369 | process.platform === "darwin" |
| 370 | ? ["pbcopy"] |
| 371 | : process.platform === "win32" |
| 372 | ? ["clip"] |
| 373 | : ["xclip", "-selection", "clipboard"]; |
| 374 | const proc = Bun.spawn(cmd, { |
| 375 | stdin: "pipe", |
| 376 | stdout: "ignore", |
| 377 | stderr: "ignore", |
| 378 | }); |
| 379 | proc.stdin.write(text); |
| 380 | proc.stdin.end(); |
| 381 | copied = true; |
| 382 | } catch { |
| 383 | // no system clipboard tool — OSC52 result stands |
| 384 | } |
| 385 | return copied; |
| 386 | }; |
| 387 | |
| 388 | const detailContent = (state: TuiState, entry: Entry): string => { |
| 389 | const { meta, result } = entry; |
| 390 | const running = entry.status === "running"; |
| 391 | const lines: Array<string> = [ |
| 392 | `${meta.file} > ${meta.titlePath.join(" > ")}`, |
| 393 | `status: ${entry.status}${ |
| 394 | running && entry.startedAt !== undefined |
| 395 | ? ` elapsed: ${formatDuration(Date.now() - entry.startedAt)}` |
| 396 | : "" |
| 397 | }${ |
| 398 | !running && result !== undefined |
| 399 | ? ` duration: ${formatDuration(result.durationMs)} retries: ${result.retries}` |
| 400 | : "" |
| 401 | }`, |
| 402 | "", |
| 403 | ]; |
| 404 | if (!running && result?.error !== undefined) { |
| 405 | lines.push("── error ──", result.error, ""); |
| 406 | } |
| 407 | // Finished tests show their result logs; running tests tail the LIVE |
| 408 | // buffer the runner appends to. |
| 409 | const logs = running ? entry.liveLogs : result?.logs; |
| 410 | if (logs !== undefined && logs.length > 0) { |
| 411 | lines.push("── captured output ──", formatLogs(logs), ""); |
| 412 | } |
| 413 | const hookLogs = state.fileLogs.get(meta.file); |
| 414 | if (hookLogs !== undefined && hookLogs.length > 0) { |
| 415 | lines.push("── file hooks (deploy/destroy) ──", formatLogs(hookLogs)); |
| 416 | } |
| 417 | return lines.join("\n"); |
| 418 | }; |
| 419 | |
| 420 | // --------------------------------------------------------------------------- |
| 421 | // TUI |
| 422 | // --------------------------------------------------------------------------- |
| 423 | |
| 424 | interface Tui { |
| 425 | readonly renderer: CliRenderer; |
| 426 | readonly state: TuiState; |
| 427 | readonly refresh: () => void; |
| 428 | readonly quit: Promise<void>; |
| 429 | readonly dispose: () => void; |
| 430 | } |
| 431 | |
| 432 | const FOOTER_HINTS = |
| 433 | "j/k · enter open · / filter · h/l fold · e/c all · r retry · R retry failed · x kill · y copy · q quit"; |
| 434 | |
| 435 | const makeTui = async (logFile: string): Promise<Tui> => { |
| 436 | const state = new TuiState(); |
| 437 | /** True once the renderer is torn down — no further writes are allowed. */ |
| 438 | let disposed = false; |
| 439 | |
| 440 | // Ctrl+C is handled by US (exitOnCtrlC: false): opentui's built-in handler |
| 441 | // destroys the renderer while our flush timer may still be queued, which |
| 442 | // crashed with "TextBuffer is destroyed". We dispose first, then exit. |
| 443 | const hadWindow = "window" in globalThis; |
| 444 | const renderer = await createCliRenderer({ |
| 445 | exitOnCtrlC: false, |
| 446 | // Console capture is owned by StrayOutput's global patch (diverted into |
| 447 | // the run log) — don't let opentui's overlay re-patch it afterwards. |
| 448 | consoleMode: "disabled", |
| 449 | }); |
| 450 | // opentui's CliRenderer constructor does `global.window = {}` to hang |
| 451 | // `requestAnimationFrame` on it and never reads it back. The tests share |
| 452 | // this process, and browser-detecting code (emscripten loaders such as |
| 453 | // pglite under @prisma/dev) treats `typeof window === "object"` as "in a |
| 454 | // browser" and then dereferences `window.location`. Restore the plain-bun |
| 455 | // globals a non-TUI run has. |
| 456 | if (!hadWindow) delete (globalThis as { window?: unknown }).window; |
| 457 | const selectionBackground = (): string => |
| 458 | renderer.themeMode === "light" ? "#d0d0d0" : "#303030"; |
| 459 | |
| 460 | // Divert stray JS-level stdout/stderr writes into the run log while the |
| 461 | // TUI owns the screen: anything that bypasses the per-test Effect Console |
| 462 | // (third-party bridges writing to the streams directly, e.g. miniflare |
| 463 | // forwarding workerd's console) would corrupt the alternate screen. |
| 464 | // Installed AFTER the renderer is created — opentui captures the real |
| 465 | // stream methods at construction and renders through its native library, |
| 466 | // so the diversion cannot affect the TUI itself. |
| 467 | const restoreStreams = captureStrayOutput(logFile); |
| 468 | |
| 469 | // No explicit colors on chrome — match the terminal's own theme. |
| 470 | const header = new TextRenderable(renderer, { |
| 471 | id: "header", |
| 472 | content: "alchemy-test — collecting…", |
| 473 | height: 1, |
| 474 | width: "100%", |
| 475 | }); |
| 476 | |
| 477 | const list = new BoxRenderable(renderer, { |
| 478 | id: "list", |
| 479 | width: "100%", |
| 480 | flexGrow: 1, |
| 481 | flexDirection: "column", |
| 482 | }); |
| 483 | |
| 484 | interface Row { |
| 485 | readonly text: TextRenderable; |
| 486 | /** Cache key of the last-applied content/style. */ |
| 487 | key: string; |
| 488 | } |
| 489 | let rows: Array<Row> = []; |
| 490 | |
| 491 | const rebuildRowPool = (): void => { |
| 492 | for (const row of rows) list.remove(row.text); |
| 493 | rows = []; |
| 494 | const count = Math.max(renderer.terminalHeight - 2, 1); |
| 495 | for (let i = 0; i < count; i++) { |
| 496 | const index = i; |
| 497 | const text = new TextRenderable(renderer, { |
| 498 | id: `list-row-${i}`, |
| 499 | width: "100%", |
| 500 | height: 1, |
| 501 | content: "", |
| 502 | // NOT selectable: rows are a recycled pool, and opentui anchors |
| 503 | // text-selection highlights on the renderable — a drag would leave |
| 504 | // highlight residue behind as row content repaints under it. Copy |
| 505 | // from the list with `y`; free-form text selection lives in the |
| 506 | // detail pane (enter), whose content is stable. |
| 507 | selectable: false, |
| 508 | // Wheel scrolls the viewable content without moving the selection; |
| 509 | // a click selects the row under the cursor. |
| 510 | onMouse: (event: MouseEvent) => { |
| 511 | if (event.type === "scroll" && event.scroll !== undefined) { |
| 512 | scrollViewport(event.scroll.direction === "up" ? -3 : 3); |
| 513 | } |
| 514 | }, |
| 515 | onMouseDown: () => selectRow(index), |
| 516 | }); |
| 517 | rows.push({ text, key: "" }); |
| 518 | list.add(text); |
| 519 | } |
| 520 | }; |
| 521 | |
| 522 | const detail = new ScrollBoxRenderable(renderer, { |
| 523 | id: "detail", |
| 524 | width: "100%", |
| 525 | flexGrow: 1, |
| 526 | visible: false, |
| 527 | // Tail semantics: pinned to the bottom while content grows (a running |
| 528 | // test's live output); scrolling up detaches, scrolling back re-pins. |
| 529 | stickyScroll: true, |
| 530 | stickyStart: "bottom", |
| 531 | }); |
| 532 | const detailText = new TextRenderable(renderer, { |
| 533 | id: "detail-text", |
| 534 | content: "", |
| 535 | selectable: true, |
| 536 | }); |
| 537 | detail.add(detailText); |
| 538 | /** Content of the open detail pane (for `y` copy). */ |
| 539 | let detailRaw = ""; |
| 540 | /** Entry shown in the detail pane, refreshed live while it's running. */ |
| 541 | let detailEntry: Entry | undefined; |
| 542 | |
| 543 | const footer = new TextRenderable(renderer, { |
| 544 | id: "footer", |
| 545 | content: FOOTER_HINTS, |
| 546 | height: 1, |
| 547 | width: "100%", |
| 548 | }); |
| 549 | |
| 550 | const root = new BoxRenderable(renderer, { |
| 551 | id: "root", |
| 552 | width: "100%", |
| 553 | height: "100%", |
| 554 | flexDirection: "column", |
| 555 | }); |
| 556 | root.add(header); |
| 557 | root.add(list); |
| 558 | root.add(detail); |
| 559 | root.add(footer); |
| 560 | renderer.root.add(root); |
| 561 | rebuildRowPool(); |
| 562 | renderer.on(CliRenderEvents.RESIZE, () => { |
| 563 | if (disposed) return; |
| 564 | rebuildRowPool(); |
| 565 | renderList(); |
| 566 | }); |
| 567 | |
| 568 | /** |
| 569 | * Drop any active text selection. The list is a recycled pool of row |
| 570 | * renderables, so a lingering selection highlight would stick to screen |
| 571 | * positions while the content underneath changes (ghost highlights after |
| 572 | * a click, glyphs staying inverted through repaints). |
| 573 | */ |
| 574 | const clearTextSelection = (): void => { |
| 575 | if (!disposed && renderer.hasSelection) renderer.clearSelection(); |
| 576 | }; |
| 577 | |
| 578 | // Drag-selected text is copied automatically on release (the terminal's |
| 579 | // own CMD+C can't see the TUI's selection). |
| 580 | renderer.on( |
| 581 | CliRenderEvents.SELECTION, |
| 582 | (selection: Selection | null | undefined) => { |
| 583 | if (disposed || selection == null) return; |
| 584 | if (selection.isDragging) return; |
| 585 | const text = selection.getSelectedText(); |
| 586 | // A plain click anchors a zero-width selection — nothing to copy. |
| 587 | if (text.trim().length > 1 && copyToClipboard(renderer, text)) { |
| 588 | flash("selection copied ✓"); |
| 589 | } |
| 590 | // Clear either way: the copy already happened, and a selection left |
| 591 | // behind turns into ghost highlighting as the row pool repaints under |
| 592 | // it. Deferred — this event is emitted from inside finishSelection, |
| 593 | // which still touches the selection after the emit. |
| 594 | setTimeout(clearTextSelection, 0); |
| 595 | }, |
| 596 | ); |
| 597 | |
| 598 | /** |
| 599 | * Full-width run of spaces appended to single-line chrome (header/footer): |
| 600 | * with transparent backgrounds, a shorter repaint would otherwise leave |
| 601 | * the previous content's tail cells on screen. Overflow is clipped. |
| 602 | */ |
| 603 | const padLine = (): string => " ".repeat(renderer.terminalWidth); |
| 604 | |
| 605 | /** Transient footer message (e.g. "copied"); reverts on the next tick. */ |
| 606 | let flashUntil = 0; |
| 607 | const flash = (message: string): void => { |
| 608 | flashUntil = Date.now() + 1500; |
| 609 | footer.content = stringToStyledText(` ${message}${padLine()}`); |
| 610 | }; |
| 611 | |
| 612 | const updateFooter = (): void => { |
| 613 | if (Date.now() < flashUntil) return; |
| 614 | if (state.filterInput) { |
| 615 | footer.content = stringToStyledText( |
| 616 | ` /${state.filter}█ ${state.visibleTestCount()} matches (enter close · esc clear)${padLine()}`, |
| 617 | ); |
| 618 | return; |
| 619 | } |
| 620 | // Always-visible toggle bar: each group shows its hotkey and current |
| 621 | // state — bright when shown, dim + struck-through when hidden. |
| 622 | const toggles = ( |
| 623 | [ |
| 624 | ["pass", "p:pass"], |
| 625 | ["fail", "f:fail"], |
| 626 | ["pending", "n:pending"], |
| 627 | ["skipped", "s:skipped"], |
| 628 | ] as Array<[StatusGroup, string]> |
| 629 | ).flatMap(([group, label], index) => [ |
| 630 | ...(index > 0 ? [dim(" ")] : []), |
| 631 | state.show[group] |
| 632 | ? stringToStyledText(label).chunks[0]! |
| 633 | : strikethrough(dim(label)), |
| 634 | ]); |
| 635 | // Toggle states lead (before the hints) so they're never clipped on |
| 636 | // narrow terminals — the footer truncates at the terminal width. |
| 637 | const filter = state.filter !== "" ? ` │ filter: ${state.filter}` : ""; |
| 638 | footer.content = new StyledText([ |
| 639 | dim(" show: "), |
| 640 | ...toggles, |
| 641 | dim(`${filter} │ ${FOOTER_HINTS}${padLine()}`), |
| 642 | ]); |
| 643 | }; |
| 644 | |
| 645 | const updateHeader = (): void => { |
| 646 | const counts = state.counts; |
| 647 | const elapsed = formatDuration( |
| 648 | state.summary?.durationMs ?? Date.now() - state.startedAt, |
| 649 | ); |
| 650 | const done = state.summary !== undefined; |
| 651 | const collecting = |
| 652 | !done && state.collectDone < state.collectTotal |
| 653 | ? ` │ importing ${state.collectDone}/${state.collectTotal} files` |
| 654 | : ""; |
| 655 | header.content = new StyledText([ |
| 656 | dim(" alchemy-test "), |
| 657 | green(`${GLYPH.pass} ${counts.pass}`), |
| 658 | dim(" "), |
| 659 | red(`${GLYPH.fail} ${counts.fail}`), |
| 660 | dim(" "), |
| 661 | yellow(`${GLYPH.running} ${counts.running}`), |
| 662 | dim(` ${GLYPH.queued} ${counts.queued}`), |
| 663 | ...(counts.skip > 0 ? [dim(` ${GLYPH.skip} ${counts.skip}`)] : []), |
| 664 | dim(` │ ${elapsed}${collecting}`), |
| 665 | ...(state.summary?.plan |
| 666 | ? [ |
| 667 | dim( |
| 668 | ` │ ${state.summary.plan.found} found, ${state.summary.plan.excluded} excluded by plan`, |
| 669 | ), |
| 670 | ] |
| 671 | : []), |
| 672 | ...(done ? [dim(" │ DONE — press q to quit")] : []), |
| 673 | dim(padLine()), |
| 674 | ]); |
| 675 | }; |
| 676 | |
| 677 | /** |
| 678 | * Paint the visible window into the row pool. O(window): a row's content |
| 679 | * is only written when its cache key changed since the last paint. |
| 680 | */ |
| 681 | const renderList = (): void => { |
| 682 | if (state.detailOpen || disposed) return; |
| 683 | const nodes = state.visible(); |
| 684 | const max = Math.max(nodes.length - 1, 0); |
| 685 | state.selectedIndex = Math.min(state.selectedIndex, max); |
| 686 | if (state.viewportFollows) { |
| 687 | if (state.selectedIndex < state.topIndex) { |
| 688 | state.topIndex = state.selectedIndex; |
| 689 | } else if (state.selectedIndex >= state.topIndex + rows.length) { |
| 690 | state.topIndex = state.selectedIndex - rows.length + 1; |
| 691 | } |
| 692 | } |
| 693 | state.topIndex = Math.max( |
| 694 | 0, |
| 695 | Math.min(state.topIndex, Math.max(nodes.length - rows.length, 0)), |
| 696 | ); |
| 697 | |
| 698 | const width = renderer.terminalWidth; |
| 699 | for (let i = 0; i < rows.length; i++) { |
| 700 | const row = rows[i]!; |
| 701 | const node = nodes[state.topIndex + i]; |
| 702 | const isSelected = |
| 703 | node !== undefined && state.topIndex + i === state.selectedIndex; |
| 704 | |
| 705 | if (node === undefined) { |
| 706 | // Empty content emits no cells, so it does not erase whatever this |
| 707 | // recycled screen row painted previously. Repaint the entire width. |
| 708 | const emptyKey = `empty|${width}`; |
| 709 | if (row.key !== emptyKey) { |
| 710 | row.key = emptyKey; |
| 711 | row.text.content = " ".repeat(width); |
| 712 | } |
| 713 | continue; |
| 714 | } |
| 715 | |
| 716 | let head: string; |
| 717 | let status: Status; |
| 718 | let rest: string; |
| 719 | if (node.kind === "file") { |
| 720 | const expanded = state.isExpanded(node.node); |
| 721 | status = node.status; |
| 722 | head = ` ${expanded ? "▾" : "▸"} ${GLYPH[status]} `; |
| 723 | const phase = filePhase(node.node); |
| 724 | rest = |
| 725 | `${node.node.file} ${fileSummary(node.node)}` + |
| 726 | (phase !== undefined ? ` — ${phase}` : ""); |
| 727 | } else { |
| 728 | status = node.entry.status; |
| 729 | head = ` ${GLYPH[status]} `; |
| 730 | rest = node.entry.label; |
| 731 | // Live elapsed timer while running (resets on retry). |
| 732 | if (status === "running" && node.entry.startedAt !== undefined) { |
| 733 | rest += ` (${formatDuration(Date.now() - node.entry.startedAt)}…)`; |
| 734 | } |
| 735 | } |
| 736 | // Clip to the terminal width AND pad to it — with transparent |
| 737 | // backgrounds a shorter repaint would otherwise leave the previous |
| 738 | // content's tail cells on screen. |
| 739 | const restWidth = Math.max(width - head.length, 0); |
| 740 | rest = rest.slice(0, restWidth).padEnd(restWidth, " "); |
| 741 | |
| 742 | const key = `${isSelected ? "S" : "."}|${status}|${head}|${rest}`; |
| 743 | if (row.key === key) continue; |
| 744 | row.key = key; |
| 745 | const chunks = [statusChunk(status, head)]; |
| 746 | if (rest !== "") { |
| 747 | // Only the glyph is colored; the label uses the terminal's default |
| 748 | // foreground (skipped rows are dimmed, todos are warning-yellow). |
| 749 | if (status === "skip") { |
| 750 | chunks.push(dim(rest)); |
| 751 | } else if (status === "todo") { |
| 752 | chunks.push(yellow(rest)); |
| 753 | } else { |
| 754 | chunks.push(...stringToStyledText(rest).chunks); |
| 755 | } |
| 756 | } |
| 757 | // Use an explicit background instead of terminal inverse video. |
| 758 | // OpenTUI's cell diff can leave inverse attributes behind when the |
| 759 | // selection moves upward; a concrete background has symmetric paint |
| 760 | // and clear operations in both navigation directions. |
| 761 | row.text.content = new StyledText( |
| 762 | isSelected ? chunks.map(bg(selectionBackground())) : chunks, |
| 763 | ); |
| 764 | } |
| 765 | }; |
| 766 | |
| 767 | const moveSelection = (delta: number): void => { |
| 768 | const nodes = state.visible(); |
| 769 | if (nodes.length === 0) return; |
| 770 | state.viewportFollows = true; |
| 771 | state.selectedIndex = Math.max( |
| 772 | 0, |
| 773 | Math.min(state.selectedIndex + delta, nodes.length - 1), |
| 774 | ); |
| 775 | renderList(); |
| 776 | }; |
| 777 | |
| 778 | /** Scroll the viewable content without moving the selection (wheel). */ |
| 779 | const scrollViewport = (delta: number): void => { |
| 780 | const nodes = state.visible(); |
| 781 | if (nodes.length === 0) return; |
| 782 | state.viewportFollows = false; |
| 783 | state.topIndex = Math.max( |
| 784 | 0, |
| 785 | Math.min(state.topIndex + delta, Math.max(nodes.length - rows.length, 0)), |
| 786 | ); |
| 787 | renderList(); |
| 788 | }; |
| 789 | |
| 790 | /** Click on a visible row selects it (without scrolling). */ |
| 791 | const selectRow = (rowIndex: number): void => { |
| 792 | const nodes = state.visible(); |
| 793 | const index = state.topIndex + rowIndex; |
| 794 | if (index >= nodes.length) return; |
| 795 | state.selectedIndex = index; |
| 796 | renderList(); |
| 797 | }; |
| 798 | |
| 799 | const refresh = (): void => { |
| 800 | if (disposed) return; |
| 801 | updateHeader(); |
| 802 | updateFooter(); |
| 803 | // Keep `dirty` set while the detail pane hides the list, so the pending |
| 804 | // repaint happens when the list becomes visible again. |
| 805 | if (state.dirty && !state.detailOpen) { |
| 806 | state.dirty = false; |
| 807 | renderList(); |
| 808 | } |
| 809 | }; |
| 810 | |
| 811 | const selectedNode = (): Node | undefined => |
| 812 | state.visible()[state.selectedIndex]; |
| 813 | |
| 814 | const setExpanded = (node: FileNode, expanded: boolean): void => { |
| 815 | node.expanded = expanded; |
| 816 | state.dirty = true; |
| 817 | renderList(); |
| 818 | }; |
| 819 | |
| 820 | const setAllVisibleExpanded = (expanded: boolean): void => { |
| 821 | const selected = selectedNode(); |
| 822 | const visibleFiles = new Set( |
| 823 | state |
| 824 | .visible() |
| 825 | .map((node) => (node.kind === "file" ? node.node : node.entry.file)), |
| 826 | ); |
| 827 | for (const file of visibleFiles) file.expanded = expanded; |
| 828 | |
| 829 | const nodes = state.visible(); |
| 830 | const selectedFile = |
| 831 | selected?.kind === "file" ? selected.node : selected?.entry.file; |
| 832 | const selectedIndex = nodes.findIndex((node) => |
| 833 | expanded && selected?.kind === "test" |
| 834 | ? node.kind === "test" && node.entry === selected.entry |
| 835 | : node.kind === "file" && node.node === selectedFile, |
| 836 | ); |
| 837 | state.selectedIndex = Math.max(selectedIndex, 0); |
| 838 | state.dirty = true; |
| 839 | renderList(); |
| 840 | }; |
| 841 | |
| 842 | /** Re-render the open detail pane (no-op when its content is unchanged). */ |
| 843 | const refreshDetail = (): void => { |
| 844 | if (detailEntry === undefined) return; |
| 845 | const raw = detailContent(state, detailEntry); |
| 846 | if (raw === detailRaw) return; |
| 847 | detailRaw = raw; |
| 848 | detailText.content = stringToStyledText(raw); |
| 849 | }; |
| 850 | |
| 851 | const openDetail = (entry: Entry): void => { |
| 852 | detailEntry = entry; |
| 853 | detailRaw = detailContent(state, entry); |
| 854 | detailText.content = stringToStyledText(detailRaw); |
| 855 | state.detailOpen = true; |
| 856 | list.visible = false; |
| 857 | detail.visible = true; |
| 858 | detail.focus(); |
| 859 | // Running test → tail the output (sticky keeps it pinned as it grows); |
| 860 | // finished test → start at the top where the error is. |
| 861 | detail.scrollTo(entry.status === "running" ? 1_000_000_000 : 0); |
| 862 | }; |
| 863 | |
| 864 | const closeDetail = (): void => { |
| 865 | state.detailOpen = false; |
| 866 | detailEntry = undefined; |
| 867 | detail.visible = false; |
| 868 | list.visible = true; |
| 869 | renderList(); |
| 870 | refresh(); |
| 871 | }; |
| 872 | |
| 873 | const copySelection = (): void => { |
| 874 | let text: string | undefined; |
| 875 | if (state.detailOpen) { |
| 876 | text = detailRaw; |
| 877 | } else { |
| 878 | const node = selectedNode(); |
| 879 | if (node?.kind === "test") { |
| 880 | text = detailContent(state, node.entry); |
| 881 | } else if (node?.kind === "file") { |
| 882 | // Copy every failure of the file plus its hook logs. |
| 883 | text = node.node.tests |
| 884 | .filter((t) => t.status === "fail") |
| 885 | .map((t) => detailContent(state, t)) |
| 886 | .join("\n\n"); |
| 887 | if (text === "" && node.node.tests.length > 0) { |
| 888 | text = detailContent(state, node.node.tests[0]!); |
| 889 | } |
| 890 | } |
| 891 | } |
| 892 | if (text === undefined || text === "") return; |
| 893 | flash( |
| 894 | copyToClipboard(renderer, text) ? "copied to clipboard ✓" : "copy failed", |
| 895 | ); |
| 896 | }; |
| 897 | |
| 898 | const retrySelection = (): void => { |
| 899 | const controller = state.controller; |
| 900 | if (controller === undefined) return; |
| 901 | const node = state.detailOpen ? undefined : selectedNode(); |
| 902 | if (node === undefined) return; |
| 903 | if (node.kind === "test") { |
| 904 | if (node.entry.status === "running" || node.entry.status === "queued") { |
| 905 | flash("still running — x to kill"); |
| 906 | return; |
| 907 | } |
| 908 | controller.retryTest(node.entry.meta.id); |
| 909 | flash(`retrying ${node.entry.meta.name}`); |
| 910 | } else { |
| 911 | controller.retryFile(node.node.file); |
| 912 | flash(`retrying ${node.node.file}`); |
| 913 | } |
| 914 | }; |
| 915 | |
| 916 | const retryFailures = (): void => { |
| 917 | const controller = state.controller; |
| 918 | if (controller === undefined) return; |
| 919 | const failed = [...state.byId.values()].filter( |
| 920 | (entry) => entry.status === "fail", |
| 921 | ); |
| 922 | for (const entry of failed) controller.retryTest(entry.meta.id); |
| 923 | flash( |
| 924 | failed.length === 0 |
| 925 | ? "no failed tests to retry" |
| 926 | : `retrying ${failed.length} failed test${failed.length === 1 ? "" : "s"}`, |
| 927 | ); |
| 928 | }; |
| 929 | |
| 930 | const killSelection = (): void => { |
| 931 | const controller = state.controller; |
| 932 | if (controller === undefined) return; |
| 933 | const node = state.detailOpen ? undefined : selectedNode(); |
| 934 | if (node === undefined) return; |
| 935 | if (node.kind === "test") { |
| 936 | if (node.entry.status !== "running") { |
| 937 | flash("not running — nothing to kill"); |
| 938 | return; |
| 939 | } |
| 940 | controller.killTest(node.entry.meta.id); |
| 941 | flash(`killing ${node.entry.meta.name}`); |
| 942 | } else { |
| 943 | for (const entry of node.node.tests) { |
| 944 | if (entry.status === "running") controller.killTest(entry.meta.id); |
| 945 | } |
| 946 | flash(`killing running tests in ${node.node.file}`); |
| 947 | } |
| 948 | }; |
| 949 | |
| 950 | let resolveQuit!: () => void; |
| 951 | const quit = new Promise<void>((resolve) => { |
| 952 | resolveQuit = resolve; |
| 953 | }); |
| 954 | |
| 955 | const onFilterKey = (key: KeyEvent): void => { |
| 956 | switch (key.name) { |
| 957 | case "escape": |
| 958 | state.filter = ""; |
| 959 | state.filterInput = false; |
| 960 | break; |
| 961 | case "return": |
| 962 | case "enter": |
| 963 | state.filterInput = false; |
| 964 | break; |
| 965 | case "backspace": |
| 966 | state.filter = state.filter.slice(0, -1); |
| 967 | break; |
| 968 | default: { |
| 969 | // Printable, single-character inputs extend the query; the list |
| 970 | // re-filters on every keystroke. |
| 971 | const seq = key.sequence ?? ""; |
| 972 | if (seq.length === 1 && !key.ctrl && !key.meta && seq >= " ") { |
| 973 | state.filter += seq; |
| 974 | } else { |
| 975 | return; |
| 976 | } |
| 977 | } |
| 978 | } |
| 979 | state.selectedIndex = 0; |
| 980 | state.topIndex = 0; |
| 981 | state.dirty = true; |
| 982 | refresh(); |
| 983 | }; |
| 984 | |
| 985 | // Vim `gg` prefix: true after a bare `g`, cleared by any other key. |
| 986 | let pendingG = false; |
| 987 | |
| 988 | renderer.keyInput.on("keypress", (key: KeyEvent) => { |
| 989 | // Ctrl+C: tear the TUI down BEFORE exiting so no queued timer callback |
| 990 | // touches a destroyed renderer ("TextBuffer is destroyed"). |
| 991 | if (key.ctrl && key.name === "c") { |
| 992 | dispose(); |
| 993 | process.exit(130); |
| 994 | } |
| 995 | if (disposed) return; |
| 996 | // Keyboard input takes over from the mouse: drop any live text |
| 997 | // selection so its highlight can't linger over rows that repaint. |
| 998 | clearTextSelection(); |
| 999 | if (state.filterInput && !state.detailOpen) { |
| 1000 | onFilterKey(key); |
| 1001 | return; |
| 1002 | } |
| 1003 | switch (key.name) { |
| 1004 | case "q": |
| 1005 | resolveQuit(); |
| 1006 | return; |
| 1007 | case "escape": |
| 1008 | if (state.detailOpen) { |
| 1009 | closeDetail(); |
| 1010 | } else if (state.filter !== "") { |
| 1011 | state.filter = ""; |
| 1012 | state.dirty = true; |
| 1013 | refresh(); |
| 1014 | } |
| 1015 | return; |
| 1016 | case "y": |
| 1017 | copySelection(); |
| 1018 | return; |
| 1019 | case "r": |
| 1020 | if (key.shift) { |
| 1021 | retryFailures(); |
| 1022 | } else { |
| 1023 | retrySelection(); |
| 1024 | } |
| 1025 | return; |
| 1026 | case "x": |
| 1027 | killSelection(); |
| 1028 | return; |
| 1029 | } |
| 1030 | if (state.detailOpen) return; // ScrollBox handles its own keys |
| 1031 | |
| 1032 | // gg — jump to top (vim) |
| 1033 | if (key.name === "g" && !key.shift && !key.ctrl) { |
| 1034 | if (pendingG) { |
| 1035 | pendingG = false; |
| 1036 | moveSelection(-Number.MAX_SAFE_INTEGER); |
| 1037 | } else { |
| 1038 | pendingG = true; |
| 1039 | } |
| 1040 | return; |
| 1041 | } |
| 1042 | pendingG = false; |
| 1043 | |
| 1044 | // vim paging: ctrl-d/u half page, ctrl-f/b full page |
| 1045 | if (key.ctrl) { |
| 1046 | switch (key.name) { |
| 1047 | case "d": |
| 1048 | moveSelection(Math.max(Math.floor(rows.length / 2), 1)); |
| 1049 | return; |
| 1050 | case "u": |
| 1051 | moveSelection(-Math.max(Math.floor(rows.length / 2), 1)); |
| 1052 | return; |
| 1053 | case "f": |
| 1054 | moveSelection(rows.length); |
| 1055 | return; |
| 1056 | case "b": |
| 1057 | moveSelection(-rows.length); |
| 1058 | return; |
| 1059 | } |
| 1060 | return; |
| 1061 | } |
| 1062 | // G — jump to bottom (vim) |
| 1063 | if (key.name === "g" && key.shift) { |
| 1064 | moveSelection(Number.MAX_SAFE_INTEGER); |
| 1065 | return; |
| 1066 | } |
| 1067 | switch (key.name) { |
| 1068 | case "/": |
| 1069 | state.filterInput = true; |
| 1070 | refresh(); |
| 1071 | return; |
| 1072 | case "space": |
| 1073 | case " ": |
| 1074 | // space pages forward (less/vim style) |
| 1075 | moveSelection(rows.length); |
| 1076 | return; |
| 1077 | case "return": |
| 1078 | case "enter": { |
| 1079 | const node = selectedNode(); |
| 1080 | if (node === undefined) return; |
| 1081 | if (node.kind === "file") { |
| 1082 | setExpanded(node.node, !state.isExpanded(node.node)); |
| 1083 | } else { |
| 1084 | openDetail(node.entry); |
| 1085 | } |
| 1086 | return; |
| 1087 | } |
| 1088 | case "left": |
| 1089 | case "h": { |
| 1090 | const node = selectedNode(); |
| 1091 | if (node === undefined) return; |
| 1092 | // On a test row, collapse the parent file and land on it. |
| 1093 | const target = node.kind === "file" ? node.node : node.entry.file; |
| 1094 | setExpanded(target, false); |
| 1095 | const index = state |
| 1096 | .visible() |
| 1097 | .findIndex((n) => n.kind === "file" && n.node === target); |
| 1098 | if (index >= 0) { |
| 1099 | state.selectedIndex = index; |
| 1100 | renderList(); |
| 1101 | } |
| 1102 | return; |
| 1103 | } |
| 1104 | case "right": |
| 1105 | case "l": { |
| 1106 | const node = selectedNode(); |
| 1107 | if (node?.kind === "file") setExpanded(node.node, true); |
| 1108 | return; |
| 1109 | } |
| 1110 | case "e": |
| 1111 | setAllVisibleExpanded(true); |
| 1112 | return; |
| 1113 | case "c": |
| 1114 | setAllVisibleExpanded(false); |
| 1115 | return; |
| 1116 | case "up": |
| 1117 | case "k": |
| 1118 | moveSelection(-1); |
| 1119 | return; |
| 1120 | case "down": |
| 1121 | case "j": |
| 1122 | moveSelection(1); |
| 1123 | return; |
| 1124 | case "pageup": |
| 1125 | moveSelection(-rows.length); |
| 1126 | return; |
| 1127 | case "pagedown": |
| 1128 | moveSelection(rows.length); |
| 1129 | return; |
| 1130 | case "home": |
| 1131 | moveSelection(-Number.MAX_SAFE_INTEGER); |
| 1132 | return; |
| 1133 | case "end": |
| 1134 | moveSelection(Number.MAX_SAFE_INTEGER); |
| 1135 | return; |
| 1136 | // Independent status-group visibility toggles: each key flips ONLY its |
| 1137 | // own group. Hiding all four leaves the list empty (no auto-reset). |
| 1138 | case "p": |
| 1139 | state.toggleGroup("pass"); |
| 1140 | break; |
| 1141 | case "f": |
| 1142 | state.toggleGroup("fail"); |
| 1143 | break; |
| 1144 | case "n": |
| 1145 | state.toggleGroup("pending"); |
| 1146 | break; |
| 1147 | case "s": |
| 1148 | state.toggleGroup("skipped"); |
| 1149 | break; |
| 1150 | default: |
| 1151 | return; |
| 1152 | } |
| 1153 | state.selectedIndex = 0; |
| 1154 | state.topIndex = 0; |
| 1155 | refresh(); |
| 1156 | }); |
| 1157 | |
| 1158 | // Single flush loop: repaints the visible window at most 10x/s and ONLY |
| 1159 | // when test data changed; otherwise it just ticks the elapsed-time header |
| 1160 | // while the run is live. |
| 1161 | const interval = setInterval(() => { |
| 1162 | if (disposed) return; |
| 1163 | // Tail the open detail pane while its test is running. |
| 1164 | if (state.detailOpen) refreshDetail(); |
| 1165 | // Live elapsed timers: hook phases on file rows and running test rows. |
| 1166 | if (state.counts.running > 0) state.dirty = true; |
| 1167 | for (const node of state.fileOrder) { |
| 1168 | if (node.hook !== undefined) state.dirty = true; |
| 1169 | } |
| 1170 | if (state.dirty) { |
| 1171 | refresh(); |
| 1172 | } else if (state.summary === undefined || Date.now() < flashUntil + 100) { |
| 1173 | updateHeader(); |
| 1174 | updateFooter(); |
| 1175 | } |
| 1176 | }, 100); |
| 1177 | |
| 1178 | const dispose = (): void => { |
| 1179 | if (disposed) return; |
| 1180 | disposed = true; |
| 1181 | clearInterval(interval); |
| 1182 | restoreStreams(); |
| 1183 | try { |
| 1184 | renderer.destroy(); |
| 1185 | } catch { |
| 1186 | // Never let teardown mask the run's real outcome. |
| 1187 | } |
| 1188 | }; |
| 1189 | |
| 1190 | return { |
| 1191 | renderer, |
| 1192 | state, |
| 1193 | refresh: () => { |
| 1194 | if (!disposed) refresh(); |
| 1195 | }, |
| 1196 | quit, |
| 1197 | dispose, |
| 1198 | }; |
| 1199 | }; |
| 1200 | |
| 1201 | // --------------------------------------------------------------------------- |
| 1202 | // Events |
| 1203 | // --------------------------------------------------------------------------- |
| 1204 | |
| 1205 | const onEvent = (tui: Tui, event: TestEvent): void => { |
| 1206 | const { state } = tui; |
| 1207 | switch (event._tag) { |
| 1208 | case "CollectStart": |
| 1209 | state.collectTotal = event.files.length; |
| 1210 | break; |
| 1211 | case "FileCollected": |
| 1212 | state.collectDone++; |
| 1213 | break; |
| 1214 | case "RunStart": |
| 1215 | state.startedAt = Date.now(); |
| 1216 | for (const meta of event.tests) state.upsert(meta, "queued"); |
| 1217 | break; |
| 1218 | case "FileStart": |
| 1219 | state.fileNode(event.file).started = true; |
| 1220 | // Live hook-log buffer — grows in place as deploy/destroy run. |
| 1221 | if (event.logs !== undefined) { |
| 1222 | state.fileLogs.set(event.file, event.logs); |
| 1223 | } |
| 1224 | break; |
| 1225 | case "HookStart": { |
| 1226 | const node = state.fileNode(event.file); |
| 1227 | node.hook = event.hook; |
| 1228 | node.hookStartedAt = Date.now(); |
| 1229 | break; |
| 1230 | } |
| 1231 | case "HookEnd": |
| 1232 | state.fileNode(event.file).hook = undefined; |
| 1233 | break; |
| 1234 | case "TestStart": { |
| 1235 | state.upsert(event.test, "running"); |
| 1236 | const entry = state.byId.get(event.test.id); |
| 1237 | if (entry !== undefined) entry.liveLogs = event.logs; |
| 1238 | break; |
| 1239 | } |
| 1240 | case "TestEnd": |
| 1241 | state.upsert(event.test, event.result.status, event.result); |
| 1242 | break; |
| 1243 | case "FileEnd": { |
| 1244 | const node = state.fileNode(event.file); |
| 1245 | node.hook = undefined; |
| 1246 | state.fileLogs.set(event.file, event.logs); |
| 1247 | if (event.error !== undefined) { |
| 1248 | // Surface import/hook failures as a synthetic failed entry. |
| 1249 | state.upsert( |
| 1250 | { |
| 1251 | id: `${event.file} > [file]`, |
| 1252 | file: event.file, |
| 1253 | titlePath: ["[file]"], |
| 1254 | name: "[file]", |
| 1255 | tags: [], |
| 1256 | optInTags: [], |
| 1257 | }, |
| 1258 | "fail", |
| 1259 | { |
| 1260 | status: "fail", |
| 1261 | durationMs: 0, |
| 1262 | error: event.error, |
| 1263 | logs: event.logs, |
| 1264 | retries: 0, |
| 1265 | }, |
| 1266 | ); |
| 1267 | } |
| 1268 | break; |
| 1269 | } |
| 1270 | case "RunEnd": |
| 1271 | state.summary = event.summary; |
| 1272 | // Final state should render immediately, not on the next tick. |
| 1273 | state.dirty = true; |
| 1274 | tui.refresh(); |
| 1275 | return; |
| 1276 | default: |
| 1277 | break; |
| 1278 | } |
| 1279 | // Everything else just marks the state dirty; the flush interval batches |
| 1280 | // repaints (a burst of TestEnd events must not repaint per event). |
| 1281 | state.dirty = true; |
| 1282 | }; |
| 1283 | |
| 1284 | export const TuiReporter = (logFile: string): Layer.Layer<Reporter> => |
| 1285 | Layer.effect(Reporter)( |
| 1286 | Effect.gen(function* () { |
| 1287 | const tui = yield* Effect.acquireRelease( |
| 1288 | Effect.promise(() => makeTui(logFile)), |
| 1289 | (t) => Effect.sync(() => t.dispose()), |
| 1290 | ); |
| 1291 | return { |
| 1292 | emit: (event) => Effect.sync(() => onEvent(tui, event)), |
| 1293 | waitForExit: () => Effect.promise(() => tui.quit), |
| 1294 | attachController: (controller) => |
| 1295 | Effect.sync(() => { |
| 1296 | tui.state.controller = controller; |
| 1297 | }), |
| 1298 | }; |
| 1299 | }), |
| 1300 | ); |