From 8c2385fe31228e682c0b1f72142c0579542f792b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 12 Mar 2026 18:17:07 +0900 Subject: [PATCH] feat(hooks): add quiet and nothrow to notification shell executions --- src/hooks/session-notification-sender.test.ts | 345 ++++++++++++++++++ src/hooks/session-notification-sender.ts | 20 +- src/hooks/session-notification.test.ts | 36 +- 3 files changed, 382 insertions(+), 19 deletions(-) create mode 100644 src/hooks/session-notification-sender.test.ts diff --git a/src/hooks/session-notification-sender.test.ts b/src/hooks/session-notification-sender.test.ts new file mode 100644 index 000000000..37baf167e --- /dev/null +++ b/src/hooks/session-notification-sender.test.ts @@ -0,0 +1,345 @@ +import { afterEach, beforeEach, describe, expect, jest, spyOn, test } from "bun:test" +import { sendSessionNotification, playSessionNotificationSound } from "./session-notification-sender" +import * as utils from "./session-notification-utils" +import type { PluginInput } from "@opencode-ai/plugin" + +function createShellPromise(handler: (cmdStr: string) => void) { + return (cmd: TemplateStringsArray, ...values: unknown[]) => { + const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + handler(cmdStr) + + const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } + const promise = Promise.resolve(result) as Promise & { + quiet: () => Promise + nothrow: () => Promise & { quiet: () => Promise } + } + promise.quiet = () => promise + promise.nothrow = () => { + const p = Promise.resolve(result) as typeof promise + p.quiet = () => p + p.nothrow = () => p + return p + } + return promise + } +} + +function createThrowingShellPromise(shouldThrow: (cmdStr: string) => boolean) { + return (cmd: TemplateStringsArray, ...values: unknown[]) => { + const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + + const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } + + if (shouldThrow(cmdStr)) { + const err = Object.assign(new Error("command failed"), result) + const rejectedPromise = Promise.reject(err) as Promise & { + quiet: () => Promise + nothrow: () => Promise & { quiet: () => Promise } + } + rejectedPromise.quiet = () => rejectedPromise + rejectedPromise.nothrow = () => { + const p = Promise.resolve(result) as typeof rejectedPromise + p.quiet = () => p + p.nothrow = () => p + return p + } + return rejectedPromise + } + + const promise = Promise.resolve(result) as Promise & { + quiet: () => Promise + nothrow: () => Promise & { quiet: () => Promise } + } + promise.quiet = () => promise + promise.nothrow = () => { + const p = Promise.resolve(result) as typeof promise + p.quiet = () => p + p.nothrow = () => p + return p + } + return promise + } +} + +describe("session-notification-sender", () => { + beforeEach(() => { + spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") + 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") + }) + afterEach(() => { + jest.restoreAllMocks() + }) + + describe("#given sendSessionNotification", () => { + describe("#when calling ctx.$ for notifications", () => { + test("#then should call .quiet() on all shell commands to suppress stdout/stderr", async () => { + const quietCalls: string[] = [] + const mockCtx = { + $: (cmd: TemplateStringsArray, ...values: unknown[]) => { + const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } + const promise = Promise.resolve(result) as Promise & { + quiet: () => Promise + nothrow: () => typeof promise + } + promise.quiet = () => { + quietCalls.push(cmdStr) + return promise + } + promise.nothrow = () => promise + return promise + }, + } as unknown as PluginInput + + await sendSessionNotification(mockCtx, "darwin", "Test", "Message") + + expect(quietCalls.length).toBeGreaterThanOrEqual(1) + expect(quietCalls[0]).toContain("terminal-notifier") + }) + + test("#then should call .quiet() on osascript fallback", async () => { + spyOn(utils, "getTerminalNotifierPath").mockResolvedValue(null) + + const quietCalls: string[] = [] + const mockCtx = { + $: (cmd: TemplateStringsArray, ...values: unknown[]) => { + const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } + const promise = Promise.resolve(result) as Promise & { + quiet: () => typeof promise + nothrow: () => typeof promise & { quiet: () => typeof promise } + } + promise.quiet = () => { + quietCalls.push(cmdStr) + return promise + } + promise.nothrow = () => { + const p = Promise.resolve(result) as typeof promise + p.quiet = () => { + quietCalls.push(cmdStr) + return p + } + p.nothrow = () => p + return p + } + return promise + }, + } as unknown as PluginInput + + await sendSessionNotification(mockCtx, "darwin", "Test", "Message") + + expect(quietCalls.length).toBeGreaterThanOrEqual(1) + expect(quietCalls[0]).toContain("osascript") + }) + + test("#then should call .quiet() on linux notify-send", async () => { + const quietCalls: string[] = [] + const mockCtx = { + $: (cmd: TemplateStringsArray, ...values: unknown[]) => { + const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } + const promise = Promise.resolve(result) as Promise & { + quiet: () => typeof promise + nothrow: () => typeof promise & { quiet: () => typeof promise } + } + promise.quiet = () => { + quietCalls.push(cmdStr) + return promise + } + promise.nothrow = () => { + const p = Promise.resolve(result) as typeof promise + p.quiet = () => { + quietCalls.push(cmdStr) + return p + } + p.nothrow = () => p + return p + } + return promise + }, + } as unknown as PluginInput + + await sendSessionNotification(mockCtx, "linux", "Test", "Message") + + expect(quietCalls.length).toBe(1) + expect(quietCalls[0]).toContain("notify-send") + }) + + test("#then should call .quiet() on win32 powershell", async () => { + const quietCalls: string[] = [] + const mockCtx = { + $: (cmd: TemplateStringsArray, ...values: unknown[]) => { + const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } + const promise = Promise.resolve(result) as Promise & { + quiet: () => typeof promise + nothrow: () => typeof promise & { quiet: () => typeof promise } + } + promise.quiet = () => { + quietCalls.push(cmdStr) + return promise + } + promise.nothrow = () => { + const p = Promise.resolve(result) as typeof promise + p.quiet = () => { + quietCalls.push(cmdStr) + return p + } + p.nothrow = () => p + return p + } + return promise + }, + } as unknown as PluginInput + + await sendSessionNotification(mockCtx, "win32", "Test", "Message") + + expect(quietCalls.length).toBe(1) + expect(quietCalls[0]).toContain("powershell") + }) + }) + }) + + describe("#given playSessionNotificationSound", () => { + describe("#when calling ctx.$ for sound playback", () => { + test("#then should call .quiet() on darwin afplay", async () => { + const quietCalls: string[] = [] + const mockCtx = { + $: (cmd: TemplateStringsArray, ...values: unknown[]) => { + const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } + const promise = Promise.resolve(result) as Promise & { + quiet: () => typeof promise + nothrow: () => typeof promise & { quiet: () => typeof promise } + } + promise.quiet = () => { + quietCalls.push(cmdStr) + return promise + } + promise.nothrow = () => { + const p = Promise.resolve(result) as typeof promise + p.quiet = () => { + quietCalls.push(cmdStr) + return p + } + p.nothrow = () => p + return p + } + return promise + }, + } as unknown as PluginInput + + await playSessionNotificationSound(mockCtx, "darwin", "/sound.aiff") + + expect(quietCalls.length).toBe(1) + expect(quietCalls[0]).toContain("afplay") + }) + + test("#then should call .quiet() on linux paplay", async () => { + const quietCalls: string[] = [] + const mockCtx = { + $: (cmd: TemplateStringsArray, ...values: unknown[]) => { + const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } + const promise = Promise.resolve(result) as Promise & { + quiet: () => typeof promise + nothrow: () => typeof promise & { quiet: () => typeof promise } + } + promise.quiet = () => { + quietCalls.push(cmdStr) + return promise + } + promise.nothrow = () => { + const p = Promise.resolve(result) as typeof promise + p.quiet = () => { + quietCalls.push(cmdStr) + return p + } + p.nothrow = () => p + return p + } + return promise + }, + } as unknown as PluginInput + + await playSessionNotificationSound(mockCtx, "linux", "/sound.oga") + + expect(quietCalls.length).toBe(1) + expect(quietCalls[0]).toContain("paplay") + }) + + test("#then should call .quiet() on linux aplay fallback", async () => { + spyOn(utils, "getPaplayPath").mockResolvedValue(null) + + const quietCalls: string[] = [] + const mockCtx = { + $: (cmd: TemplateStringsArray, ...values: unknown[]) => { + const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } + const promise = Promise.resolve(result) as Promise & { + quiet: () => typeof promise + nothrow: () => typeof promise & { quiet: () => typeof promise } + } + promise.quiet = () => { + quietCalls.push(cmdStr) + return promise + } + promise.nothrow = () => { + const p = Promise.resolve(result) as typeof promise + p.quiet = () => { + quietCalls.push(cmdStr) + return p + } + p.nothrow = () => p + return p + } + return promise + }, + } as unknown as PluginInput + + await playSessionNotificationSound(mockCtx, "linux", "/sound.oga") + + expect(quietCalls.length).toBe(1) + expect(quietCalls[0]).toContain("aplay") + }) + + test("#then should call .quiet() on win32 powershell sound", async () => { + const quietCalls: string[] = [] + const mockCtx = { + $: (cmd: TemplateStringsArray, ...values: unknown[]) => { + const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } + const promise = Promise.resolve(result) as Promise & { + quiet: () => typeof promise + nothrow: () => typeof promise & { quiet: () => typeof promise } + } + promise.quiet = () => { + quietCalls.push(cmdStr) + return promise + } + promise.nothrow = () => { + const p = Promise.resolve(result) as typeof promise + p.quiet = () => { + quietCalls.push(cmdStr) + return p + } + p.nothrow = () => p + return p + } + return promise + }, + } as unknown as PluginInput + + await playSessionNotificationSound(mockCtx, "win32", "C:\\sound.wav") + + expect(quietCalls.length).toBe(1) + expect(quietCalls[0]).toContain("powershell") + }) + }) + }) +}) diff --git a/src/hooks/session-notification-sender.ts b/src/hooks/session-notification-sender.ts index f7e3e2ce6..722509592 100644 --- a/src/hooks/session-notification-sender.ts +++ b/src/hooks/session-notification-sender.ts @@ -46,9 +46,9 @@ export async function sendSessionNotification( const bundleId = process.env.__CFBundleIdentifier try { if (bundleId) { - await ctx.$`${terminalNotifierPath} -title ${title} -message ${message} -activate ${bundleId}` + await ctx.$`${terminalNotifierPath} -title ${title} -message ${message} -activate ${bundleId}`.quiet() } else { - await ctx.$`${terminalNotifierPath} -title ${title} -message ${message}` + await ctx.$`${terminalNotifierPath} -title ${title} -message ${message}`.quiet() } break } catch { @@ -61,16 +61,14 @@ export async function sendSessionNotification( const escapedTitle = escapeAppleScriptText(title) const escapedMessage = escapeAppleScriptText(message) - await ctx.$`${osascriptPath} -e ${"display notification \"" + escapedMessage + "\" with title \"" + escapedTitle + "\""}`.catch( - () => {} - ) + await ctx.$`${osascriptPath} -e ${"display notification \"" + escapedMessage + "\" with title \"" + escapedTitle + "\""}`.nothrow().quiet() break } case "linux": { const notifySendPath = await getNotifySendPath() if (!notifySendPath) return - await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.catch(() => {}) + await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.nothrow().quiet() break } case "win32": { @@ -78,7 +76,7 @@ export async function sendSessionNotification( if (!powershellPath) return const toastScript = buildWindowsToastScript(title, message) - await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {}) + await ctx.$`${powershellPath} -Command ${toastScript}`.nothrow().quiet() break } } @@ -93,17 +91,17 @@ export async function playSessionNotificationSound( case "darwin": { const afplayPath = await getAfplayPath() if (!afplayPath) return - ctx.$`${afplayPath} ${soundPath}`.catch(() => {}) + ctx.$`${afplayPath} ${soundPath}`.nothrow().quiet() break } case "linux": { const paplayPath = await getPaplayPath() if (paplayPath) { - ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.catch(() => {}) + ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.nothrow().quiet() } else { const aplayPath = await getAplayPath() if (aplayPath) { - ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.catch(() => {}) + ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.nothrow().quiet() } } break @@ -112,7 +110,7 @@ export async function playSessionNotificationSound( const powershellPath = await getPowershellPath() if (!powershellPath) return const escaped = escapePowerShellSingleQuotedText(soundPath) - ctx.$`${powershellPath} -Command ${("(New-Object Media.SoundPlayer '" + escaped + "').PlaySync()")}`.catch(() => {}) + ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + escaped + "').PlaySync()"}`.nothrow().quiet() break } } diff --git a/src/hooks/session-notification.test.ts b/src/hooks/session-notification.test.ts index d165edffe..11a04b03b 100644 --- a/src/hooks/session-notification.test.ts +++ b/src/hooks/session-notification.test.ts @@ -390,12 +390,16 @@ describe("session-notification", () => { function createSenderMockCtx() { const notifyCalls: string[] = [] const mockCtx = { - $: async (cmd: TemplateStringsArray | string, ...values: any[]) => { + $: (cmd: TemplateStringsArray | string, ...values: any[]) => { const cmdStr = typeof cmd === "string" ? cmd : cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") notifyCalls.push(cmdStr) - return { stdout: "", stderr: "", exitCode: 0 } + const result = { stdout: "", stderr: "", exitCode: 0 } + const promise = Promise.resolve(result) as any + promise.quiet = () => promise + promise.nothrow = () => { const p = Promise.resolve(result) as any; p.quiet = () => p; p.nothrow = () => p; return p } + return promise }, } as any return { mockCtx, notifyCalls } @@ -451,17 +455,25 @@ describe("session-notification", () => { spyOn(sender, "sendSessionNotification").mockRestore() const notifyCalls: string[] = [] const mockCtx = { - $: async (cmd: TemplateStringsArray | string, ...values: unknown[]) => { + $: (cmd: TemplateStringsArray | string, ...values: unknown[]) => { const cmdStr = typeof cmd === "string" ? cmd : cmd.reduce((acc, part, index) => `${acc}${part}${String(values[index] ?? "")}`, "") notifyCalls.push(cmdStr) if (cmdStr.includes("terminal-notifier")) { - throw new Error("terminal-notifier failed") + const err = Object.assign(new Error("terminal-notifier failed"), { stdout: "", stderr: "", exitCode: 1 }) + const rejected = Promise.reject(err) as any + rejected.quiet = () => rejected + rejected.nothrow = () => { const p = Promise.resolve({ stdout: "", stderr: "", exitCode: 1 }) as any; p.quiet = () => p; p.nothrow = () => p; return p } + return rejected } - return { stdout: "", stderr: "", exitCode: 0 } + const result = { stdout: "", stderr: "", exitCode: 0 } + const promise = Promise.resolve(result) as any + promise.quiet = () => promise + promise.nothrow = () => { const p = Promise.resolve(result) as any; p.quiet = () => p; p.nothrow = () => p; return p } + return promise }, } as any spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") @@ -482,16 +494,24 @@ describe("session-notification", () => { spyOn(sender, "sendSessionNotification").mockRestore() const notifyCalls: string[] = [] const mockCtx = { - $: async (cmd: TemplateStringsArray | string, ...values: unknown[]) => { + $: (cmd: TemplateStringsArray | string, ...values: unknown[]) => { if (values.some(Array.isArray)) { - throw new Error("array interpolation unsupported") + const err = Object.assign(new Error("array interpolation unsupported"), { stdout: "", stderr: "", exitCode: 1 }) + const rejected = Promise.reject(err) as any + rejected.quiet = () => rejected + rejected.nothrow = () => { const p = Promise.resolve({ stdout: "", stderr: "", exitCode: 1 }) as any; p.quiet = () => p; p.nothrow = () => p; return p } + return rejected } const commandString = typeof cmd === "string" ? cmd : cmd.reduce((acc, part, index) => `${acc}${part}${String(values[index] ?? "")}`, "") notifyCalls.push(commandString) - return { stdout: "", stderr: "", exitCode: 0 } + const result = { stdout: "", stderr: "", exitCode: 0 } + const promise = Promise.resolve(result) as any + promise.quiet = () => promise + promise.nothrow = () => { const p = Promise.resolve(result) as any; p.quiet = () => p; p.nothrow = () => p; return p } + return promise }, } as any spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier")