orca/src/main/app-icon.test.ts

452 lines
13 KiB
TypeScript

import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
browserWindowGetAllWindowsMock,
createFromPathMock,
dockSetIconMock,
isMock,
windowSetIconMock
} = vi.hoisted(() => ({
browserWindowGetAllWindowsMock: vi.fn(),
createFromPathMock: vi.fn(),
dockSetIconMock: vi.fn(),
isMock: { dev: false },
windowSetIconMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { dock: { setIcon: dockSetIconMock } },
BrowserWindow: { getAllWindows: browserWindowGetAllWindowsMock },
nativeImage: { createFromPath: createFromPathMock }
}))
vi.mock('@electron-toolkit/utils', () => ({
is: isMock
}))
vi.mock('../../resources/icon.png?asset', () => ({
default: 'classic-icon'
}))
vi.mock('../../resources/icon-dev.png?asset', () => ({
default: 'classic-dev-icon'
}))
vi.mock('../../resources/app-icons/orca-watercolor.png?asset', () => ({
default: 'watercolor-icon'
}))
vi.mock('../../resources/app-icons/orca-watercolor.png?asset&asarUnpack', () => ({
default: 'watercolor-icon-unpacked'
}))
vi.mock('../../resources/app-icons/orca-blue.png?asset', () => ({
default: 'blue-icon'
}))
vi.mock('../../resources/app-icons/orca-blue.png?asset&asarUnpack', () => ({
default: 'blue-icon-unpacked'
}))
import { applyAppIcon, getAppIconPath, persistMacDockIcon } from './app-icon'
function waitForQueuedPersistence(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve))
}
async function waitForQueuedPersistenceMicrotasks(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
function createMockChildProcess(): EventEmitter & { kill: ReturnType<typeof vi.fn> } {
const childProcess = new EventEmitter() as EventEmitter & { kill: ReturnType<typeof vi.fn> }
childProcess.kill = vi.fn(() => {
childProcess.emit('exit')
return true
})
return childProcess
}
describe('app icon selection', () => {
beforeEach(() => {
browserWindowGetAllWindowsMock.mockReset()
createFromPathMock.mockReset()
dockSetIconMock.mockReset()
windowSetIconMock.mockReset()
isMock.dev = false
})
afterEach(() => {
vi.useRealTimers()
})
it('resolves classic, watercolor, blue, and invalid icon ids', () => {
expect(getAppIconPath('classic')).toBe('classic-icon')
expect(getAppIconPath('watercolor')).toBe('watercolor-icon')
expect(getAppIconPath('blue')).toBe('blue-icon')
expect(getAppIconPath('missing')).toBe('classic-icon')
})
it('applies the selected icon to the dock and live windows', () => {
const image = { isEmpty: () => false }
createFromPathMock.mockReturnValue(image)
browserWindowGetAllWindowsMock.mockReturnValue([
{ isDestroyed: () => false, setIcon: windowSetIconMock },
{ isDestroyed: () => true, setIcon: vi.fn() }
])
applyAppIcon('watercolor')
expect(createFromPathMock).toHaveBeenCalledWith('watercolor-icon')
if (process.platform === 'darwin') {
expect(dockSetIconMock).toHaveBeenCalledWith(image)
} else {
expect(dockSetIconMock).not.toHaveBeenCalled()
}
expect(windowSetIconMock).toHaveBeenCalledWith(image)
})
it('persists a custom macOS dock icon to the app bundle for inactive Dock pins', async () => {
const execFile = vi.fn(
(
_file: string,
_args: string[],
optionsOrCallback: unknown,
callback?: (error: Error | null) => void
) => {
const onComplete =
typeof optionsOrCallback === 'function'
? (optionsOrCallback as (error: Error | null) => void)
: callback
onComplete?.(null)
}
)
persistMacDockIcon('watercolor', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
await waitForQueuedPersistence()
expect(execFile).toHaveBeenCalledWith(
'/usr/bin/osascript',
expect.arrayContaining(['-e', expect.stringContaining('setIcon:image forFile:appPath')]),
expect.objectContaining({
env: expect.objectContaining({
ORCA_APP_BUNDLE_PATH: '/Applications/Orca.app',
ORCA_APP_ICON_PATH: 'watercolor-icon-unpacked'
})
}),
expect.any(Function)
)
})
it('clears the AppKit icon and Finder metadata when switching macOS back to classic', async () => {
const execFile = vi.fn(
(
_file: string,
_args: string[],
optionsOrCallback: unknown,
callback?: (error: Error | null) => void
) => {
const onComplete =
typeof optionsOrCallback === 'function'
? (optionsOrCallback as (error: Error | null) => void)
: callback
onComplete?.(null)
}
)
persistMacDockIcon('classic', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
await waitForQueuedPersistence()
expect(execFile).toHaveBeenNthCalledWith(
1,
'/usr/bin/osascript',
expect.arrayContaining([
'-e',
expect.stringContaining('setIcon:(missing value) forFile:appPath')
]),
expect.objectContaining({
env: expect.objectContaining({
ORCA_APP_BUNDLE_PATH: '/Applications/Orca.app'
}),
timeout: 10_000
}),
expect.any(Function)
)
expect(execFile).toHaveBeenCalledWith(
'/usr/bin/xattr',
['-d', 'com.apple.FinderInfo', '/Applications/Orca.app'],
expect.objectContaining({
timeout: 10_000
}),
expect.any(Function)
)
expect(execFile).toHaveBeenCalledWith(
'/usr/bin/xattr',
['-d', 'com.apple.ResourceFork', '/Applications/Orca.app'],
expect.objectContaining({
timeout: 10_000
}),
expect.any(Function)
)
})
it('warns for non-benign failures when clearing Finder custom icon metadata', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const execFile = vi.fn(
(
file: string,
args: string[],
optionsOrCallback: unknown,
callback?: (error: Error | null) => void
) => {
const onComplete =
typeof optionsOrCallback === 'function'
? (optionsOrCallback as (error: Error | null) => void)
: callback
if (file !== '/usr/bin/xattr') {
onComplete?.(null)
return
}
onComplete?.(new Error(args[1] === 'com.apple.FinderInfo' ? 'No such xattr' : 'EACCES'))
}
)
persistMacDockIcon('classic', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
await waitForQueuedPersistence()
expect(warnSpy).toHaveBeenCalledTimes(1)
expect(warnSpy).toHaveBeenCalledWith(
'[app-icon] failed to clear macOS dock icon metadata com.apple.ResourceFork:',
expect.any(Error)
)
warnSpy.mockRestore()
})
it('warns when the AppKit classic icon reset fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const execFile = vi.fn(
(
file: string,
_args: string[],
optionsOrCallback: unknown,
callback?: (error: Error | null) => void
) => {
const onComplete =
typeof optionsOrCallback === 'function'
? (optionsOrCallback as (error: Error | null) => void)
: callback
onComplete?.(file === '/usr/bin/osascript' ? new Error('reset denied') : null)
}
)
persistMacDockIcon('classic', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
await waitForQueuedPersistence()
expect(warnSpy).toHaveBeenCalledWith(
'[app-icon] failed to clear macOS dock icon:',
expect.any(Error)
)
warnSpy.mockRestore()
})
it('serializes rapid macOS dock icon persistence so the last icon request wins', async () => {
const pendingCallbacks: (() => void)[] = []
const execFile = vi.fn(
(
_file: string,
_args: string[],
optionsOrCallback: unknown,
callback?: (error: Error | null) => void
) => {
const onComplete =
typeof optionsOrCallback === 'function'
? (optionsOrCallback as (error: Error | null) => void)
: callback
pendingCallbacks.push(() => onComplete?.(null))
}
)
persistMacDockIcon('watercolor', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
await waitForQueuedPersistence()
persistMacDockIcon('blue', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
persistMacDockIcon('classic', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
expect(execFile).toHaveBeenCalledTimes(1)
expect(execFile).toHaveBeenCalledWith(
'/usr/bin/osascript',
expect.any(Array),
expect.objectContaining({
env: expect.objectContaining({
ORCA_APP_ICON_PATH: 'watercolor-icon-unpacked'
})
}),
expect.any(Function)
)
pendingCallbacks.shift()?.()
await waitForQueuedPersistence()
expect(execFile).toHaveBeenCalledTimes(2)
expect(execFile).not.toHaveBeenCalledWith(
'/usr/bin/osascript',
expect.any(Array),
expect.objectContaining({
env: expect.objectContaining({
ORCA_APP_ICON_PATH: 'blue-icon-unpacked'
})
}),
expect.any(Function)
)
expect(execFile).toHaveBeenNthCalledWith(
2,
'/usr/bin/osascript',
expect.arrayContaining([
'-e',
expect.stringContaining('setIcon:(missing value) forFile:appPath')
]),
expect.objectContaining({
env: expect.objectContaining({
ORCA_APP_BUNDLE_PATH: '/Applications/Orca.app'
}),
timeout: 10_000
}),
expect.any(Function)
)
pendingCallbacks.shift()?.()
await waitForQueuedPersistence()
expect(execFile).toHaveBeenCalledTimes(4)
expect(execFile).toHaveBeenNthCalledWith(
3,
'/usr/bin/xattr',
['-d', 'com.apple.FinderInfo', '/Applications/Orca.app'],
expect.objectContaining({
timeout: 10_000
}),
expect.any(Function)
)
expect(execFile).toHaveBeenNthCalledWith(
4,
'/usr/bin/xattr',
['-d', 'com.apple.ResourceFork', '/Applications/Orca.app'],
expect.objectContaining({
timeout: 10_000
}),
expect.any(Function)
)
for (const completeCommand of pendingCallbacks) {
completeCommand()
}
await waitForQueuedPersistence()
})
it('continues macOS dock icon persistence when a command never completes', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const hungChildProcess = createMockChildProcess()
const execFile = vi.fn(
(
_file: string,
_args: string[],
optionsOrCallback: unknown,
callback?: (error: Error | null) => void
) => {
if (execFile.mock.calls.length === 1) {
return hungChildProcess
}
const onComplete =
typeof optionsOrCallback === 'function'
? (optionsOrCallback as (error: Error | null) => void)
: callback
onComplete?.(null)
return undefined
}
)
persistMacDockIcon('watercolor', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
await waitForQueuedPersistenceMicrotasks()
persistMacDockIcon('blue', {
appBundlePath: '/Applications/Orca.app',
execFile,
isDevApp: false,
platform: 'darwin'
})
expect(execFile).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(10_000)
await waitForQueuedPersistenceMicrotasks()
expect(hungChildProcess.kill).not.toHaveBeenCalled()
expect(execFile).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1_000)
await waitForQueuedPersistenceMicrotasks()
expect(warnSpy).toHaveBeenCalledWith('[app-icon] timed out persisting macOS dock icon')
expect(hungChildProcess.kill).toHaveBeenCalledTimes(1)
expect(execFile).toHaveBeenCalledTimes(2)
expect(execFile).toHaveBeenNthCalledWith(
2,
'/usr/bin/osascript',
expect.any(Array),
expect.objectContaining({
env: expect.objectContaining({
ORCA_APP_ICON_PATH: 'blue-icon-unpacked'
}),
timeout: 10_000
}),
expect.any(Function)
)
warnSpy.mockRestore()
})
})