Files
prism/test/mac-spotify-provider.test.ts
T
2026-04-19 18:43:54 -04:00

422 lines
13 KiB
TypeScript

import assert from 'node:assert/strict'
import test from 'node:test'
import { MacSpotifyProvider } from '../src/main/services/macSpotifyProvider'
import type {
NativeWindowsMediaAPI,
NativeWindowsSpotifyPlaybackState,
} from '../src/types/nativeWindowsMedia'
const DELIMITER = '\u001f'
function createStatusPayload(options: {
album?: string
artist?: string
artworkUrl?: string
duration?: number
id?: string
isFavorite?: boolean
playbackState: 'playing' | 'paused' | 'stopped'
position?: number
title?: string
}): string {
return [
'ok',
options.playbackState,
String(options.position ?? 0),
String(options.duration ?? 0),
options.id ?? '',
options.title ?? '',
options.artist ?? '',
options.album ?? '',
options.artworkUrl ?? '',
String(options.isFavorite ?? false),
].join(DELIMITER)
}
function createLinuxListNamesPayload(names: string[]): string {
return `([${names.map((name) => `'${name}'`).join(', ')}],)`
}
function createLinuxStatusPayload(options: {
album?: string
artist?: string
artworkUrl?: string
durationUs?: number
playbackState: 'Playing' | 'Paused' | 'Stopped'
positionUs?: number
title?: string
trackId?: string
trackUrl?: string
}): string {
const artists = options.artist ? `['${options.artist}']` : '[]'
return `({'PlaybackStatus': <'${options.playbackState}'>, 'Metadata': <{'mpris:trackid': <objectpath '${options.trackId ?? '/com/spotify/track/123'}'>, 'mpris:length': <int64 ${options.durationUs ?? 0}>, 'mpris:artUrl': <'${options.artworkUrl ?? ''}'>, 'xesam:album': <'${options.album ?? ''}'>, 'xesam:artist': <${artists}>, 'xesam:title': <'${options.title ?? ''}'>, 'xesam:url': <'${options.trackUrl ?? ''}'>}>, 'Position': <int64 ${options.positionUs ?? 0}>},)`
}
async function waitFor(predicate: () => boolean, message: string): Promise<void> {
const deadline = Date.now() + 2000
while (Date.now() < deadline) {
if (predicate()) {
return
}
await new Promise((resolve) => setTimeout(resolve, 0))
}
assert.fail(message)
}
class StubSpotifyRunner {
commandCalls: string[] = []
commandError: Error | null = null
statusCalls = 0
statusError: Error | null = null
constructor(private readonly statusResponses: string[]) {}
async run(scriptLines: string[]): Promise<string> {
const script = scriptLines.join('\n')
if (script.includes('next track')) {
return this.handleCommand('next')
}
if (script.includes('previous track')) {
return this.handleCommand('previous')
}
if (script.includes('\n pause\n')) {
return this.handleCommand('pause')
}
if (script.includes('\n play\n')) {
return this.handleCommand('play')
}
this.statusCalls += 1
if (this.statusError) {
throw this.statusError
}
return this.statusResponses[Math.min(this.statusCalls - 1, this.statusResponses.length - 1)] ?? 'not_running'
}
private handleCommand(command: string): string {
this.commandCalls.push(command)
if (this.commandError) {
throw this.commandError
}
return ''
}
}
class StubCommandRunner {
calls: Array<{ command: string; args: string[] }> = []
error: Error | null = null
constructor(
private readonly handler: (command: string, args: string[]) => string,
) {}
async run(command: string, args: string[]): Promise<string> {
this.calls.push({ command, args: [...args] })
if (this.error) {
throw this.error
}
return this.handler(command, args)
}
}
class StubWindowsMediaApi implements NativeWindowsMediaAPI {
calls: Array<{ kind: 'status' | 'control'; command?: string }> = []
controlError: Error | null = null
statusError: Error | null = null
support = {
available: true,
reason: null,
} as const
constructor(
private readonly playbackState: NativeWindowsSpotifyPlaybackState | null,
) {}
getSupport() {
return this.support
}
getSpotifyPlaybackState(): NativeWindowsSpotifyPlaybackState | null {
this.calls.push({ kind: 'status' })
if (this.statusError) {
throw this.statusError
}
return this.playbackState
}
sendSpotifyControl(command: 'play' | 'pause' | 'next' | 'previous'): boolean {
this.calls.push({ kind: 'control', command })
if (this.controlError) {
throw this.controlError
}
return true
}
}
test('provider stays unavailable on unsupported platforms', async () => {
const provider = new MacSpotifyProvider({
platform: 'freebsd',
})
try {
await provider.initialize()
assert.equal(provider.getProviderState().available, false)
assert.equal(provider.getProviderState().connectionState, 'unavailable')
} finally {
await provider.dispose()
}
})
test('provider reads local macOS Spotify playback and hydrates artwork while active', async () => {
const runner = new StubSpotifyRunner([
createStatusPayload({
playbackState: 'playing',
position: 42,
duration: 180000,
id: 'spotify:track:123',
title: 'Song One',
artist: 'Artist One',
album: 'Album One',
artworkUrl: 'https://i.scdn.co/image/cover-one',
isFavorite: true,
}),
])
const provider = new MacSpotifyProvider({
accessImpl: async () => undefined,
fetchImpl: async () => new Response(Buffer.from('cover-one'), {
status: 200,
headers: {
'content-type': 'image/png',
},
}),
now: () => 1000,
platform: 'darwin',
runner: (scriptLines) => runner.run(scriptLines),
})
try {
await provider.initialize()
await provider.setConsumerActive(1, true)
await waitFor(() => provider.getProviderState().connectionState === 'connected', 'expected connected Spotify state')
const state = provider.getProviderState()
assert.equal(state.snapshot?.currentTrack?.title, 'Song One')
assert.equal(state.snapshot?.currentTrack?.isFavorite, true)
assert.match(state.snapshot?.currentTrack?.artworkDataUrl ?? '', /^data:image\/png;base64,/)
assert.equal(state.snapshot?.playbackState, 'playing')
assert.equal(state.snapshot?.currentTime, 42)
assert.equal(state.snapshot?.duration, 180)
} finally {
await provider.dispose()
}
})
test('provider treats a non-running macOS Spotify app as idle instead of erroring', async () => {
const provider = new MacSpotifyProvider({
accessImpl: async () => undefined,
platform: 'darwin',
runner: async () => 'not_running',
})
try {
await provider.initialize()
await provider.setConsumerActive(1, true)
await waitFor(() => provider.getProviderState().connectionState === 'disabled', 'expected disabled Spotify state')
assert.equal(provider.getProviderState().lastError, null)
} finally {
await provider.dispose()
}
})
test('provider surfaces macOS Automation permission failures during polling', async () => {
const provider = new MacSpotifyProvider({
accessImpl: async () => undefined,
platform: 'darwin',
runner: async () => {
throw new Error('Not authorized to send Apple events to Spotify. (-1743)')
},
})
try {
await provider.initialize()
await provider.setConsumerActive(1, true)
await waitFor(() => provider.getProviderState().connectionState === 'error', 'expected Spotify error state')
assert.match(provider.getProviderState().lastError ?? '', /Automation permission/)
} finally {
await provider.dispose()
}
})
test('provider routes macOS transport controls through AppleScript and records control failures', async () => {
const runner = new StubSpotifyRunner([
createStatusPayload({
playbackState: 'paused',
position: 0,
duration: 180000,
id: 'spotify:track:123',
title: 'Song One',
artist: 'Artist One',
album: 'Album One',
}),
])
const provider = new MacSpotifyProvider({
accessImpl: async () => undefined,
platform: 'darwin',
runner: (scriptLines) => runner.run(scriptLines),
})
try {
await provider.initialize()
await provider.sendControl('next')
assert.deepEqual(runner.commandCalls, ['next'])
runner.commandError = new Error('Not authorized to send Apple events to Spotify. (-1743)')
await assert.rejects(() => provider.sendControl('play'), /Automation permission/)
assert.match(provider.getProviderState().lastControlError ?? '', /Automation permission/)
} finally {
await provider.dispose()
}
})
test('provider reads Linux Spotify playback through MPRIS and routes controls through gdbus', async () => {
const runner = new StubCommandRunner((command, args) => {
assert.equal(command, 'gdbus')
const methodIndex = args.indexOf('--method')
const method = methodIndex >= 0 ? args[methodIndex + 1] : ''
switch (method) {
case 'org.freedesktop.DBus.ListNames':
return createLinuxListNamesPayload([
'org.freedesktop.DBus',
'org.mpris.MediaPlayer2.spotify',
])
case 'org.freedesktop.DBus.Properties.GetAll':
return createLinuxStatusPayload({
playbackState: 'Playing',
positionUs: 42000000,
durationUs: 180000000,
title: 'Song Linux',
artist: 'Artist Linux',
album: 'Album Linux',
artworkUrl: 'https://i.scdn.co/image/linux-cover',
trackUrl: 'spotify:track:linux123',
})
case 'org.mpris.MediaPlayer2.Player.Next':
return '()'
default:
throw new Error(`Unhandled Linux method ${method}`)
}
})
const provider = new MacSpotifyProvider({
commandRunner: (command, args) => runner.run(command, args),
fetchImpl: async () => new Response(Buffer.from('linux-cover'), {
status: 200,
headers: {
'content-type': 'image/png',
},
}),
now: () => 2000,
platform: 'linux',
})
try {
await provider.initialize()
assert.equal(provider.getProviderState().available, true)
await provider.setConsumerActive(1, true)
await waitFor(() => provider.getProviderState().connectionState === 'connected', 'expected connected Linux Spotify state')
const state = provider.getProviderState()
assert.equal(state.snapshot?.currentTrack?.title, 'Song Linux')
assert.equal(state.snapshot?.currentTrack?.artist, 'Artist Linux')
assert.equal(state.snapshot?.currentTrack?.isFavorite, false)
assert.match(state.snapshot?.currentTrack?.artworkDataUrl ?? '', /^data:image\/png;base64,/)
assert.equal(state.snapshot?.currentTime, 42)
assert.equal(state.snapshot?.duration, 180)
await provider.sendControl('next')
assert.ok(runner.calls.some((call) => call.args.includes('org.mpris.MediaPlayer2.Player.Next')))
} finally {
await provider.dispose()
}
})
test('provider treats a missing Linux Spotify MPRIS session as idle', async () => {
const provider = new MacSpotifyProvider({
commandRunner: async (_command, args) => {
if (args.includes('org.freedesktop.DBus.ListNames')) {
return createLinuxListNamesPayload(['org.freedesktop.DBus'])
}
throw new Error('unexpected command')
},
platform: 'linux',
})
try {
await provider.initialize()
assert.equal(provider.getProviderState().available, true)
await provider.setConsumerActive(1, true)
await waitFor(() => provider.getProviderState().connectionState === 'disabled', 'expected disabled Linux Spotify state')
assert.equal(provider.getProviderState().lastError, null)
} finally {
await provider.dispose()
}
})
test('provider stays unavailable on Windows when the native media API is not present', async () => {
const provider = new MacSpotifyProvider({
platform: 'win32',
})
try {
await provider.initialize()
assert.equal(provider.getProviderState().available, false)
assert.match(provider.getProviderState().lastError ?? '', /native Windows media integration is not available/)
} finally {
await provider.dispose()
}
})
test('provider reads Windows Spotify playback through system media controls and routes commands', async () => {
const windowsMediaApi = new StubWindowsMediaApi({
playbackStatus: 'Playing',
positionMs: 32000,
durationMs: 210000,
title: 'Song Windows',
artist: 'Artist Windows',
album: 'Album Windows',
sourceAppUserModelId: 'SpotifyAB.SpotifyMusic_zpdnekdrzrea0!Spotify',
})
const provider = new MacSpotifyProvider({
now: () => 3000,
platform: 'win32',
windowsMediaApi,
})
try {
await provider.initialize()
assert.equal(provider.getProviderState().available, true)
await provider.setConsumerActive(1, true)
await waitFor(() => provider.getProviderState().connectionState === 'connected', 'expected connected Windows Spotify state')
const state = provider.getProviderState()
assert.equal(state.snapshot?.currentTrack?.title, 'Song Windows')
assert.equal(state.snapshot?.currentTrack?.artist, 'Artist Windows')
assert.equal(state.snapshot?.currentTrack?.isFavorite, false)
assert.equal(state.snapshot?.currentTime, 32)
assert.equal(state.snapshot?.duration, 210)
await provider.sendControl('next')
assert.ok(windowsMediaApi.calls.some((call) => call.kind === 'control' && call.command === 'next'))
} finally {
await provider.dispose()
}
})