Files
ccs/tests/unit/utils/signal-forwarder.test.ts
T

155 lines
5.2 KiB
TypeScript

import { describe, it, expect, jest } from 'bun:test';
import { EventEmitter } from 'events';
import type { ChildProcess } from 'child_process';
import { forwardSignals, wireChildProcessSignals } from '../../../src/utils/signal-forwarder';
type MockChildProcess = EventEmitter & {
killed: boolean;
kill: ChildProcess['kill'];
};
function createMockChildProcess(): ChildProcess {
const child = new EventEmitter() as MockChildProcess;
child.killed = false;
child.kill = jest.fn(() => true) as ChildProcess['kill'];
return child as ChildProcess;
}
function getSignalListenerCounts(): Record<'SIGINT' | 'SIGTERM' | 'SIGHUP', number> {
return {
SIGINT: process.listenerCount('SIGINT'),
SIGTERM: process.listenerCount('SIGTERM'),
SIGHUP: process.listenerCount('SIGHUP'),
};
}
describe('signal-forwarder', () => {
it('forwardSignals should register and cleanup listeners', () => {
const child = createMockChildProcess();
const before = getSignalListenerCounts();
const cleanup = forwardSignals(child);
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT + 1);
expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM + 1);
expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP + 1);
cleanup();
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT);
expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM);
expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP);
});
it('wireChildProcessSignals should use default exit behavior for exit code', () => {
const child = createMockChildProcess();
const before = getSignalListenerCounts();
const exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined as never) as typeof process.exit);
const killSpy = jest
.spyOn(process, 'kill')
.mockImplementation((() => true) as typeof process.kill);
try {
wireChildProcessSignals(child, () => {});
child.emit('exit', 7, null);
expect(exitSpy).toHaveBeenCalledWith(7);
expect(killSpy).not.toHaveBeenCalled();
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT);
expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM);
expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP);
} finally {
exitSpy.mockRestore();
killSpy.mockRestore();
}
});
it('wireChildProcessSignals should use default exit behavior for signal', () => {
const child = createMockChildProcess();
const before = getSignalListenerCounts();
const exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined as never) as typeof process.exit);
const killSpy = jest
.spyOn(process, 'kill')
.mockImplementation((() => true) as typeof process.kill);
try {
wireChildProcessSignals(child, () => {});
child.emit('exit', null, 'SIGTERM');
expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGTERM');
expect(exitSpy).not.toHaveBeenCalled();
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT);
expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM);
expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP);
} finally {
exitSpy.mockRestore();
killSpy.mockRestore();
}
});
it('wireChildProcessSignals should invoke onError and cleanup listeners', async () => {
const child = createMockChildProcess();
const before = getSignalListenerCounts();
const onError = jest.fn(async () => {});
const err = Object.assign(new Error('spawn failed'), {
code: 'ENOENT',
}) as NodeJS.ErrnoException;
wireChildProcessSignals(child, onError);
child.emit('error', err);
await Promise.resolve();
expect(onError).toHaveBeenCalledWith(err);
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT);
expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM);
expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP);
});
it('wireChildProcessSignals should run only one terminal callback when error is followed by exit', async () => {
const child = createMockChildProcess();
const onError = jest.fn(async () => {});
const onExit = jest.fn();
const err = Object.assign(new Error('spawn failed'), {
code: 'ENOENT',
}) as NodeJS.ErrnoException;
wireChildProcessSignals(child, onError, onExit);
child.emit('error', err);
child.emit('exit', 1, null);
await Promise.resolve();
expect(onError).toHaveBeenCalledTimes(1);
expect(onExit).not.toHaveBeenCalled();
});
it('wireChildProcessSignals should exit with code 1 when onError throws', async () => {
const child = createMockChildProcess();
const onError = jest.fn(async () => {
throw new Error('handler exploded');
});
const exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined as never) as typeof process.exit);
try {
const err = Object.assign(new Error('spawn failed'), {
code: 'ENOENT',
}) as NodeJS.ErrnoException;
wireChildProcessSignals(child, onError);
child.emit('error', err);
await Promise.resolve();
expect(onError).toHaveBeenCalledTimes(1);
expect(exitSpy).toHaveBeenCalledWith(1);
} finally {
exitSpy.mockRestore();
}
});
});