matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
240 lines • 11.9 kB
JavaScript
import { EventEmitter } from 'node:events';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LocalNetworkClient } from '../../../roborockCommunication/local/localClient.js';
import { MessageContext } from '../../../roborockCommunication/models/messageContext.js';
import { V1ResponseBroadcaster } from '../../../roborockCommunication/routing/listeners/v1ResponseBroadcaster.js';
import { V1PendingResponseTracker } from '../../../roborockCommunication/routing/services/v1PendingResponseTracker.js';
import { asPartial, createMockLogger, mkUser } from '../../helpers/testUtils.js';
vi.mock('node:net', () => {
function Socket() {
return globalThis.mockSocketQueue?.shift();
}
return { Socket };
});
const DUID = 'device-001';
const IP = '192.168.1.100';
function makeMockSocket() {
return Object.assign(new EventEmitter(), {
connect: vi.fn(),
destroy: vi.fn(),
write: vi.fn(),
setTimeout: vi.fn(),
address: vi.fn().mockReturnValue({ address: IP, port: 58867 }),
readyState: 'open',
destroyed: false,
writable: true,
readable: true,
});
}
function createClient() {
const logger = createMockLogger();
const user = mkUser();
const context = new MessageContext(user);
context.registerDevice(DUID, 'local-key', 'L01', undefined);
const tracker = new V1PendingResponseTracker(logger);
const broadcaster = new V1ResponseBroadcaster(tracker, logger);
const client = new LocalNetworkClient(logger, context, DUID, IP, broadcaster, tracker);
Object.defineProperty(client, 'serializer', {
value: asPartial({ serialize: vi.fn().mockReturnValue({ buffer: Buffer.from([1, 2, 3]), messageId: 1 }) }),
writable: true,
});
return client;
}
describe('LocalNetworkClient – ping timeout triggers reconnect', () => {
let client;
beforeEach(() => {
vi.useFakeTimers();
globalThis.mockSocketQueue = [];
client = createClient();
});
afterEach(() => {
if (client['checkConnectionInterval']) {
clearInterval(client['checkConnectionInterval']);
client['checkConnectionInterval'] = undefined;
}
vi.clearAllTimers();
vi.useRealTimers();
vi.clearAllMocks();
});
async function simulateSuccessfulConnect(socket) {
client['helloResponseListener'].waitFor = vi.fn().mockResolvedValue({
header: { nonce: 9999, version: 'L01' },
});
client.connect();
socket.emit('connect');
await vi.advanceTimersByTimeAsync(0);
}
it('should trigger reconnect when no ping response received for 15s', async () => {
const socket = makeMockSocket();
globalThis.mockSocketQueue = [socket];
await simulateSuccessfulConnect(socket);
expect(client.isReady()).toBe(true);
const disconnectSpy = vi.spyOn(client, 'disconnect');
const connectSpy = vi.spyOn(client, 'connect');
// Pre-populate queue for the reconnect's connect() call
globalThis.mockSocketQueue = [makeMockSocket()];
// checkConnection fires every 5s; advance tick-by-tick so async guard resets between calls.
// At t=5000: 5000ms elapsed — no reconnect.
// At t=10000: 10000ms elapsed — no reconnect.
// At t=15001: >15000ms elapsed — reconnect triggered.
for (let tick = 0; tick < 4; tick++) {
await vi.advanceTimersByTimeAsync(5000);
}
expect(disconnectSpy).toHaveBeenCalledOnce();
expect(connectSpy).toHaveBeenCalled();
});
it('should update lastPingResponse when ping response is received after reconnect', async () => {
const socket = makeMockSocket();
globalThis.mockSocketQueue = [socket];
await simulateSuccessfulConnect(socket);
expect(client.isReady()).toBe(true);
// Pre-populate queue for the reconnect's connect() call
globalThis.mockSocketQueue = [makeMockSocket()];
await vi.advanceTimersByTimeAsync(20000);
// Reconnect with fresh client and socket
client = createClient();
const socket2 = makeMockSocket();
globalThis.mockSocketQueue = [socket2];
await simulateSuccessfulConnect(socket2);
expect(client.isReady()).toBe(true);
const before = client['pingResponseListener'].lastPingResponse;
await vi.advanceTimersByTimeAsync(100);
client['pingResponseListener'].lastPingResponse = Date.now();
expect(client['pingResponseListener'].lastPingResponse).toBeGreaterThan(before);
});
it('should not reconnect again when ping responses are received regularly', async () => {
const socket = makeMockSocket();
globalThis.mockSocketQueue = [socket];
await simulateSuccessfulConnect(socket);
expect(client.isReady()).toBe(true);
const disconnectSpy = vi.spyOn(client, 'disconnect');
// Deliver a fresh ping timestamp before each checkConnection call
for (let i = 0; i < 4; i++) {
client['pingResponseListener'].lastPingResponse = Date.now();
await vi.advanceTimersByTimeAsync(5000);
}
expect(disconnectSpy).not.toHaveBeenCalled();
});
it('should suppress stale close event from old socket when no new socket exists', async () => {
const socket = makeMockSocket();
globalThis.mockSocketQueue = [socket];
await simulateSuccessfulConnect(socket);
expect(client['connected']).toBe(true);
// Disconnect — socket reference captured in closure is no longer this.socket
await client.disconnect();
expect(client['connected']).toBe(false);
// Stale 'close' event: socket !== this.socket (undefined) → handler returns early
socket.emit('close', false);
await vi.advanceTimersByTimeAsync(0);
expect(client['connected']).toBe(false);
});
describe('stale socket race condition (closure-based guard)', () => {
it('should not destroy new socket when old socket fires stale close event after reconnect', async () => {
const socketA = makeMockSocket();
const socketB = makeMockSocket();
globalThis.mockSocketQueue = [socketA, socketB];
await simulateSuccessfulConnect(socketA);
expect(client.isReady()).toBe(true);
// disconnect() destroys socketA, connect() creates socketB
await client.disconnect();
client.connect();
// socketA fires stale 'close' (as happens async in real Node.js after destroy())
// handler checks: this.socket (socketB) !== socket (socketA) → returns early
socketA.emit('close', false);
await vi.advanceTimersByTimeAsync(0);
expect(socketB.destroy).not.toHaveBeenCalled();
});
it('should not destroy new socket when old socket fires stale error event after reconnect', async () => {
const socketA = makeMockSocket();
const socketB = makeMockSocket();
globalThis.mockSocketQueue = [socketA, socketB];
await simulateSuccessfulConnect(socketA);
expect(client.isReady()).toBe(true);
await client.disconnect();
client.connect();
// socketA fires stale 'error'
socketA.emit('error', new Error('stale network error'));
await vi.advanceTimersByTimeAsync(0);
expect(socketB.destroy).not.toHaveBeenCalled();
});
it('should process close event from the current socket normally', async () => {
const socket = makeMockSocket();
globalThis.mockSocketQueue = [socket];
await simulateSuccessfulConnect(socket);
expect(client['connected']).toBe(true);
// 'close' from the current socket → this.socket === socket → processed
socket.emit('close', false);
await vi.advanceTimersByTimeAsync(0);
expect(client['connected']).toBe(false);
});
it('isReconnecting should return true after disconnect and false after connect', async () => {
const socket = makeMockSocket();
globalThis.mockSocketQueue = [socket];
await simulateSuccessfulConnect(socket);
expect(client.isReconnecting()).toBe(false);
await client.disconnect();
expect(client.isReconnecting()).toBe(true);
globalThis.mockSocketQueue = [makeMockSocket()];
client.connect();
expect(client.isReconnecting()).toBe(false);
});
it('should suppress onDisconnect when intentionalDisconnect is true', async () => {
const socket = makeMockSocket();
globalThis.mockSocketQueue = [socket];
await simulateSuccessfulConnect(socket);
await client.disconnect(); // sets intentionalDisconnect = true
// Emit close from the disconnected socket — handler checks intentionalDisconnect guard
socket.emit('close', false);
await vi.advanceTimersByTimeAsync(0);
// No onDisconnected broadcast expected — intentionalDisconnect suppresses it
expect(client['connected']).toBe(false);
});
it('should suppress onError when intentionalDisconnect is true', async () => {
const socket = makeMockSocket();
globalThis.mockSocketQueue = [socket];
await simulateSuccessfulConnect(socket);
await client.disconnect(); // sets intentionalDisconnect = true
socket.emit('error', new Error('intentional suppressed error'));
await vi.advanceTimersByTimeAsync(0);
expect(client['connected']).toBe(false);
});
it('onTimeout should log error', async () => {
const socket = makeMockSocket();
globalThis.mockSocketQueue = [socket];
const logger = client['logger'];
await simulateSuccessfulConnect(socket);
socket.emit('timeout');
await vi.advanceTimersByTimeAsync(0);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('timed out'));
});
it('onEnd should log notice', async () => {
const socket = makeMockSocket();
globalThis.mockSocketQueue = [socket];
const logger = client['logger'];
await simulateSuccessfulConnect(socket);
socket.emit('end');
await vi.advanceTimersByTimeAsync(0);
expect(logger.notice).toHaveBeenCalledWith(expect.stringContaining('socket ended'));
});
it('should not destroy new socket when ping-timeout reconnect fires old socket close event late', async () => {
const socketA = makeMockSocket();
const socketB = makeMockSocket();
globalThis.mockSocketQueue = [socketA, socketB];
await simulateSuccessfulConnect(socketA);
expect(client.isReady()).toBe(true);
// Pre-populate hello mock for socketB reconnect
client['helloResponseListener'].waitFor = vi.fn().mockResolvedValue({
header: { nonce: 9999, version: 'L01' },
});
// Advance past 15s ping timeout — checkConnection calls disconnect() + connect()
for (let tick = 0; tick < 4; tick++) {
await vi.advanceTimersByTimeAsync(5000);
}
// socketA fires its 'close' event late (after connect() has set this.socket = socketB)
socketA.emit('close', false);
await vi.advanceTimersByTimeAsync(0);
expect(socketB.destroy).not.toHaveBeenCalled();
});
});
});
//# sourceMappingURL=localNetworkClient.reconnect.test.js.map