Skip to content

Commit e2e06f6

Browse files
committed
test(hooks): add test coverage for interactive-bash-session hook
1 parent 36e69f8 commit e2e06f6

1 file changed

Lines changed: 370 additions & 0 deletions

File tree

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
import { beforeEach, describe, expect, it, mock } from "bun:test";
2+
import type { InteractiveBashSessionState } from "./types";
3+
4+
const mockSaveState = mock();
5+
const mockClearState = mock();
6+
const mockLoadState = mock(() => null);
7+
const mockKillAllTrackedSessions = mock(() => Promise.resolve());
8+
const mockSubagentSessions = new Set<string>();
9+
10+
mock.module("./storage", () => ({
11+
saveInteractiveBashSessionState: (...args: unknown[]) => mockSaveState(...args),
12+
clearInteractiveBashSessionState: (...args: unknown[]) => mockClearState(...args),
13+
loadInteractiveBashSessionState: (...args: unknown[]) => mockLoadState(...args),
14+
}));
15+
16+
mock.module("./state-manager", () => ({
17+
getOrCreateState: (sessionID: string, sessionStates: Map<string, InteractiveBashSessionState>) => {
18+
const existing = sessionStates.get(sessionID);
19+
if (existing) return existing;
20+
const state: InteractiveBashSessionState = {
21+
sessionID,
22+
tmuxSessions: new Set<string>(),
23+
updatedAt: Date.now(),
24+
};
25+
sessionStates.set(sessionID, state);
26+
return state;
27+
},
28+
isOmoSession: (sessionName: string | null): sessionName is string => {
29+
return sessionName !== null && sessionName.startsWith("omo-");
30+
},
31+
killAllTrackedSessions: (...args: unknown[]) => mockKillAllTrackedSessions(...args),
32+
}));
33+
34+
mock.module("../../features/claude-code-session-state", () => ({
35+
subagentSessions: mockSubagentSessions,
36+
}));
37+
38+
mock.module("../../shared/event-session-id", () => ({
39+
resolveSessionEventID: (props: unknown) => {
40+
const p = props as Record<string, unknown> | undefined;
41+
return p?.sessionID as string | undefined;
42+
},
43+
}));
44+
45+
import { createInteractiveBashSessionHook } from "./hook";
46+
47+
function createMockCtx() {
48+
return {
49+
directory: "/workspace",
50+
client: {
51+
session: {
52+
abort: mock(() => Promise.resolve()),
53+
},
54+
},
55+
} as unknown as Parameters<typeof createInteractiveBashSessionHook>[0];
56+
}
57+
58+
describe("interactive-bash-session hook", () => {
59+
beforeEach(() => {
60+
mockSaveState.mockClear();
61+
mockClearState.mockClear();
62+
mockLoadState.mockClear();
63+
mockKillAllTrackedSessions.mockClear();
64+
mockSubagentSessions.clear();
65+
});
66+
67+
describe("#given createInteractiveBashSessionHook is called", () => {
68+
it("#then returns tool.execute.after and event handlers", () => {
69+
// given
70+
const ctx = createMockCtx();
71+
72+
// when
73+
const hook = createInteractiveBashSessionHook(ctx);
74+
75+
// then
76+
expect(hook).toHaveProperty(["tool.execute.after"]);
77+
expect(hook).toHaveProperty(["event"]);
78+
expect(typeof hook["tool.execute.after"]).toBe("function");
79+
expect(typeof hook.event).toBe("function");
80+
});
81+
});
82+
83+
describe("#given tool.execute.after is invoked", () => {
84+
describe("#when tool is not interactive_bash", () => {
85+
it("#then does nothing", async () => {
86+
// given
87+
const ctx = createMockCtx();
88+
const hook = createInteractiveBashSessionHook(ctx);
89+
const input = { tool: "bash", sessionID: "ses-1", callID: "call-1", args: {} };
90+
const output = { title: "", output: "", metadata: {} };
91+
92+
// when
93+
await hook["tool.execute.after"](input, output);
94+
95+
// then
96+
expect(mockSaveState).not.toHaveBeenCalled();
97+
});
98+
});
99+
100+
describe("#when tool is interactive_bash but no tmux_command arg", () => {
101+
it("#then does nothing", async () => {
102+
// given
103+
const ctx = createMockCtx();
104+
const hook = createInteractiveBashSessionHook(ctx);
105+
const input = {
106+
tool: "interactive_bash",
107+
sessionID: "ses-2",
108+
callID: "call-2",
109+
args: { command: "ls" },
110+
};
111+
const output = { title: "", output: "", metadata: {} };
112+
113+
// when
114+
await hook["tool.execute.after"](input, output);
115+
116+
// then
117+
expect(mockSaveState).not.toHaveBeenCalled();
118+
});
119+
});
120+
121+
describe("#when output starts with Error:", () => {
122+
it("#then returns early without tracking", async () => {
123+
// given
124+
const ctx = createMockCtx();
125+
const hook = createInteractiveBashSessionHook(ctx);
126+
const input = {
127+
tool: "interactive_bash",
128+
sessionID: "ses-3",
129+
callID: "call-3",
130+
args: { tmux_command: "new-session -s omo-test" },
131+
};
132+
const output = { title: "", output: "Error: session not found", metadata: {} };
133+
134+
// when
135+
await hook["tool.execute.after"](input, output);
136+
137+
// then
138+
expect(mockSaveState).not.toHaveBeenCalled();
139+
});
140+
});
141+
142+
describe("#when new-session with omo- prefix", () => {
143+
it("#then tracks the session and saves state", async () => {
144+
// given
145+
const ctx = createMockCtx();
146+
const hook = createInteractiveBashSessionHook(ctx);
147+
const input = {
148+
tool: "interactive_bash",
149+
sessionID: "ses-4",
150+
callID: "call-4",
151+
args: { tmux_command: "new-session -s omo-dev" },
152+
};
153+
const output = { title: "", output: "session created", metadata: {} };
154+
155+
// when
156+
await hook["tool.execute.after"](input, output);
157+
158+
// then
159+
expect(mockSaveState).toHaveBeenCalledTimes(1);
160+
const savedState = mockSaveState.mock.calls[0][0] as InteractiveBashSessionState;
161+
expect(savedState.tmuxSessions.has("omo-dev")).toBe(true);
162+
});
163+
164+
it("#then appends session reminder to output", async () => {
165+
// given
166+
const ctx = createMockCtx();
167+
const hook = createInteractiveBashSessionHook(ctx);
168+
const input = {
169+
tool: "interactive_bash",
170+
sessionID: "ses-5",
171+
callID: "call-5",
172+
args: { tmux_command: "new-session -s omo-work" },
173+
};
174+
const output = { title: "", output: "ok", metadata: {} };
175+
176+
// when
177+
await hook["tool.execute.after"](input, output);
178+
179+
// then
180+
expect(output.output).toContain("Active omo-* tmux sessions");
181+
expect(output.output).toContain("omo-work");
182+
});
183+
});
184+
185+
describe("#when new-session without omo- prefix", () => {
186+
it("#then does not track the session", async () => {
187+
// given
188+
const ctx = createMockCtx();
189+
const hook = createInteractiveBashSessionHook(ctx);
190+
const input = {
191+
tool: "interactive_bash",
192+
sessionID: "ses-6",
193+
callID: "call-6",
194+
args: { tmux_command: "new-session -s my-session" },
195+
};
196+
const output = { title: "", output: "ok", metadata: {} };
197+
198+
// when
199+
await hook["tool.execute.after"](input, output);
200+
201+
// then
202+
expect(mockSaveState).not.toHaveBeenCalled();
203+
});
204+
});
205+
206+
describe("#when kill-session with omo- prefix", () => {
207+
it("#then removes the session from tracking", async () => {
208+
// given
209+
const ctx = createMockCtx();
210+
const hook = createInteractiveBashSessionHook(ctx);
211+
const sessionID = "ses-7";
212+
213+
// first create a session
214+
await hook["tool.execute.after"](
215+
{
216+
tool: "interactive_bash",
217+
sessionID,
218+
callID: "call-7a",
219+
args: { tmux_command: "new-session -s omo-temp" },
220+
},
221+
{ title: "", output: "ok", metadata: {} },
222+
);
223+
mockSaveState.mockClear();
224+
225+
// when
226+
const output = { title: "", output: "ok", metadata: {} };
227+
await hook["tool.execute.after"](
228+
{
229+
tool: "interactive_bash",
230+
sessionID,
231+
callID: "call-7b",
232+
args: { tmux_command: "kill-session -t omo-temp" },
233+
},
234+
output,
235+
);
236+
237+
// then
238+
expect(mockSaveState).toHaveBeenCalledTimes(1);
239+
const savedState = mockSaveState.mock.calls[0][0] as InteractiveBashSessionState;
240+
expect(savedState.tmuxSessions.has("omo-temp")).toBe(false);
241+
});
242+
});
243+
244+
describe("#when kill-server", () => {
245+
it("#then clears all tracked sessions", async () => {
246+
// given
247+
const ctx = createMockCtx();
248+
const hook = createInteractiveBashSessionHook(ctx);
249+
const sessionID = "ses-8";
250+
251+
// create sessions first
252+
await hook["tool.execute.after"](
253+
{
254+
tool: "interactive_bash",
255+
sessionID,
256+
callID: "call-8a",
257+
args: { tmux_command: "new-session -s omo-a" },
258+
},
259+
{ title: "", output: "ok", metadata: {} },
260+
);
261+
await hook["tool.execute.after"](
262+
{
263+
tool: "interactive_bash",
264+
sessionID,
265+
callID: "call-8b",
266+
args: { tmux_command: "new-session -s omo-b" },
267+
},
268+
{ title: "", output: "ok", metadata: {} },
269+
);
270+
mockSaveState.mockClear();
271+
272+
// when
273+
await hook["tool.execute.after"](
274+
{
275+
tool: "interactive_bash",
276+
sessionID,
277+
callID: "call-8c",
278+
args: { tmux_command: "kill-server" },
279+
},
280+
{ title: "", output: "ok", metadata: {} },
281+
);
282+
283+
// then
284+
expect(mockSaveState).toHaveBeenCalledTimes(1);
285+
const savedState = mockSaveState.mock.calls[0][0] as InteractiveBashSessionState;
286+
expect(savedState.tmuxSessions.size).toBe(0);
287+
});
288+
});
289+
290+
describe("#when non-session tmux command (e.g. send-keys)", () => {
291+
it("#then does not modify state or append reminder", async () => {
292+
// given
293+
const ctx = createMockCtx();
294+
const hook = createInteractiveBashSessionHook(ctx);
295+
const input = {
296+
tool: "interactive_bash",
297+
sessionID: "ses-9",
298+
callID: "call-9",
299+
args: { tmux_command: "send-keys -t omo-dev 'ls' Enter" },
300+
};
301+
const output = { title: "", output: "ok", metadata: {} };
302+
303+
// when
304+
await hook["tool.execute.after"](input, output);
305+
306+
// then
307+
expect(mockSaveState).not.toHaveBeenCalled();
308+
expect(output.output).toBe("ok");
309+
});
310+
});
311+
});
312+
313+
describe("#given event handler is invoked", () => {
314+
describe("#when event is session.deleted with valid sessionID", () => {
315+
it("#then kills tracked sessions and clears state", async () => {
316+
// given
317+
const ctx = createMockCtx();
318+
const hook = createInteractiveBashSessionHook(ctx);
319+
320+
// when
321+
await hook.event({
322+
event: {
323+
type: "session.deleted",
324+
properties: { sessionID: "ses-del-1" },
325+
},
326+
});
327+
328+
// then
329+
expect(mockKillAllTrackedSessions).toHaveBeenCalledTimes(1);
330+
expect(mockClearState).toHaveBeenCalledWith("ses-del-1");
331+
});
332+
});
333+
334+
describe("#when event is session.deleted without sessionID", () => {
335+
it("#then does nothing", async () => {
336+
// given
337+
const ctx = createMockCtx();
338+
const hook = createInteractiveBashSessionHook(ctx);
339+
340+
// when
341+
await hook.event({
342+
event: {
343+
type: "session.deleted",
344+
properties: {},
345+
},
346+
});
347+
348+
// then
349+
expect(mockKillAllTrackedSessions).not.toHaveBeenCalled();
350+
expect(mockClearState).not.toHaveBeenCalled();
351+
});
352+
});
353+
354+
describe("#when event is unrelated", () => {
355+
it("#then does nothing", async () => {
356+
// given
357+
const ctx = createMockCtx();
358+
const hook = createInteractiveBashSessionHook(ctx);
359+
360+
// when
361+
await hook.event({
362+
event: { type: "session.idle", properties: {} },
363+
});
364+
365+
// then
366+
expect(mockKillAllTrackedSessions).not.toHaveBeenCalled();
367+
});
368+
});
369+
});
370+
});

0 commit comments

Comments
 (0)