4a38e70fa8
Replace Bun shell template literals (ctx.$) with node:child_process.spawn to work around Bun's ShellInterpreter garbage collection bug on Windows. This bug causes segmentation faults in deinitFromFinalizer during heap sweeping when shell operations are used repeatedly over time. Bug references: - oven-sh/bun#23177 (closed incomplete) - oven-sh/bun#24368 (still open) - Pending fix: oven-sh/bun#24093 The fix applies to all platforms for consistency and safety.
366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
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<typeof spyOn>
|
|
|
|
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)
|
|
})
|
|
})
|