Files
2026-06-22 12:40:00 +02:00

228 lines
7.4 KiB
TypeScript

import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
import { spawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import { FfmpegProcessError } from '../src';
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;
once: jest.Mock;
off: jest.Mock;
};
stderr: {
destroy: jest.Mock;
on: jest.Mock;
resume: jest.Mock;
};
once: jest.Mock;
off: 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(),
once: jest.fn(),
off: jest.fn(),
},
stderr: {
destroy: jest.fn(),
on: jest.fn(),
resume: jest.fn(),
},
once: jest.fn(),
off: jest.fn(),
removeAllListeners: jest.fn(),
kill: jest.fn(),
killed: false,
exitCode: null,
signalCode: null,
};
}
function mockSpawnReturn(childProcess: MockChildProcess): void {
mockSpawn.mockReturnValue(childProcess as unknown as ChildProcess);
}
function getProcessHandler(childProcess: MockChildProcess, eventName: string): (...args: unknown[]) => void {
return childProcess.once.mock.calls.find(([event]) => event === eventName)?.[1] as (...args: unknown[]) => void;
}
function getStdoutHandler(childProcess: MockChildProcess, eventName: string): (...args: unknown[]) => void {
return childProcess.stdout.once.mock.calls.find(([event]) => event === eventName)?.[1] as (
...args: unknown[]
) => void;
}
function getStderrHandler(childProcess: MockChildProcess, eventName: string): (...args: unknown[]) => void {
return childProcess.stderr.on.mock.calls.find(([event]) => event === eventName)?.[1] as (
...args: unknown[]
) => void;
}
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();
mockSpawnReturn(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.once).toHaveBeenCalledWith('exit', expect.any(Function));
expect(childProcess.stdout.once).toHaveBeenCalledWith('readable', expect.any(Function));
expect(childProcess.stderr.on).toHaveBeenCalledWith('data', expect.any(Function));
expect(childProcess.stderr.resume).toHaveBeenCalledTimes(1);
});
it('rejects readiness when ffmpeg startup emits an error', async () => {
const childProcess = createMockChildProcess();
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
const errorHandler = getProcessHandler(childProcess, 'error');
errorHandler(new Error('spawn ENOENT'));
await expect(handle.ready).rejects.toThrow(FfmpegProcessError);
await expect(handle.ready).rejects.toThrow('spawn ENOENT');
});
it('includes stderr when ffmpeg exits before producing audio', async () => {
const childProcess = createMockChildProcess();
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
const stderrHandler = getStderrHandler(childProcess, 'data');
const exitHandler = getProcessHandler(childProcess, 'exit');
stderrHandler('invalid input');
exitHandler(1, null);
await expect(handle.ready).rejects.toThrow('invalid input');
});
it('keeps the useful tail of long ffmpeg stderr output', async () => {
const childProcess = createMockChildProcess();
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
const stderrHandler = getStderrHandler(childProcess, 'data');
const exitHandler = getProcessHandler(childProcess, 'exit');
stderrHandler(`${'x'.repeat(5_000)}final diagnostic`);
exitHandler(1, null);
await expect(handle.ready).rejects.toThrow('final diagnostic');
});
it('keeps readiness resolved when ffmpeg exits after producing audio', async () => {
const childProcess = createMockChildProcess();
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
const readableHandler = getStdoutHandler(childProcess, 'readable');
const exitHandler = getProcessHandler(childProcess, 'exit');
readableHandler();
exitHandler(1, null);
await expect(handle.ready).resolves.toBeUndefined();
});
it('uses custom executable and argument overrides when provided', () => {
const childProcess = createMockChildProcess();
mockSpawnReturn(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();
mockSpawnReturn(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;
mockSpawnReturn(childProcess);
const handle = startFfmpeg('tests/audio.mp3');
handle.stop();
expect(childProcess.kill).not.toHaveBeenCalled();
});
});