feat(hooks): add quiet and nothrow to notification shell executions
This commit is contained in:
@@ -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<typeof result> & {
|
||||
quiet: () => Promise<typeof result>
|
||||
nothrow: () => Promise<typeof result> & { quiet: () => Promise<typeof result> }
|
||||
}
|
||||
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<typeof result> & {
|
||||
quiet: () => Promise<typeof result>
|
||||
nothrow: () => Promise<typeof result> & { quiet: () => Promise<typeof result> }
|
||||
}
|
||||
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<typeof result> & {
|
||||
quiet: () => Promise<typeof result>
|
||||
nothrow: () => Promise<typeof result> & { quiet: () => Promise<typeof result> }
|
||||
}
|
||||
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<typeof result> & {
|
||||
quiet: () => Promise<typeof result>
|
||||
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<typeof result> & {
|
||||
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<typeof result> & {
|
||||
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<typeof result> & {
|
||||
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<typeof result> & {
|
||||
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<typeof result> & {
|
||||
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<typeof result> & {
|
||||
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<typeof result> & {
|
||||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user