140 lines
4.0 KiB
TypeScript
140 lines
4.0 KiB
TypeScript
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
|
|
import { spawn } from 'node:child_process';
|
|
|
|
import { resolveFfmpegExecutable, startFfmpeg } from '../src/ffmpeg';
|
|
|
|
jest.mock('node:child_process', () => ({
|
|
spawn: jest.fn(),
|
|
}));
|
|
|
|
const mockSpawn = jest.mocked(spawn);
|
|
|
|
type MockChildProcess = {
|
|
stdout: {
|
|
destroy: jest.Mock;
|
|
};
|
|
stderr: {
|
|
destroy: jest.Mock;
|
|
resume: jest.Mock;
|
|
};
|
|
once: jest.Mock;
|
|
removeAllListeners: jest.Mock;
|
|
kill: jest.Mock;
|
|
killed: boolean;
|
|
exitCode: number | null;
|
|
signalCode: NodeJS.Signals | null;
|
|
};
|
|
|
|
function createMockChildProcess(): MockChildProcess {
|
|
return {
|
|
stdout: {
|
|
destroy: jest.fn(),
|
|
},
|
|
stderr: {
|
|
destroy: jest.fn(),
|
|
resume: jest.fn(),
|
|
},
|
|
once: jest.fn(),
|
|
removeAllListeners: jest.fn(),
|
|
kill: jest.fn(),
|
|
killed: false,
|
|
exitCode: null,
|
|
signalCode: null,
|
|
};
|
|
}
|
|
|
|
describe('ffmpeg helpers', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it('prefers an explicit executable path', () => {
|
|
expect(resolveFfmpegExecutable({ executablePath: '/custom/ffmpeg' })).toBe('/custom/ffmpeg');
|
|
});
|
|
|
|
it('uses the native ffmpeg binary by default', () => {
|
|
expect(resolveFfmpegExecutable()).toBe('ffmpeg');
|
|
expect(resolveFfmpegExecutable({ mode: 'native' })).toBe('ffmpeg');
|
|
});
|
|
|
|
it('spawns ffmpeg with the default argument set', () => {
|
|
const childProcess = createMockChildProcess();
|
|
mockSpawn.mockReturnValue(childProcess);
|
|
|
|
startFfmpeg('https://synradiode.stream.laut.fm/synradiode');
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'ffmpeg',
|
|
[
|
|
'-hide_banner',
|
|
'-loglevel',
|
|
'error',
|
|
'-nostdin',
|
|
'-i',
|
|
'https://synradiode.stream.laut.fm/synradiode',
|
|
'-vn',
|
|
'-f',
|
|
's16le',
|
|
'-ar',
|
|
'48000',
|
|
'-ac',
|
|
'2',
|
|
'pipe:1',
|
|
],
|
|
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
);
|
|
expect(childProcess.once).toHaveBeenCalledWith('error', expect.any(Function));
|
|
expect(childProcess.stderr.resume).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('uses custom executable and argument overrides when provided', () => {
|
|
const childProcess = createMockChildProcess();
|
|
mockSpawn.mockReturnValue(childProcess);
|
|
|
|
startFfmpeg('tests/audio.mp3', {
|
|
executablePath: '/custom/ffmpeg',
|
|
inputArgs: ['-re'],
|
|
outputArgs: ['-f', 'wav', 'pipe:1'],
|
|
});
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'/custom/ffmpeg',
|
|
['-re', '-i', 'tests/audio.mp3', '-f', 'wav', 'pipe:1'],
|
|
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
);
|
|
});
|
|
|
|
it('stops a running child process and schedules a force kill fallback', () => {
|
|
jest.useFakeTimers();
|
|
const childProcess = createMockChildProcess();
|
|
mockSpawn.mockReturnValue(childProcess);
|
|
|
|
const handle = startFfmpeg('tests/audio.mp3');
|
|
handle.stop();
|
|
|
|
expect(childProcess.stdout.destroy).toHaveBeenCalledTimes(1);
|
|
expect(childProcess.stderr.destroy).toHaveBeenCalledTimes(1);
|
|
expect(childProcess.removeAllListeners).toHaveBeenCalledTimes(1);
|
|
expect(childProcess.kill).toHaveBeenCalledWith('SIGTERM');
|
|
|
|
jest.advanceTimersByTime(2_000);
|
|
|
|
expect(childProcess.kill).toHaveBeenNthCalledWith(2, 'SIGKILL');
|
|
});
|
|
|
|
it('does not signal a process that already exited', () => {
|
|
const childProcess = createMockChildProcess();
|
|
childProcess.exitCode = 0;
|
|
mockSpawn.mockReturnValue(childProcess);
|
|
|
|
const handle = startFfmpeg('tests/audio.mp3');
|
|
handle.stop();
|
|
|
|
expect(childProcess.kill).not.toHaveBeenCalled();
|
|
});
|
|
});
|