Files
DeskClaw/tests/unit/gateway-manager-restart-recovery.test.ts

123 lines
3.9 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp',
isPackaged: false,
},
utilityProcess: {
fork: vi.fn(),
},
}));
vi.mock('@electron/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
describe('GatewayManager restart recovery', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-08T00:00:00.000Z'));
});
it('re-enables auto-reconnect when start() fails during restart', async () => {
const { GatewayManager } = await import('@electron/gateway/manager');
const manager = new GatewayManager();
// Expose private members for testing
const internals = manager as unknown as {
shouldReconnect: boolean;
status: { state: string; port: number };
startLock: boolean;
reconnectTimer: NodeJS.Timeout | null;
restartInFlight: Promise<void> | null;
scheduleReconnect: () => void;
stop: () => Promise<void>;
start: () => Promise<void>;
};
// Set the manager into a state where restart can proceed:
// - state must not be 'starting' or 'reconnecting' (would defer restart)
// - startLock must be false
internals.status = { state: 'running', port: 18789 };
internals.startLock = false;
internals.shouldReconnect = true;
// Mock stop to just reset flags (simulates normal stop)
vi.spyOn(manager, 'stop').mockImplementation(async () => {
internals.shouldReconnect = false;
internals.status = { state: 'stopped', port: 18789 };
});
// Mock start to fail (simulates the race condition where gateway
// is reachable but not attachable after in-process restart)
vi.spyOn(manager, 'start').mockRejectedValue(
new Error('WebSocket closed before handshake: unknown'),
);
// Spy on scheduleReconnect
const scheduleReconnectSpy = vi.spyOn(
internals as unknown as { scheduleReconnect: () => void },
'scheduleReconnect',
);
// Perform the restart - it should throw because start() fails
await expect(manager.restart()).rejects.toThrow(
'WebSocket closed before handshake: unknown',
);
// KEY ASSERTION: After start() fails in restart(), shouldReconnect
// must be re-enabled so the gateway can self-heal
expect(internals.shouldReconnect).toBe(true);
expect(scheduleReconnectSpy).toHaveBeenCalled();
});
it('does not schedule extra reconnect when restart succeeds', async () => {
const { GatewayManager } = await import('@electron/gateway/manager');
const manager = new GatewayManager();
const internals = manager as unknown as {
shouldReconnect: boolean;
status: { state: string; port: number };
startLock: boolean;
reconnectTimer: NodeJS.Timeout | null;
restartInFlight: Promise<void> | null;
scheduleReconnect: () => void;
};
internals.status = { state: 'running', port: 18789 };
internals.startLock = false;
internals.shouldReconnect = true;
// Mock stop to reset flags
vi.spyOn(manager, 'stop').mockImplementation(async () => {
internals.shouldReconnect = false;
internals.status = { state: 'stopped', port: 18789 };
});
// Mock start to succeed
vi.spyOn(manager, 'start').mockImplementation(async () => {
internals.shouldReconnect = true;
internals.status = { state: 'running', port: 18789 };
});
const scheduleReconnectSpy = vi.spyOn(
internals as unknown as { scheduleReconnect: () => void },
'scheduleReconnect',
);
await manager.restart();
// scheduleReconnect should NOT have been called by the catch block
// (it may be called from other paths, but not the restart-recovery catch)
expect(scheduleReconnectSpy).not.toHaveBeenCalled();
});
});