Files
wehub-resource-sync 0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

742 lines
27 KiB
TypeScript

import { EventEmitter } from 'node:events';
import http from 'node:http';
import { Server as SocketIOServer } from 'socket.io';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock dependencies before importing the module
vi.mock('../../src/migrate', () => ({
runDbMigrations: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../src/database/signal', async () => {
const actual = await vi.importActual('../../src/database/signal');
return {
// Keep the real (pure) signal classifiers so the watcher's emit decisions are exercised.
...actual,
readSignalFile: vi.fn().mockReturnValue({ type: 'update' }),
setupSignalWatcher: vi.fn().mockReturnValue({
close: vi.fn(),
on: vi.fn(),
}),
};
});
vi.mock('../../src/models/evalMutation', () => ({
invalidateEvaluationCache: vi.fn(),
invalidateEvaluationCaches: vi.fn(),
}));
vi.mock('../../src/util/server', () => ({
BrowserBehavior: { OPEN: 0, SKIP: 1, ASK: 2 },
BrowserBehaviorNames: { 0: 'OPEN', 1: 'SKIP', 2: 'ASK' },
openBrowser: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../src/logger', () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock('../../src/models/eval', () => ({
default: {
findById: vi.fn().mockResolvedValue(null),
latest: vi.fn().mockResolvedValue(null),
},
getEvalSummaries: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../src/server/services/promptCacheService', () => ({
promptCacheService: {
getAll: vi.fn().mockResolvedValue([]),
invalidate: vi.fn(),
},
}));
import { readSignalFile, setupSignalWatcher } from '../../src/database/signal';
import logger from '../../src/logger';
import Eval from '../../src/models/eval';
import {
invalidateEvaluationCache,
invalidateEvaluationCaches,
} from '../../src/models/evalMutation';
// Import after mocks are set up
import { ServerError } from '../../src/server/errors';
import { handleServerError, startServer } from '../../src/server/server';
import { promptCacheService } from '../../src/server/services/promptCacheService';
describe('server', () => {
describe('handleServerError', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should log specific message for EADDRINUSE error', () => {
const error = new Error('Address in use') as NodeJS.ErrnoException;
error.code = 'EADDRINUSE';
const startupError = handleServerError(error, 3000);
expect(logger.error).toHaveBeenCalledWith(
'Port 3000 is already in use. Do you have another Promptfoo instance running?',
);
expect(startupError).toBeInstanceOf(ServerError);
expect(startupError).toMatchObject({
code: 'EADDRINUSE',
phase: 'startup',
message: 'Port 3000 is already in use. Do you have another Promptfoo instance running?',
port: 3000,
});
});
it('should log generic message for other errors', () => {
const error = new Error('Some other error') as NodeJS.ErrnoException;
error.code = 'ENOENT';
const startupError = handleServerError(error, 3000);
expect(logger.error).toHaveBeenCalledWith('Failed to start server: Some other error');
expect(startupError).toBeInstanceOf(ServerError);
expect(startupError).toMatchObject({
code: 'ENOENT',
phase: 'startup',
message: 'Failed to start server: Some other error',
port: 3000,
});
});
});
describe('startServer shutdown behavior', () => {
type MockHttpServer = EventEmitter & {
listen: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
listening: boolean;
};
let mockHttpServer: MockHttpServer;
let originalCreateServer: typeof http.createServer;
let signalHandlers: Map<string | symbol, ((...args: unknown[]) => void)[]>;
beforeEach(() => {
vi.clearAllMocks();
// Track signal handlers using a Map to support multiple handlers per event
signalHandlers = new Map();
vi.spyOn(process, 'once').mockImplementation(
(event: string | symbol, handler: (...args: unknown[]) => void) => {
if (event === 'SIGINT' || event === 'SIGTERM') {
if (!signalHandlers.has(event)) {
signalHandlers.set(event, []);
}
signalHandlers.get(event)!.push(handler);
}
return process;
},
);
// Create mock HTTP server
mockHttpServer = Object.assign(new EventEmitter(), {
listening: false,
listen: vi.fn().mockImplementation(function (
this: MockHttpServer,
_port: number,
callback: () => void,
) {
// Simulate async listen - server is now listening
this.listening = true;
setImmediate(() => callback());
return this;
}),
close: vi.fn().mockImplementation(function (
this: MockHttpServer,
callback?: (err?: Error) => void,
) {
this.listening = false;
setImmediate(() => callback?.());
}),
});
// Mock http.createServer
originalCreateServer = http.createServer;
(http.createServer as unknown) = vi.fn().mockReturnValue(mockHttpServer);
});
afterEach(() => {
http.createServer = originalCreateServer;
vi.restoreAllMocks();
});
const triggerSignal = (signal: 'SIGINT' | 'SIGTERM') => {
const handlers = signalHandlers.get(signal);
if (handlers) {
for (const handler of handlers) {
handler();
}
}
};
it('should register SIGINT and SIGTERM handlers', async () => {
// Start server and immediately trigger shutdown
const serverPromise = startServer(0);
// Wait for server to start
await new Promise((resolve) => setImmediate(resolve));
expect(signalHandlers.has('SIGINT')).toBe(true);
expect(signalHandlers.has('SIGTERM')).toBe(true);
// Trigger shutdown to complete the promise
triggerSignal('SIGINT');
await serverPromise;
});
it('should reject startup errors without exiting the process', async () => {
const startupError = new Error('Address in use') as NodeJS.ErrnoException;
startupError.code = 'EADDRINUSE';
const mockWatcher = { close: vi.fn(), on: vi.fn() };
vi.mocked(setupSignalWatcher).mockReturnValueOnce(mockWatcher as never);
mockHttpServer.listen = vi.fn().mockImplementation(function (this: MockHttpServer) {
setImmediate(() => this.emit('error', startupError));
return this;
});
await expect(startServer(3000)).rejects.toMatchObject({
name: 'ServerError',
code: 'EADDRINUSE',
phase: 'startup',
message: 'Port 3000 is already in use. Do you have another Promptfoo instance running?',
port: 3000,
});
expect(mockWatcher.close).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
'Port 3000 is already in use. Do you have another Promptfoo instance running?',
);
});
it('should close the live server before rejecting runtime server errors', async () => {
const runtimeError = new Error('Unexpected runtime failure') as NodeJS.ErrnoException;
const mockWatcher = { close: vi.fn(), on: vi.fn() };
vi.mocked(setupSignalWatcher).mockReturnValueOnce(mockWatcher as never);
const serverPromise = startServer(0);
await vi.waitFor(() =>
expect(logger.info).toHaveBeenCalledWith(
'Server running at http://localhost:0 and monitoring for new evals.',
),
);
mockHttpServer.emit('error', runtimeError);
await expect(serverPromise).rejects.toMatchObject({
name: 'ServerError',
phase: 'runtime',
message: 'Server error: Unexpected runtime failure',
port: 0,
});
expect(mockWatcher.close).toHaveBeenCalled();
expect(mockHttpServer.close).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith('Server error: Unexpected runtime failure');
});
it('should close file watcher on shutdown', async () => {
const mockWatcher = { close: vi.fn(), on: vi.fn() };
vi.mocked(setupSignalWatcher).mockReturnValueOnce(mockWatcher as never);
const serverPromise = startServer(0);
// Wait for server to start
await new Promise((resolve) => setImmediate(resolve));
// Trigger SIGINT
triggerSignal('SIGINT');
await serverPromise;
expect(mockWatcher.close).toHaveBeenCalled();
});
it('should invalidate process-local evaluation caches when the signal file changes', async () => {
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
// The default unscoped update can touch any eval's cached count, so it clears all of them
// (no-arg call) rather than scoping the clear to a single id.
expect(invalidateEvaluationCache).toHaveBeenCalledWith();
expect(invalidateEvaluationCaches).not.toHaveBeenCalled();
await new Promise((resolve) => setImmediate(resolve));
triggerSignal('SIGINT');
await serverPromise;
});
it('should invalidate the prompts cache on every signal', async () => {
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() => expect(promptCacheService.invalidate).toHaveBeenCalledOnce());
triggerSignal('SIGINT');
await serverPromise;
});
it('should invalidate the prompts cache even when the signal emits no eval update', async () => {
// Regression: a signal whose eval has no surviving record (here a scoped update for a
// missing eval) emits nothing, but cached `/api/prompts` output must still be dropped so a
// later request re-reads the DB. Invalidation must not be gated on a non-empty result/emit.
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'update', evalId: 'missing-eval' });
vi.mocked(Eval.findById).mockResolvedValueOnce(undefined as never);
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() => expect(promptCacheService.invalidate).toHaveBeenCalledOnce());
await new Promise((resolve) => setImmediate(resolve));
expect(emitSpy).not.toHaveBeenCalledWith('update', expect.anything());
triggerSignal('SIGINT');
await serverPromise;
});
it('should invalidate the prompts cache for a delete signal', async () => {
// A delete takes a different branch than an update; the prompt cache must still be dropped.
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'delete', deletedEvalIds: ['gone'] });
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() => expect(promptCacheService.invalidate).toHaveBeenCalledOnce());
triggerSignal('SIGINT');
await serverPromise;
});
it('should invalidate the prompts cache for a delete-all signal', async () => {
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'delete' });
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() => expect(promptCacheService.invalidate).toHaveBeenCalledOnce());
triggerSignal('SIGINT');
await serverPromise;
});
it('should scope process-local evaluation cache invalidation to the signaled eval', async () => {
vi.mocked(readSignalFile).mockReturnValueOnce({
type: 'update',
evalId: 'eval-12345-abcdef',
});
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
expect(invalidateEvaluationCaches).toHaveBeenCalledWith(['eval-12345-abcdef']);
await new Promise((resolve) => setImmediate(resolve));
triggerSignal('SIGINT');
await serverPromise;
});
it('should emit scoped updates for legacy evals without normalized result rows', async () => {
const getResultsCount = vi.fn().mockResolvedValue(0);
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'update', evalId: 'legacy-eval' });
vi.mocked(Eval.findById).mockResolvedValueOnce({
id: 'legacy-eval',
config: {},
getResultsCount,
} as never);
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() =>
expect(logger.debug).toHaveBeenCalledWith('Emitting update for eval: legacy-eval'),
);
expect(getResultsCount).not.toHaveBeenCalled();
triggerSignal('SIGINT');
await serverPromise;
});
it('should emit an empty update when no evals remain after a mutation', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith('update', null));
triggerSignal('SIGINT');
await serverPromise;
});
it('should ignore scoped updates for evals that were not persisted', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'update', evalId: 'missing-eval' });
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await new Promise((resolve) => setImmediate(resolve));
expect(emitSpy).not.toHaveBeenCalledWith('update', null);
expect(emitSpy).not.toHaveBeenCalledWith('update', { evalId: 'missing-eval' });
triggerSignal('SIGINT');
await serverPromise;
});
it('should broadcast the latest eval for an unscoped mutation', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
vi.mocked(Eval.latest).mockResolvedValueOnce({ id: 'surviving-eval' } as never);
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() =>
expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'surviving-eval' }),
);
triggerSignal('SIGINT');
await serverPromise;
});
it('emits the latest eval for an unscoped refresh coalesced with a scoped update', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
// A bare unscoped update coalesced with a scoped one (refreshLatest marker): the pinned eval
// refreshes AND the root view still follows the latest eval.
vi.mocked(readSignalFile).mockReturnValueOnce({
type: 'update',
evalId: 'eval-scoped',
updatedEvalIds: ['eval-scoped'],
refreshLatest: true,
});
vi.mocked(Eval.findById).mockResolvedValueOnce({ id: 'eval-scoped', config: {} } as never);
vi.mocked(Eval.latest).mockResolvedValueOnce({ id: 'eval-latest', config: {} } as never);
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() =>
expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'eval-scoped' }),
);
await vi.waitFor(() =>
expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'eval-latest' }),
);
// An unscoped refresh clears all count caches (the latest eval id is not known at signal time).
expect(invalidateEvaluationCache).toHaveBeenCalledWith();
triggerSignal('SIGINT');
await serverPromise;
});
it('should broadcast deleted eval IDs without selecting a surviving eval', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
vi.mocked(readSignalFile).mockReturnValueOnce({
type: 'delete',
deletedEvalIds: ['deleted-eval'],
});
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() =>
expect(emitSpy).toHaveBeenCalledWith('update', { deletedEvalIds: ['deleted-eval'] }),
);
// The deleted eval's count cache must be cleared so a re-import can't reuse a stale count.
expect(invalidateEvaluationCaches).toHaveBeenCalledWith(['deleted-eval']);
expect(invalidateEvaluationCache).not.toHaveBeenCalled();
triggerSignal('SIGINT');
await serverPromise;
});
it('should broadcast an empty id list when a delete signal omits ids (delete-all)', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
// delete-all writes `{type:'delete'}` with no deletedEvalIds; the watcher should still
// broadcast a delete with an empty list so clients clear/reload.
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'delete' });
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() =>
expect(emitSpy).toHaveBeenCalledWith('update', { deletedEvalIds: [] }),
);
// A delete-all can invalidate any eval's count, so it clears every cache (no-arg call).
expect(invalidateEvaluationCache).toHaveBeenCalledWith();
expect(invalidateEvaluationCaches).not.toHaveBeenCalled();
triggerSignal('SIGINT');
await serverPromise;
});
it('should emit both a delete and an update for a coalesced delete+update signal', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
// A delete that folded in a pending scoped update (two mutations coalesced in the window)
// must broadcast the removed eval AND refresh clients to the surviving update.
vi.mocked(readSignalFile).mockReturnValueOnce({
type: 'delete',
deletedEvalIds: ['deleted-eval'],
evalId: 'surviving-eval',
});
vi.mocked(Eval.findById).mockResolvedValueOnce({
id: 'surviving-eval',
config: {},
} as never);
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() =>
expect(emitSpy).toHaveBeenCalledWith('update', { deletedEvalIds: ['deleted-eval'] }),
);
await vi.waitFor(() =>
expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'surviving-eval' }),
);
// Both the surviving update AND the deleted eval are invalidated — the delete component of a
// coalesced signal must not leave the removed eval's count cache stale.
expect(invalidateEvaluationCaches).toHaveBeenCalledWith(['surviving-eval', 'deleted-eval']);
triggerSignal('SIGINT');
await serverPromise;
});
it('emits an update per eval for a coalesced multi-update signal', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
// Two back-to-back scoped updates coalesced in the window must each refresh their clients.
vi.mocked(readSignalFile).mockReturnValueOnce({
type: 'update',
evalId: 'eval-Z',
updatedEvalIds: ['eval-Y', 'eval-Z'],
});
// The watcher resolves the ids in order; mockResolvedValueOnce avoids leaking a persistent
// implementation into other (randomly-ordered) tests.
vi.mocked(Eval.findById)
.mockResolvedValueOnce({ id: 'eval-Y', config: {} } as never)
.mockResolvedValueOnce({ id: 'eval-Z', config: {} } as never);
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'eval-Y' }));
await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'eval-Z' }));
// Every coalesced eval's process-local caches are invalidated, not just the latest.
expect(invalidateEvaluationCaches).toHaveBeenCalledWith(['eval-Y', 'eval-Z']);
triggerSignal('SIGINT');
await serverPromise;
});
it('does not resurrect a deleted eval when a coalesced delete carries its own id', async () => {
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
// Pathological coalescing: the carried update id is also in deletedEvalIds. The delete
// must broadcast, but the update component must NOT emit (findById -> null) — and it must
// not fall back to broadcasting the latest eval, since the signal was scoped.
vi.mocked(readSignalFile).mockReturnValueOnce({
type: 'delete',
deletedEvalIds: ['doomed-eval'],
evalId: 'doomed-eval',
});
vi.mocked(Eval.findById).mockResolvedValueOnce(undefined as never);
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() =>
expect(emitSpy).toHaveBeenCalledWith('update', { deletedEvalIds: ['doomed-eval'] }),
);
// Give the async update branch a chance to (not) emit.
await new Promise((resolve) => setImmediate(resolve));
expect(emitSpy).not.toHaveBeenCalledWith('update', { evalId: 'doomed-eval' });
expect(emitSpy).not.toHaveBeenCalledWith('update', null);
triggerSignal('SIGINT');
await serverPromise;
});
it('keeps the server alive when a signal-triggered eval lookup rejects', async () => {
// The watcher callback is fire-and-forget; a rejected DB lookup must be caught and logged
// rather than becoming an unhandledRejection that tears down the long-running view server.
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'update', evalId: 'boom-eval' });
vi.mocked(Eval.findById).mockRejectedValueOnce(new Error('libsql exploded'));
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
onSignalChange();
await vi.waitFor(() =>
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Failed to handle eval signal update'),
),
);
// The server is still running and shuts down cleanly.
triggerSignal('SIGINT');
await expect(serverPromise).resolves.toBeUndefined();
});
it('should not deadlock the shutdown promise if watcher.close throws', async () => {
const mockWatcher = {
close: vi.fn().mockImplementation(() => {
throw new Error('watcher already closed');
}),
on: vi.fn(),
};
vi.mocked(setupSignalWatcher).mockReturnValueOnce(mockWatcher as never);
const serverPromise = startServer(0);
await new Promise((resolve) => setImmediate(resolve));
triggerSignal('SIGINT');
await expect(serverPromise).resolves.toBeUndefined();
expect(mockWatcher.close).toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Error closing file watcher'),
);
});
it('should not deadlock startup-error rejection if watcher.close throws', async () => {
const startupError = new Error('Address in use') as NodeJS.ErrnoException;
startupError.code = 'EADDRINUSE';
const mockWatcher = {
close: vi.fn().mockImplementation(() => {
throw new Error('watcher unhappy');
}),
on: vi.fn(),
};
vi.mocked(setupSignalWatcher).mockReturnValueOnce(mockWatcher as never);
mockHttpServer.listen = vi.fn().mockImplementation(function (this: MockHttpServer) {
setImmediate(() => this.emit('error', startupError));
return this;
});
await expect(startServer(3000)).rejects.toMatchObject({
name: 'ServerError',
phase: 'startup',
});
expect(mockWatcher.close).toHaveBeenCalled();
});
it('should log server closure', async () => {
const serverPromise = startServer(0);
// Wait for server to start
await new Promise((resolve) => setImmediate(resolve));
// Trigger SIGINT
triggerSignal('SIGINT');
await serverPromise;
expect(logger.info).toHaveBeenCalledWith('Shutting down server...');
expect(logger.info).toHaveBeenCalledWith('Server closed');
});
it('should handle httpServer.close error gracefully', async () => {
// Mock close to call callback with error
mockHttpServer.close = vi.fn().mockImplementation(function (
callback?: (err?: Error) => void,
) {
setImmediate(() => callback?.(new Error('Close failed')));
});
const serverPromise = startServer(0);
// Wait for server to start
await new Promise((resolve) => setImmediate(resolve));
// Trigger SIGINT
triggerSignal('SIGINT');
await serverPromise;
expect(logger.warn).toHaveBeenCalledWith('Error closing server: Close failed');
expect(logger.info).toHaveBeenCalledWith('Server closed');
});
// Note: Testing the case where socket.io already closed the HTTP server would require
// mocking socket.io at the module level. The fix (checking httpServer.listening) handles
// this case in production where io.close() closes the underlying server.
//
// Note: Testing the 5-second force shutdown timeout requires mocking socket.io
// at the module level, which is complex. The timeout exists as a safety measure
// and the core shutdown logic is tested by the other tests.
it('should handle SIGTERM the same as SIGINT', async () => {
const serverPromise = startServer(0);
// Wait for server to start
await new Promise((resolve) => setImmediate(resolve));
// Trigger SIGTERM instead of SIGINT
triggerSignal('SIGTERM');
await serverPromise;
expect(logger.info).toHaveBeenCalledWith('Shutting down server...');
expect(logger.info).toHaveBeenCalledWith('Server closed');
});
});
});