| 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { |
| 3 | applyTerminalAttachStreamEvent, |
| 4 | combineTerminalSessionState, |
| 5 | EMPTY_TERMINAL_BUFFER_STATE, |
| 6 | INITIAL_TERMINAL_OUTPUT_CURSOR, |
| 7 | nextTerminalAttachSeedState, |
| 8 | } from "@t3tools/client-runtime/state/terminal"; |
| 9 | |
| 10 | import { |
| 11 | shouldHandleTerminalExit, |
| 12 | terminalContextMenuItems, |
| 13 | synchronizeTerminalOutput, |
| 14 | terminalSelectionLineRange, |
| 15 | terminalThemeFromApp, |
| 16 | } from "./ThreadTerminalDrawer"; |
| 17 | |
| 18 | describe("terminal selection menus", () => { |
| 19 | it("omits Add to chat when the terminal has no chat target", () => { |
| 20 | expect(terminalContextMenuItems({ hasSelection: true }).map(({ id }) => id)).toEqual([ |
| 21 | "add-to-chat", |
| 22 | "copy", |
| 23 | "paste", |
| 24 | "select-all", |
| 25 | "scroll-to-bottom", |
| 26 | ]); |
| 27 | |
| 28 | expect( |
| 29 | terminalContextMenuItems({ hasSelection: true, canAddToChat: false }).map(({ id }) => id), |
| 30 | ).toEqual(["copy", "paste", "select-all", "scroll-to-bottom"]); |
| 31 | }); |
| 32 | |
| 33 | it("keeps local viewing actions available to a read-only observer", () => { |
| 34 | const items = terminalContextMenuItems({ hasSelection: false, readOnly: true }); |
| 35 | expect(items.filter((item) => item.disabled).map((item) => item.id)).toEqual([ |
| 36 | "add-to-chat", |
| 37 | "copy", |
| 38 | "paste", |
| 39 | ]); |
| 40 | expect(items.filter((item) => !item.disabled).map((item) => item.id)).toEqual([ |
| 41 | "select-all", |
| 42 | "scroll-to-bottom", |
| 43 | ]); |
| 44 | }); |
| 45 | }); |
| 46 | |
| 47 | describe("terminalThemeFromApp", () => { |
| 48 | afterEach(() => { |
| 49 | vi.unstubAllGlobals(); |
| 50 | }); |
| 51 | |
| 52 | it("uses terminal colors inherited by the mount instead of a light document theme", () => { |
| 53 | const root = { classList: { contains: () => false } }; |
| 54 | const body = {}; |
| 55 | const drawer = {}; |
| 56 | vi.stubGlobal("document", { documentElement: root, body, querySelector: () => drawer }); |
| 57 | |
| 58 | vi.stubGlobal("getComputedStyle", (element: object) => { |
| 59 | const local = element === drawer; |
| 60 | const values = local |
| 61 | ? { |
| 62 | "--terminal-background": "#000", |
| 63 | "--terminal-foreground": "#fff", |
| 64 | "--terminal-cursor": "#ddd", |
| 65 | "--terminal-selection-background": "rgba(255, 255, 255, 0.2)", |
| 66 | } |
| 67 | : { |
| 68 | "--terminal-background": "#fff", |
| 69 | "--terminal-foreground": "#111", |
| 70 | }; |
| 71 | return { |
| 72 | backgroundColor: local ? "#000" : "#fff", |
| 73 | color: local ? "#fff" : "#111", |
| 74 | colorScheme: local ? "dark" : "light", |
| 75 | getPropertyValue: (name: string) => values[name as keyof typeof values] ?? "", |
| 76 | }; |
| 77 | }); |
| 78 | |
| 79 | const theme = terminalThemeFromApp(); |
| 80 | |
| 81 | expect(theme.background).toBe("#000"); |
| 82 | expect(theme.foreground).toBe("#fff"); |
| 83 | expect(theme.cursor).toBe("#ddd"); |
| 84 | }); |
| 85 | }); |
| 86 | |
| 87 | describe("terminal selection actions", () => { |
| 88 | it("uses Ghostty's physical screen range for visually wrapped selections", () => { |
| 89 | expect( |
| 90 | terminalSelectionLineRange({ |
| 91 | start: { y: 4 }, |
| 92 | end: { y: 6 }, |
| 93 | }), |
| 94 | ).toEqual({ lineStart: 5, lineEnd: 7 }); |
| 95 | }); |
| 96 | |
| 97 | it("handles an exit that lands while the terminal surface is still loading", () => { |
| 98 | expect(shouldHandleTerminalExit("exited", "running", false, 1)).toBe(true); |
| 99 | expect(shouldHandleTerminalExit("exited", "exited", false, 1)).toBe(false); |
| 100 | expect(shouldHandleTerminalExit("closed", "running", true, 1)).toBe(false); |
| 101 | }); |
| 102 | |
| 103 | it.each(["closed", "exited"] as const)("ignores an unsynchronized %s seed", (status) => { |
| 104 | expect(shouldHandleTerminalExit(status, "running", false, 0)).toBe(false); |
| 105 | expect(shouldHandleTerminalExit(status, "running", false, 1)).toBe(true); |
| 106 | }); |
| 107 | }); |
| 108 | |
| 109 | it("retains visible output and selection until a replacement subscription receives its snapshot", () => { |
| 110 | const terminal = { resetAndWrite: vi.fn(), write: vi.fn(), clearSelection: vi.fn() }; |
| 111 | const snapshot = { |
| 112 | threadId: "thread", |
| 113 | terminalId: "term-1", |
| 114 | cwd: "/repo", |
| 115 | worktreePath: null, |
| 116 | status: "running" as const, |
| 117 | pid: 123, |
| 118 | history: "host output", |
| 119 | exitCode: null, |
| 120 | exitSignal: null, |
| 121 | label: "Terminal 1", |
| 122 | updatedAt: "2026-09-05T00:00:00.000Z", |
| 123 | }; |
| 124 | const attached = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), { |
| 125 | type: "snapshot", |
| 126 | snapshot, |
| 127 | }); |
| 128 | const cursor = synchronizeTerminalOutput(terminal, attached, INITIAL_TERMINAL_OUTPUT_CURSOR); |
| 129 | expect(terminal.resetAndWrite).toHaveBeenLastCalledWith("host output"); |
| 130 | terminal.resetAndWrite.mockClear(); |
| 131 | terminal.clearSelection.mockClear(); |
| 132 | |
| 133 | const pending = combineTerminalSessionState( |
| 134 | { ...snapshot, hasRunningSubprocess: false }, |
| 135 | EMPTY_TERMINAL_BUFFER_STATE, |
| 136 | ); |
| 137 | expect(pending.status).toBe("running"); |
| 138 | expect(synchronizeTerminalOutput(terminal, pending, cursor)).toBe(cursor); |
| 139 | expect(terminal.resetAndWrite).not.toHaveBeenCalled(); |
| 140 | expect(terminal.write).not.toHaveBeenCalled(); |
| 141 | expect(terminal.clearSelection).not.toHaveBeenCalled(); |
| 142 | |
| 143 | const observed = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), { |
| 144 | type: "snapshot", |
| 145 | snapshot: { ...snapshot, history: "host output\nobserved output" }, |
| 146 | }); |
| 147 | const observedCursor = synchronizeTerminalOutput(terminal, observed, cursor); |
| 148 | expect(terminal.resetAndWrite).toHaveBeenLastCalledWith("host output\nobserved output"); |
| 149 | |
| 150 | const cleared = applyTerminalAttachStreamEvent(observed, { |
| 151 | type: "cleared", |
| 152 | threadId: "thread", |
| 153 | terminalId: "term-1", |
| 154 | }); |
| 155 | synchronizeTerminalOutput(terminal, cleared, observedCursor); |
| 156 | expect(terminal.resetAndWrite).toHaveBeenLastCalledWith(""); |
| 157 | }); |