import { describe, expect, test, beforeEach, afterEach, spyOn, mock } from "bun:test" import { EventEmitter } from "node:events" import * as childProcess from "node:child_process" import { createSessionNotification } from "./session-notification" import { setMainSession, subagentSessions } from "../features/claude-code-session-state" import * as utils from "./session-notification-utils" describe("session-notification", () => { let notificationCalls: string[] let spawnMock: ReturnType function createMockPluginInput() { return { $: async () => ({ stdout: "", stderr: "", exitCode: 0 }), client: { session: { todo: async () => ({ data: [] }), }, }, directory: "/tmp/test", } as any } beforeEach(() => { notificationCalls = [] // Mock spawn to track notification commands // Uses node:child_process.spawn instead of Bun shell to avoid GC crash spawnMock = spyOn(childProcess, "spawn").mockImplementation((cmd: string, args?: string[]) => { // Track notification commands (osascript, notify-send, powershell) if (cmd.includes("osascript") || cmd.includes("notify-send") || cmd.includes("powershell")) { notificationCalls.push(`${cmd} ${(args ?? []).join(" ")}`) } const emitter = new EventEmitter() setTimeout(() => emitter.emit("close", 0), 0) return emitter as any }) spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript") spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send") spyOn(utils, "getPowershellPath").mockResolvedValue("powershell") spyOn(utils, "getAfplayPath").mockResolvedValue("/usr/bin/afplay") spyOn(utils, "getPaplayPath").mockResolvedValue("/usr/bin/paplay") spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay") spyOn(utils, "startBackgroundCheck").mockImplementation(() => {}) }) afterEach(() => { // #given - cleanup after each test subagentSessions.clear() setMainSession(undefined) }) test("should not trigger notification for subagent session", async () => { // #given - a subagent session exists const subagentSessionID = "subagent-123" subagentSessions.add(subagentSessionID) const hook = createSessionNotification(createMockPluginInput(), { idleConfirmationDelay: 0, }) // #when - subagent session goes idle await hook({ event: { type: "session.idle", properties: { sessionID: subagentSessionID }, }, }) // Wait for any pending timers await new Promise((resolve) => setTimeout(resolve, 50)) // #then - notification should NOT be sent expect(notificationCalls).toHaveLength(0) }) test("should not trigger notification when mainSessionID is set and session is not main", async () => { // #given - main session is set, but a different session goes idle const mainSessionID = "main-123" const otherSessionID = "other-456" setMainSession(mainSessionID) const hook = createSessionNotification(createMockPluginInput(), { idleConfirmationDelay: 0, }) // #when - non-main session goes idle await hook({ event: { type: "session.idle", properties: { sessionID: otherSessionID }, }, }) // Wait for any pending timers await new Promise((resolve) => setTimeout(resolve, 50)) // #then - notification should NOT be sent expect(notificationCalls).toHaveLength(0) }) test("should trigger notification for main session when idle", async () => { // #given - main session is set const mainSessionID = "main-789" setMainSession(mainSessionID) const hook = createSessionNotification(createMockPluginInput(), { idleConfirmationDelay: 10, skipIfIncompleteTodos: false, }) // #when - main session goes idle await hook({ event: { type: "session.idle", properties: { sessionID: mainSessionID }, }, }) // Wait for idle confirmation delay + buffer await new Promise((resolve) => setTimeout(resolve, 100)) // #then - notification should be sent expect(notificationCalls.length).toBeGreaterThanOrEqual(1) }) test("should skip notification for subagent even when mainSessionID is set", async () => { // #given - both mainSessionID and subagent session exist const mainSessionID = "main-999" const subagentSessionID = "subagent-888" setMainSession(mainSessionID) subagentSessions.add(subagentSessionID) const hook = createSessionNotification(createMockPluginInput(), { idleConfirmationDelay: 0, }) // #when - subagent session goes idle await hook({ event: { type: "session.idle", properties: { sessionID: subagentSessionID }, }, }) // Wait for any pending timers await new Promise((resolve) => setTimeout(resolve, 50)) // #then - notification should NOT be sent (subagent check takes priority) expect(notificationCalls).toHaveLength(0) }) test("should handle subagentSessions and mainSessionID checks in correct order", async () => { // #given - main session and subagent session exist const mainSessionID = "main-111" const subagentSessionID = "subagent-222" const unknownSessionID = "unknown-333" setMainSession(mainSessionID) subagentSessions.add(subagentSessionID) const hook = createSessionNotification(createMockPluginInput(), { idleConfirmationDelay: 0, }) // #when - subagent session goes idle await hook({ event: { type: "session.idle", properties: { sessionID: subagentSessionID }, }, }) // #when - unknown session goes idle (not main, not in subagentSessions) await hook({ event: { type: "session.idle", properties: { sessionID: unknownSessionID }, }, }) // Wait for any pending timers await new Promise((resolve) => setTimeout(resolve, 50)) // #then - no notifications (subagent blocked by subagentSessions, unknown blocked by mainSessionID check) expect(notificationCalls).toHaveLength(0) }) test("should cancel pending notification on session activity", async () => { // #given - main session is set const mainSessionID = "main-cancel" setMainSession(mainSessionID) const hook = createSessionNotification(createMockPluginInput(), { idleConfirmationDelay: 100, // Long delay skipIfIncompleteTodos: false, }) // #when - session goes idle await hook({ event: { type: "session.idle", properties: { sessionID: mainSessionID }, }, }) // #when - activity happens before delay completes await hook({ event: { type: "tool.execute.before", properties: { sessionID: mainSessionID }, }, }) // Wait for original delay to pass await new Promise((resolve) => setTimeout(resolve, 150)) // #then - notification should NOT be sent (cancelled by activity) expect(notificationCalls).toHaveLength(0) }) test("should handle session.created event without notification", async () => { // #given - a new session is created const hook = createSessionNotification(createMockPluginInput(), {}) // #when - session.created event fires await hook({ event: { type: "session.created", properties: { info: { id: "new-session", title: "Test Session" }, }, }, }) // Wait for any pending timers await new Promise((resolve) => setTimeout(resolve, 50)) // #then - no notification should be triggered expect(notificationCalls).toHaveLength(0) }) test("should handle session.deleted event and cleanup state", async () => { // #given - a session exists const hook = createSessionNotification(createMockPluginInput(), {}) // #when - session.deleted event fires await hook({ event: { type: "session.deleted", properties: { info: { id: "deleted-session" }, }, }, }) // Wait for any pending timers await new Promise((resolve) => setTimeout(resolve, 50)) // #then - no notification should be triggered expect(notificationCalls).toHaveLength(0) }) test("should mark session activity on message.updated event", async () => { // #given - main session is set const mainSessionID = "main-message" setMainSession(mainSessionID) const hook = createSessionNotification(createMockPluginInput(), { idleConfirmationDelay: 50, skipIfIncompleteTodos: false, }) // #when - session goes idle, then message.updated fires await hook({ event: { type: "session.idle", properties: { sessionID: mainSessionID }, }, }) await hook({ event: { type: "message.updated", properties: { info: { sessionID: mainSessionID, role: "user", finish: false }, }, }, }) // Wait for idle delay to pass await new Promise((resolve) => setTimeout(resolve, 100)) // #then - notification should NOT be sent (activity cancelled it) expect(notificationCalls).toHaveLength(0) }) test("should mark session activity on tool.execute.before event", async () => { // #given - main session is set const mainSessionID = "main-tool" setMainSession(mainSessionID) const hook = createSessionNotification(createMockPluginInput(), { idleConfirmationDelay: 50, skipIfIncompleteTodos: false, }) // #when - session goes idle, then tool.execute.before fires await hook({ event: { type: "session.idle", properties: { sessionID: mainSessionID }, }, }) await hook({ event: { type: "tool.execute.before", properties: { sessionID: mainSessionID }, }, }) // Wait for idle delay to pass await new Promise((resolve) => setTimeout(resolve, 100)) // #then - notification should NOT be sent (activity cancelled it) expect(notificationCalls).toHaveLength(0) }) test("should not send duplicate notification for same session", async () => { // #given - main session is set const mainSessionID = "main-dup" setMainSession(mainSessionID) const hook = createSessionNotification(createMockPluginInput(), { idleConfirmationDelay: 10, skipIfIncompleteTodos: false, }) // #when - session goes idle twice await hook({ event: { type: "session.idle", properties: { sessionID: mainSessionID }, }, }) // Wait for first notification await new Promise((resolve) => setTimeout(resolve, 50)) await hook({ event: { type: "session.idle", properties: { sessionID: mainSessionID }, }, }) // Wait for second potential notification await new Promise((resolve) => setTimeout(resolve, 50)) // #then - only one notification should be sent expect(notificationCalls).toHaveLength(1) }) })