7 Commits
Author SHA1 Message Date
fraujulian 9f4f70dcc0 fix: place version 2026-05-26 17:48:08 +02:00
fraujulian 907985b54e chore: added publish task 2026-05-26 17:44:18 +02:00
fraujulian 9998447c65 chore: updated ignore files 2026-05-26 17:43:58 +02:00
fraujulian e8fba67a1a chore(docs): updated md files 2026-05-26 17:41:05 +02:00
fraujulian b34afa0d8b chore: updated and improved dependencies 2026-05-26 17:33:56 +02:00
fraujulian 7f2eeaefb0 improve: tests 2026-05-26 17:33:11 +02:00
fraujulian 0fd3a41f1c chore: versioning 2026-05-26 17:10:26 +02:00
13 changed files with 643 additions and 710 deletions
+7 -3
View File
@@ -6,6 +6,10 @@ on:
permissions:
contents: write
env:
VERSION_MAJOR: '1'
VERSION_MINOR: '1'
jobs:
build:
runs-on: ubuntu-latest
@@ -17,10 +21,10 @@ jobs:
fetch-depth: 0
fetch-tags: true
- name: Use NodeJS v24.14.0
- name: Use NodeJS v22.22.3
uses: actions/setup-node@v6
with:
node-version: '24.14.0'
node-version: '22.22.3'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
@@ -29,7 +33,7 @@ jobs:
- name: Set Release Version
id: version
run: npm run release:version
run: npm run version:place
- name: Export Release Version
id: release_version
+2 -2
View File
@@ -15,10 +15,10 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v4
- name: use NodeJS v24.14.0
- name: use NodeJS v22.22.3
uses: actions/setup-node@v4
with:
node-version: '24.14.0'
node-version: '22.22.3'
- name: Install Dependencies
run: npm ci
+13 -4
View File
@@ -1,4 +1,13 @@
.idea
.git
node_modules
dist
.idea/
.vscode/
node_modules/
dist/
coverage/
.npm/
.eslintcache
*.log
*.tsbuildinfo
.DS_Store
Thumbs.db
+5 -1
View File
@@ -6,6 +6,8 @@
dist
node_modules
coverage
.npm
.husky
src
@@ -24,6 +26,8 @@ CODEOWNERS
tsconfig.json
tsconfig.eslint.json
tsup.config.ts
eslint.config.js
eslint.config.cjs
eslint.config.typeaware.cjs
package-lock.json
jest.config.cjs
tests
+9 -5
View File
@@ -1,5 +1,9 @@
node_modules
dist
.git
.idea
.vscode
node_modules/
dist/
coverage/
.git/
.idea/
.vscode/
package-lock.json
+1 -91
View File
@@ -29,7 +29,7 @@ Please create an [issue](https://github.com/FrauJulian/Discord-Audio-Stream/issu
### Installation
**Node.js `22.12.0` or newer is required.**
**Node.js `22.22.3` or newer is required.**
Install the library and the required voice packages:
@@ -208,88 +208,6 @@ The package exports these error classes:
Configuration problems, such as a missing source or invalid URL, throw `AudioManagerConfigError`. Invalid lifecycle
operations, such as calling `pause()` while nothing is playing, throw `AudioManagerStateError`.
## 📋 Migration From `0.7`
Version `1.0` intentionally breaks the old PascalCase API.
| Old | New |
| -------------------------------------------------------- | ------------------------------------------------------------------- |
| `new AudioManager('Native', renewMs, connection, audio)` | `new AudioManager({ ffmpeg, renewIntervalMs, connection, source })` |
| `OverrideVoiceConnectionData(...)` | `setConnection(...)` |
| `OverrideVoiceAudioDataModel(...)` | `setSource(...)` |
| `CreateConnection()` | `connect()` |
| `PlayAudio()` | `play()` |
| `CreateAndPlay()` | `start()` |
| `PauseAudio()` | `pause()` |
| `ResumeAudio()` | `resume()` |
| `StopConnection()` | `stop()` |
| `SetVolume(...)` | `setVolume(...)` |
| `Dispose()` | `dispose()` |
Old connection data:
```ts
{
VoiceChannelId: voiceChannel.id,
GuildId: guild.id,
VoiceAdapter: guild.voiceAdapterCreator,
}
```
New connection data:
```ts
{
channelId: voiceChannel.id,
guildId: guild.id,
adapterCreator: guild.voiceAdapterCreator,
}
```
Old resource data:
```ts
{
ResourceType: ('Link', Resource, 'https://example.com/audio.mp3');
}
{
ResourceType: ('File', Resource, 'audio/intro.mp3');
}
```
New source data:
```ts
{
type: ('url', url, 'https://example.com/audio.mp3');
}
{
type: ('file', path, 'audio/intro.mp3');
}
```
Old ffmpeg modes:
```ts
'Native';
'Standalone';
```
New ffmpeg modes:
```ts
{
ffmpeg: {
mode: 'native';
}
}
{
ffmpeg: {
mode: 'static';
}
}
```
## 🧑‍💻 Development
```bash
@@ -298,14 +216,6 @@ npm run check
npm run build
```
`npm run check` runs formatting, linting, type-aware linting, TypeScript type checking, and unit tests.
Release version stamping is separate from normal builds:
```bash
npm run release:version
```
## 📋 Contributors
~ [**FrauJulian - Julian Lechner**](https://fraujulian.xyz/) - CODEOWNER
+46 -9
View File
@@ -2,17 +2,54 @@
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
Security fixes are provided for the latest published release line.
| Version | Supported |
| ------- | ------------------ |
| 0.1.x | :white_check_mark: |
| Version | Supported |
| ----------------------------------------- | --------- |
| Latest release | Yes |
| Older releases | No |
| Unreleased local forks or modified builds | No |
If a vulnerability is fixed, the fix will generally be released only for the current supported version unless a backport is clearly necessary.
## Reporting a Vulnerability
Use this section to tell people how to report a vulnerability.
Do not open a public GitHub issue for security vulnerabilities.
Tell them where to go, how often they can expect to get an update on a
reported vulnerability, what to expect if the vulnerability is accepted or
declined, etc.
Report vulnerabilities privately by email to `fraujulian@lechner.top`.
Please include:
- A clear description of the issue and the expected security impact.
- Affected package version or commit, Node.js version, and runtime environment.
- Reproduction steps, proof of concept, or a minimal example.
- Any relevant logs, stack traces, or dependency details.
- Whether you believe the issue is already publicly known.
## What To Expect
- Initial acknowledgement is targeted within 7 days.
- After triage, you may be asked for more detail or validation.
- If the report is accepted, a fix will be prepared and released as soon as reasonably possible.
- Coordinated disclosure is preferred. Please wait for a fix or explicit approval before publishing details.
- If the report is out of scope or not reproducible, you will be told why.
## Scope
This policy covers vulnerabilities in the published `discord-audio-stream` package itself.
Examples that may be in scope:
- Input handling issues that could cause unsafe process execution behavior.
- Vulnerabilities caused by package logic, lifecycle management, or exposed API behavior.
- Security-relevant documentation mistakes that would realistically lead users into unsafe deployment.
Examples that are usually out of scope:
- Vulnerabilities in third-party dependencies that are not introduced by this package.
- Misconfiguration in a consuming bot, Discord server, host OS, or infrastructure.
- Denial of service caused solely by unsupported environments or intentionally extreme workloads.
## Disclosure
When a report is confirmed, the goal is to publish a fix and then disclose the issue responsibly through the repository release notes, commit history, or an advisory, depending on severity and impact.
+163 -550
View File
File diff suppressed because it is too large Load Diff
+22 -19
View File
@@ -1,7 +1,8 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "discord-audio-stream",
"version": "1.0.DDDhhmm",
"license": "LGPL-2.1-only",
"version": "x.x.x",
"license": "LGPL-3.0-only",
"description": "A small Discord voice audio streaming library with managed ffmpeg playback.",
"exports": {
".": {
@@ -25,8 +26,9 @@
"lib": "src"
},
"scripts": {
"update:dependencies": "npx npm-check-updates --upgrade",
"release:version": "node set-version.js",
"version:place": "node set-version.js set-ci-version",
"version:check": "node set-version.js check-placeholder",
"version:fix": "node set-version.js fix-placeholder",
"lint": "eslint . --ext .ts,.tsx,.js,.cjs,.mjs",
"lint:fix": "eslint . --ext .ts,.tsx,.js,.cjs,.mjs --fix",
"lint:types": "eslint . --config eslint.config.typeaware.cjs --ext .ts,.tsx",
@@ -34,8 +36,9 @@
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
"test": "jest --runInBand",
"check": "npm run format:check && npm run lint && npm run lint:types && npm run typecheck && npm run test",
"build": "del-cli dist && tsup && tsc -p tsconfig.build.json && node scripts/copy-dts.cjs",
"check": "npm run version:check && npm run format:check && npm run lint && npm run lint:types && npm run typecheck && npm run test",
"fix": "npm run version:fix && npm run format && npm run lint:fix",
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsup && tsc -p tsconfig.build.json && node scripts/copy-dts.cjs",
"prepare": "husky"
},
"repository": {
@@ -45,15 +48,17 @@
"bugs": "https://github.com/FrauJulian/Discord-Audio-Stream/issues",
"funding": "https://ko-fi.com/FrauJulian",
"keywords": [
"discordjs",
"discord",
"discord.js",
"audio",
"voice",
"streaming",
"discordjs/voice",
"music",
"voice",
"audio",
"24/7"
],
"contributors": [
"FrauJulian - Lechner Julian <fraujulian@lechner.top>"
"Julian Lechner - FrauJulian <fraujulian@lechner.top>"
],
"lint-staged": {
"*.{ts,tsx,js,cjs,mjs}": [
@@ -68,9 +73,8 @@
"@jest/globals": "^30.4.1",
"@types/ejs": "^3.1.5",
"@types/node": "^25.9.1",
"@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/parser": "^8.59.4",
"del-cli": "^7.0.0",
"@typescript-eslint/eslint-plugin": "^8.60.0",
"@typescript-eslint/parser": "^8.60.0",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
@@ -80,17 +84,16 @@
"eslint-plugin-unused-imports": "^4.4.1",
"husky": "^9.1.7",
"jest": "^30.4.2",
"npm-check-updates": "^22.2.0",
"prettier": "^3.8.3",
"ts-jest": "^29.4.11",
"tsup": "^8.5.1",
"typescript": "^6.0.3"
},
"peerDependencies": {
"@discordjs/voice": "^0.19.0",
"@snazzah/davey": "^0.1.8",
"ffmpeg-static": "^5.2.0",
"libsodium-wrappers": "^0.7.15",
"@discordjs/voice": "^0.19.2",
"@snazzah/davey": "^0.1.11",
"ffmpeg-static": "^5.3.0",
"libsodium-wrappers": "^0.8.4",
"opusscript": "^0.0.8",
"prism-media": "^1.3.5"
},
@@ -103,7 +106,7 @@
}
},
"engines": {
"node": ">=22.12.0"
"node": ">=22.22.3"
},
"private": false,
"publishConfig": {
+95 -13
View File
@@ -1,21 +1,103 @@
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8').toString());
const lockFilePath = 'package-lock.json';
const packageFilePath = 'package.json';
const placeholderVersion = 'x.x.x';
const command = process.argv[2];
const [major, minor] = pkg.version.split('.');
const prefix = `${major}.${minor}.`;
function readJson(path) {
return JSON.parse(fs.readFileSync(path, 'utf8').toString());
}
const now = new Date();
const start = new Date(now.getFullYear(), 0, 0);
const diff = now - start;
const dayOfYear = Math.floor(diff / (1000 * 60 * 60 * 24));
function writeJson(path, value, spaces) {
fs.writeFileSync(path, `${JSON.stringify(value, null, spaces)}\n`);
}
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
function getLockFile() {
if (!fs.existsSync(lockFilePath)) {
return null;
}
const newVersion = `${prefix}${String(dayOfYear).padStart(3, '0')}${hh}${mm}`;
return readJson(lockFilePath);
}
pkg.version = newVersion;
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
function writeVersions(version) {
const pkg = readJson(packageFilePath);
pkg.version = version;
writeJson(packageFilePath, pkg, 2);
console.log('Version set to: ', newVersion);
const lock = getLockFile();
if (lock) {
lock.version = version;
if (lock.packages?.['']) {
lock.packages[''].version = version;
}
writeJson(lockFilePath, lock, 4);
}
}
function checkPlaceholder() {
const pkg = readJson(packageFilePath);
const lock = getLockFile();
const errors = [];
if (pkg.version !== placeholderVersion) {
errors.push(`package.json version must be "${placeholderVersion}", got "${pkg.version}"`);
}
if (lock && lock.version !== placeholderVersion) {
errors.push(`package-lock.json version must be "${placeholderVersion}", got "${lock.version}"`);
}
if (lock?.packages?.[''] && lock.packages[''].version !== placeholderVersion) {
errors.push(
`package-lock.json packages[""] version must be "${placeholderVersion}", got "${lock.packages[''].version}"`,
);
}
if (errors.length > 0) {
for (const error of errors) {
console.error(error);
}
process.exit(1);
}
console.log(`Version placeholder validated: ${placeholderVersion}`);
}
function fixPlaceholder() {
writeVersions(placeholderVersion);
console.log(`Version placeholder set to: ${placeholderVersion}`);
}
function getCiVersion() {
const major = process.env.VERSION_MAJOR;
const minor = process.env.VERSION_MINOR;
const patch = process.env.VERSION_PATCH ?? process.env.GITHUB_RUN_NUMBER;
const segments = { major, minor, patch };
for (const [name, value] of Object.entries(segments)) {
if (!/^\d+$/.test(value ?? '')) {
console.error(`Missing or invalid ${name} version segment: "${value ?? ''}"`);
process.exit(1);
}
}
return `${major}.${minor}.${patch}`;
}
if (command === 'check-placeholder') {
checkPlaceholder();
} else if (command === 'fix-placeholder') {
fixPlaceholder();
} else if (command === 'set-ci-version') {
const ciVersion = getCiVersion();
writeVersions(ciVersion);
console.log(`CI version set to: ${ciVersion}`);
} else {
console.error('Invalid command. Use one of: check-placeholder, fix-placeholder, set-ci-version');
process.exit(1);
}
+141 -13
View File
@@ -1,5 +1,5 @@
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
import { sep } from 'node:path';
import { resolve } from 'node:path';
import { PassThrough } from 'node:stream';
import AudioManager from '../src/audio-manager';
@@ -59,11 +59,14 @@ const connectionOptions: VoiceConnectionOptions = {
adapterCreator: jest.fn() as unknown as VoiceConnectionOptions['adapterCreator'],
};
const source: AudioSource = {
const liveStreamSource: AudioSource = {
type: 'url',
url: 'https://example.com/audio.mp3',
url: 'https://synradiode.stream.laut.fm/synradiode',
};
const fileSourcePath = 'tests/audio.mp3';
const resolvedFileSourcePath = resolve(process.cwd(), fileSourcePath);
describe('AudioManager', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -82,7 +85,7 @@ describe('AudioManager', () => {
it('connects and starts playback with resolved URL source', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source,
source: liveStreamSource,
renewIntervalMs: false,
});
@@ -92,23 +95,91 @@ describe('AudioManager', () => {
expect(manager.isConnected).toBe(true);
expect(manager.isPlaying).toBe(true);
expect(mockConnection.subscribe).toHaveBeenCalledWith(mockAudioPlayer);
expect(startFfmpeg).toHaveBeenCalledWith('https://example.com/audio.mp3', undefined);
expect(startFfmpeg).toHaveBeenCalledWith('https://synradiode.stream.laut.fm/synradiode', undefined);
expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource);
});
it('resolves file sources against the current working directory', async () => {
it('starts playback with the committed mp3 test file', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: {
type: 'file',
path: 'audio/song.mp3',
path: fileSourcePath,
},
renewIntervalMs: false,
});
await manager.start();
expect(startFfmpeg).toHaveBeenCalledWith(expect.stringContaining(`audio${sep}song.mp3`), undefined);
expect(startFfmpeg).toHaveBeenCalledWith(resolvedFileSourcePath, undefined);
expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource);
});
it('accepts the live stream URL as a valid source', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.connect();
await manager.play();
expect(startFfmpeg).toHaveBeenCalledWith('https://synradiode.stream.laut.fm/synradiode', undefined);
});
it('plays a source passed directly to play()', async () => {
const manager = new AudioManager({
connection: connectionOptions,
renewIntervalMs: false,
});
await manager.connect();
await manager.play({
type: 'file',
path: fileSourcePath,
});
expect(startFfmpeg).toHaveBeenCalledWith(resolvedFileSourcePath, undefined);
expect(manager.state).toBe('playing');
});
it('replaces active playback when a new source is played', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
await manager.start();
await manager.play({
type: 'file',
path: fileSourcePath,
});
expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1);
expect(mockAudioResource.playStream.destroy).toHaveBeenCalledTimes(1);
expect(startFfmpeg).toHaveBeenLastCalledWith(resolvedFileSourcePath, undefined);
});
it('requires an active connection before play()', async () => {
const manager = new AudioManager({
source: liveStreamSource,
renewIntervalMs: false,
});
await expect(manager.play()).rejects.toThrow(AudioManagerStateError);
});
it('requires a source before play()', async () => {
const manager = new AudioManager({
connection: connectionOptions,
renewIntervalMs: false,
});
await manager.connect();
await expect(manager.play()).rejects.toThrow(AudioManagerConfigError);
});
it('rejects invalid URL sources', async () => {
@@ -129,7 +200,7 @@ describe('AudioManager', () => {
it('applies initial volume only when volume support is enabled', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source,
source: liveStreamSource,
renewIntervalMs: false,
volume: {
enabled: true,
@@ -148,10 +219,37 @@ describe('AudioManager', () => {
expect(() => manager.setVolume(50)).toThrow(AudioManagerStateError);
});
it('rejects invalid volume percentages', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
volume: {
enabled: true,
},
});
await manager.start();
expect(() => manager.setVolume(-1)).toThrow(AudioManagerConfigError);
expect(() => manager.setVolume(101)).toThrow(AudioManagerConfigError);
});
it('rejects volume changes before any resource exists', () => {
const manager = new AudioManager({
renewIntervalMs: false,
volume: {
enabled: true,
},
});
expect(() => manager.setVolume(50)).toThrow(AudioManagerStateError);
});
it('pauses and resumes only from valid playback states', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source,
source: liveStreamSource,
renewIntervalMs: false,
});
@@ -169,7 +267,7 @@ describe('AudioManager', () => {
it('stops playback and voice resources idempotently', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source,
source: liveStreamSource,
renewIntervalMs: false,
});
@@ -183,12 +281,29 @@ describe('AudioManager', () => {
expect(manager.state).toBe('stopped');
});
it('restarts playback when the renewal timer fires', async () => {
jest.useFakeTimers();
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: 10_000,
});
const startSpy = jest.spyOn(manager, 'start').mockResolvedValue(undefined);
await manager.connect();
jest.advanceTimersByTime(10_000);
await Promise.resolve();
expect(startSpy).toHaveBeenCalledTimes(1);
});
it('clears existing renewal timer before reconnecting', async () => {
jest.useFakeTimers();
const manager = new AudioManager({
connection: connectionOptions,
source,
source: liveStreamSource,
renewIntervalMs: 10_000,
});
@@ -200,10 +315,23 @@ describe('AudioManager', () => {
expect(jest.getTimerCount()).toBe(0);
});
it('blocks source and connection changes after disposal', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: liveStreamSource,
renewIntervalMs: false,
});
manager.dispose();
expect(() => manager.setSource(liveStreamSource)).toThrow(AudioManagerStateError);
expect(() => manager.setConnection(connectionOptions)).toThrow(AudioManagerStateError);
});
it('prevents use after disposal', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source,
source: liveStreamSource,
renewIntervalMs: false,
});
BIN
View File
Binary file not shown.
+139
View File
@@ -0,0 +1,139 @@
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();
});
});