snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
import WS from 'jest-websocket-mock';
import { handleConnection, handleMethod } from './helpers';
import { DDPSDK } from '../src/DDPSDK';
let server: WS;
beforeEach(async () => {
server = new WS('ws://localhost:1234/websocket');
});
afterEach(() => {
server.close();
WS.clean();
});
describe('login', () => {
it('should save credentials to user object - loginWithToken', async () => {
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const messageResult = {
id: 123,
token: 'token',
tokenExpires: { $date: 99999999 },
};
await handleMethod(server, 'login', [{ resume: 'token' }], JSON.stringify(messageResult), sdk.account.loginWithToken('token'));
const { user } = sdk.account;
expect(user?.token).toBe(messageResult.token);
expect((user?.tokenExpires as Date)?.toISOString()).toBe(new Date(messageResult.tokenExpires.$date).toISOString());
expect(user?.id).toBe(messageResult.id);
});
it('should save credentials to user object - loginWithPassword', async () => {
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const messageResult = {
id: 123,
token: 'token',
tokenExpires: { $date: 99999999 },
};
await handleMethod(
server,
'login',
[{ user: { username: 'username' }, password: { digest: 'password', algorithm: 'sha-256' } }],
JSON.stringify(messageResult),
sdk.account.loginWithPassword('username', 'password'),
);
const { user } = sdk.account;
expect(user?.token).toBe(messageResult.token);
expect((user?.tokenExpires as Date)?.toISOString()).toBe(new Date(messageResult.tokenExpires.$date).toISOString());
expect(user?.id).toBe(messageResult.id);
});
it('should logout', async () => {
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const loginResult = {
id: '123',
token: 'token',
tokenExpires: { $date: 99999999 },
};
await handleMethod(server, 'login', [{ resume: 'token' }], JSON.stringify(loginResult), sdk.account.loginWithToken('token'));
const cb = jest.fn();
sdk.account.on('uid', cb);
await handleMethod(server, 'logout', [], '{}', sdk.account.logout());
// uid setter only fires on transitions, so logging out from a logged-in
// state emits exactly once (string → undefined).
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith(undefined);
const { user } = sdk.account;
expect(user).toBeUndefined();
});
it('should fire onLogin / onLogout on uid transitions only', async () => {
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const onLogin = jest.fn();
const onLogout = jest.fn();
sdk.account.onLogin(onLogin);
sdk.account.onLogout(onLogout);
const loginResult = {
id: '123',
token: 'token',
tokenExpires: { $date: 99999999 },
};
await handleMethod(server, 'login', [{ resume: 'token' }], JSON.stringify(loginResult), sdk.account.loginWithToken('token'));
expect(onLogin).toHaveBeenCalledTimes(1);
expect(onLogout).not.toHaveBeenCalled();
await handleMethod(server, 'logout', [], '{}', sdk.account.logout());
expect(onLogin).toHaveBeenCalledTimes(1);
expect(onLogout).toHaveBeenCalledTimes(1);
});
it('should fan out emailVerificationLink and pageLoadLogin events', () => {
const sdk = DDPSDK.create('ws://localhost:1234');
const verify = jest.fn();
const pageLoad = jest.fn();
const stopVerify = sdk.account.onEmailVerificationLink(verify);
sdk.account.onPageLoadLogin(pageLoad);
sdk.account.emit('emailVerificationLink', 'tok-1');
sdk.account.emit('pageLoadLogin', { error: 'totp-required' });
expect(verify).toHaveBeenCalledWith('tok-1');
expect(pageLoad).toHaveBeenCalledWith({ error: 'totp-required' });
stopVerify();
sdk.account.emit('emailVerificationLink', 'tok-2');
expect(verify).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,275 @@
import { ClientStreamImpl } from '../src/ClientStream';
import { DDPDispatcher } from '../src/DDPDispatcher';
import { MinimalDDPClient } from '../src/MinimalDDPClient';
describe('call procedures', () => {
it('should be able to call a method and receive a result', async () => {
const callback = jest.fn();
const ws = new DDPDispatcher();
const client = new ClientStreamImpl(ws);
const id = client.call('test', callback);
ws.handleMessage(
JSON.stringify({
msg: 'result',
result: ['arg1', 'arg2'],
id,
}),
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(null, ['arg1', 'arg2']);
});
it('should be able to handle errors thrown by the method call', async () => {
const callback = jest.fn();
const ws = new MinimalDDPClient(() => undefined);
const client = new ClientStreamImpl(ws);
const id = client.call('test', callback);
ws.handleMessage(
JSON.stringify({
msg: 'result',
error: {
error: 400,
reason: 'Bad Request',
message: 'Bad Request [400]',
errorType: 'Meteor.Error',
},
id,
}),
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith({
error: 400,
reason: 'Bad Request',
message: 'Bad Request [400]',
errorType: 'Meteor.Error',
});
});
it('should be able to callAsync a method and receive a result', async () => {
const ws = new MinimalDDPClient(() => undefined);
const client = new ClientStreamImpl(ws);
const promise = client.callAsync('test');
ws.handleMessage(
JSON.stringify({
msg: 'result',
result: ['arg1', 'arg2'],
id: promise.id,
}),
);
const result = await promise;
expect(result).toEqual(['arg1', 'arg2']);
});
it('should be able to callAsync a method and receive an error', async () => {
const ws = new MinimalDDPClient(() => undefined);
const client = new ClientStreamImpl(ws);
const promise = client.callAsync('test');
ws.handleMessage(
JSON.stringify({
msg: 'result',
error: {
error: 400,
reason: 'Bad Request',
message: 'Bad Request [400]',
errorType: 'Meteor.Error',
},
id: promise.id,
}),
);
await expect(promise).rejects.toEqual({
error: 400,
reason: 'Bad Request',
message: 'Bad Request [400]',
errorType: 'Meteor.Error',
});
});
it('should only call the further methods after the previous one has been resolved respecting the wait option', async () => {
const fn = jest.fn();
const dispatch = jest.fn();
const ws = new DDPDispatcher();
const client = new ClientStreamImpl(ws);
client.dispatcher.on('send', dispatch);
const call = client.callWithOptions('wait 1', { wait: true }, fn);
expect(client.dispatcher.queue.length).toBe(1);
const callNoWait = client.call('no wait', fn);
expect(client.dispatcher.queue.length).toBe(2);
const call2 = client.callWithOptions('wait 2', { wait: true }, fn);
expect(client.dispatcher.queue.length).toBe(3);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledTimes(0);
ws.handleMessage(
JSON.stringify({
msg: 'result',
result: ['arg1', 'arg2'],
id: call,
}),
);
expect(dispatch).toHaveBeenCalledTimes(3);
expect(fn).toHaveBeenCalledTimes(1);
ws.handleMessage(
JSON.stringify({
msg: 'result',
result: ['arg1', 'arg2'],
id: call2,
}),
);
expect(fn).toHaveBeenCalledTimes(2);
ws.handleMessage(
JSON.stringify({
msg: 'result',
result: ['arg1', 'arg2'],
id: callNoWait,
}),
);
expect(fn).toHaveBeenCalledTimes(3);
});
});
describe('subscribe procedures', () => {
it('should be able to subscribe to a collection and receive a result', async () => {
const ws = new MinimalDDPClient(() => undefined);
const client = new ClientStreamImpl(ws);
const subscription = client.subscribe('test');
ws.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [subscription.id],
}),
);
await expect(subscription.ready()).resolves.toBeUndefined();
});
it('should be able to subscribe to a collection and receive an error', async () => {
const ws = new MinimalDDPClient(() => undefined);
const client = new ClientStreamImpl(ws);
const subscription = client.subscribe('test');
ws.handleMessage(
JSON.stringify({
msg: 'nosub',
error: {
error: 400,
reason: 'Bad Request',
message: 'Bad Request [400]',
errorType: 'Meteor.Error',
},
id: subscription.id,
}),
);
await expect(subscription.ready()).rejects.toEqual({
error: 400,
reason: 'Bad Request',
message: 'Bad Request [400]',
errorType: 'Meteor.Error',
});
});
it('should be able to unsubscribe from a collection', async () => {
const ws = new MinimalDDPClient(() => undefined);
const client = new ClientStreamImpl(ws);
const subscription = client.subscribe('test');
ws.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [subscription.id],
}),
);
await expect(subscription.ready()).resolves.toBeUndefined();
const unsubPromise = client.unsubscribe(subscription.id);
ws.handleMessage(
JSON.stringify({
msg: 'nosub',
id: subscription.id,
}),
);
expect(unsubPromise).resolves.toEqual({
msg: 'nosub',
id: subscription.id,
});
});
it('should subscribe to a collection and receive values through the observer', async () => {
const ws = new MinimalDDPClient(() => undefined);
const client = new ClientStreamImpl(ws);
const promise = client.subscribe('test');
ws.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [promise.id],
}),
);
await expect(promise.ready()).resolves.toBeUndefined();
const observer = jest.fn();
client.onCollection('test', observer);
ws.handleMessage(
JSON.stringify({
msg: 'added',
collection: 'test',
id: '1',
fields: {
name: 'test',
},
}),
);
expect(observer).toHaveBeenCalledTimes(1);
expect(observer).toHaveBeenCalledWith({
msg: 'added',
collection: 'test',
id: '1',
fields: {
name: 'test',
},
});
});
});
describe('connect procedure', () => {
it('should be able to connect to a server', async () => {
const ws = new MinimalDDPClient(() => undefined);
const client = new ClientStreamImpl(ws);
const promise = client.connect();
ws.handleMessage(
JSON.stringify({
msg: 'connected',
session: 'test',
}),
);
await expect(promise).resolves.toEqual({
msg: 'connected',
session: 'test',
});
});
});

View File

@@ -0,0 +1,370 @@
import WS from 'jest-websocket-mock';
import { handleConnection, handleConnectionAndRejects, handleMethod } from './helpers';
import { ConnectionImpl } from '../src/Connection';
import { MinimalDDPClient } from '../src/MinimalDDPClient';
let server: WS;
let connection: ConnectionImpl;
beforeEach(() => {
server = new WS('ws://localhost:1234/websocket');
});
afterEach(() => {
connection?.close();
server.close();
WS.clean();
jest.useRealTimers();
});
it('should connect', async () => {
const client = new MinimalDDPClient();
connection = new ConnectionImpl('ws://localhost:1234', WebSocket as any, client, { retryCount: 0, retryTime: 0 });
expect(connection.status).toBe('idle');
expect(connection.session).toBeUndefined();
await handleConnection(server, connection.connect());
expect(connection.session).toBe('session');
expect(connection.status).toBe('connected');
});
it('should handle a failing connection', async () => {
const client = new MinimalDDPClient();
connection = new ConnectionImpl('ws://localhost:1234', WebSocket as any, client, { retryCount: 0, retryTime: 0 });
expect(connection.status).toBe('idle');
expect(connection.session).toBeUndefined();
await expect(handleConnectionAndRejects(server, connection.connect())).rejects.toBe('1');
expect(connection.session).toBeUndefined();
expect(connection.status).toBe('failed');
});
it('should trigger a disconnect callback', async () => {
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', globalThis.WebSocket, client, { retryCount: 0, retryTime: 0 });
expect(connection.status).toBe('idle');
expect(connection.session).toBeUndefined();
const disconnectCallback = jest.fn();
connection.on('connection', disconnectCallback);
await handleConnection(server, connection.connect());
expect(disconnectCallback).toHaveBeenNthCalledWith(1, 'connecting');
expect(disconnectCallback).toHaveBeenNthCalledWith(2, 'connected');
expect(disconnectCallback).toHaveBeenCalledTimes(2);
server.close();
expect(disconnectCallback).toHaveBeenCalledTimes(3);
expect(disconnectCallback).toHaveBeenNthCalledWith(3, 'disconnected');
expect(connection.status).toBe('disconnected');
});
it('should handle the close method', async () => {
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', globalThis.WebSocket, client, {
retryCount: 0,
retryTime: 0,
});
server.nextMessage.then((message) => {
expect(message).toBe('{"msg":"connect","version":"1","support":["1","pre2","pre1"]}');
server.send('{"msg":"connected","session":"123"}');
});
expect(connection.status).toBe('idle');
expect(connection.session).toBeUndefined();
await expect(connection.connect()).resolves.toBe(true);
expect(connection.session).toBe('123');
expect(connection.status).toBe('connected');
connection.close();
expect(connection.status).toBe('closed');
});
it('should handle reconnecting', async () => {
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', WebSocket, client, { retryCount: 1, retryTime: 100 });
expect(connection.status).toBe('idle');
expect(connection.session).toBeUndefined();
await handleConnection(server, connection.connect());
expect(connection.session).toBe('session');
expect(connection.status).toBe('connected');
// Fake timers are used to avoid waiting for the reconnect timeout
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
expect(connection.status).toBe('disconnected');
await handleConnection(
server,
jest.advanceTimersByTimeAsync(200),
new Promise((resolve) => connection.once('reconnecting', () => resolve(undefined))),
new Promise((resolve) => {
const handler = (status: string) => {
if (status === 'connected') {
connection.off('connection', handler);
resolve(status);
}
};
connection.on('connection', handler);
}),
);
expect(connection.status).toBe('connected');
jest.useRealTimers();
});
it('should queue messages if the connection is not ready', async () => {
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', globalThis.WebSocket, client, { retryCount: 0, retryTime: 0 });
await handleConnection(server, connection.connect());
connection.close();
expect(connection.status).toBe('closed');
client.emit('send', { msg: 'method', method: 'method', params: ['arg1', 'arg2'], id: '1' });
expect(connection.queue.size).toBe(1);
await handleConnection(server, connection.reconnect());
expect(connection.queue.size).toBe(0);
await handleMethod(server, 'method', ['arg1', 'arg2'], '1');
});
it('should be idempotent if reconnect is called while already connected', async () => {
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', globalThis.WebSocket, client, { retryCount: 0, retryTime: 0 });
await handleConnection(server, connection.connect());
// Previous behavior was to throw "Connection in progress" — the consumer's
// `void this.reconnect()` paths (notably the ws.onclose retry timer)
// surfaced that as an unhandled rejection / pageError. Now a redundant
// reconnect is just a no-op resolving with the current state.
await expect(connection.reconnect()).resolves.toBe(true);
expect(connection.status).toBe('connected');
});
it('should be idempotent if connect is called while already connected', async () => {
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', globalThis.WebSocket, client, { retryCount: 0, retryTime: 0 });
await handleConnection(server, connection.connect());
await expect(connection.connect()).resolves.toBe(true);
expect(connection.status).toBe('connected');
});
it('should share the in-flight connect promise with a concurrent connect() caller', async () => {
// Regression: while status === 'connecting', a second connect() used to
// receive Promise.resolve(true) immediately, hiding any subsequent
// 'failed' payload from the in-flight handshake. Both callers must now
// observe the real outcome.
const client = new MinimalDDPClient();
connection = new ConnectionImpl('ws://localhost:1234', WebSocket as any, client, { retryCount: 0, retryTime: 0 });
const first = connection.connect();
expect(connection.status).toBe('connecting');
const second = connection.connect();
expect(second).toBe(first);
await expect(handleConnectionAndRejects(server, first, second)).rejects.toBe('1');
expect(connection.status).toBe('failed');
});
it('should share the in-flight connect promise with a concurrent reconnect() caller', async () => {
// Same as above for the reconnect() entry point — the ws.onclose retry
// timer fires `reconnect()` and must piggyback on any handshake the
// consumer's bootstrap path already started.
const client = new MinimalDDPClient();
connection = new ConnectionImpl('ws://localhost:1234', WebSocket as any, client, { retryCount: 0, retryTime: 0 });
const first = connection.connect();
expect(connection.status).toBe('connecting');
const second = connection.reconnect();
expect(second).toBe(first);
await handleConnection(server, first, second);
expect(connection.status).toBe('connected');
});
it('should not surface the retry timer rejection when an external connect won the race', async () => {
// Regression: ws.onclose schedules a `void this.reconnect()` timer; if the
// consumer (e.g. ddpSdk.ts startConnect) opens a fresh socket before that
// timer fires, the timer used to reject with "Connection in progress" and,
// because of the leading `void`, the rejection became an unhandled
// rejection on the page. The timer must now no-op silently when the
// connection has already been re-established.
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', WebSocket, client, { retryCount: 1, retryTime: 100 });
await handleConnection(server, connection.connect());
expect(connection.status).toBe('connected');
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
expect(connection.status).toBe('disconnected');
// Track unhandled rejections on the timer's promise.
const unhandled = jest.fn();
process.on('unhandledRejection', unhandled);
// External code opens a new connection BEFORE the retry timer fires.
const externalConnect = handleConnection(server, connection.connect());
// Run the timer.
await jest.advanceTimersByTimeAsync(200);
await externalConnect;
// Drain any microtasks the timer might have queued.
await Promise.resolve();
await Promise.resolve();
expect(connection.status).toBe('connected');
expect(unhandled).not.toHaveBeenCalled();
process.off('unhandledRejection', unhandled);
jest.useRealTimers();
});
it('should reset retryCount on a successful connection so subsequent drops can retry', async () => {
// Regression: retryCount was only ever incremented on disconnect, never
// zeroed on successful (re)connect. With default retryCount=1 budget, a
// single force-logout cycle (server close → SDK reconnects → app calls
// `Meteor.logout()` → server's logout handler closes WS again) drained the
// budget, and the second close left the SDK permanently disconnected.
// Method frames queued on the SDK during that window stayed queued
// forever — observed in e2e-encryption/e2ee-passphrase-management as the
// next loginByUserState login frame never being delivered.
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', WebSocket, client, { retryCount: 1, retryTime: 100 });
await handleConnection(server, connection.connect());
expect(connection.status).toBe('connected');
expect((connection as unknown as { retryCount: number }).retryCount).toBe(0);
// First disconnect → schedules retry (retryCount: 0 → 1).
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
expect(connection.status).toBe('disconnected');
await handleConnection(
server,
jest.advanceTimersByTimeAsync(200),
new Promise((resolve) => connection.once('reconnecting', () => resolve(undefined))),
new Promise((resolve) => {
const handler = (status: string) => {
if (status === 'connected') {
connection.off('connection', handler);
resolve(status);
}
};
connection.on('connection', handler);
}),
);
jest.useRealTimers();
expect(connection.status).toBe('connected');
// Successful reconnect must zero the retry budget.
expect((connection as unknown as { retryCount: number }).retryCount).toBe(0);
// Second disconnect should still schedule a retry now that the budget reset.
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
await handleConnection(
server,
jest.advanceTimersByTimeAsync(200),
new Promise((resolve) => connection.once('reconnecting', () => resolve(undefined))),
new Promise((resolve) => {
const handler = (status: string) => {
if (status === 'connected') {
connection.off('connection', handler);
resolve(status);
}
};
connection.on('connection', handler);
}),
);
jest.useRealTimers();
expect(connection.status).toBe('connected');
});
it('should ignore a stale ws.onclose that fires after the socket has been replaced', async () => {
// Regression: ws.onclose handlers were closed over the original ws but
// mutated `this.status`/`this.retryCount` unconditionally. If a late close
// event from an old socket arrives after a new socket is connected, the
// handler would flip status back to 'disconnected' and schedule another
// retry timer.
const client = new MinimalDDPClient();
connection = ConnectionImpl.create('ws://localhost:1234', WebSocket, client, { retryCount: 1, retryTime: 100 });
await handleConnection(server, connection.connect());
const firstWs = (connection as unknown as { ws: WebSocket }).ws;
expect(connection.status).toBe('connected');
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
expect(connection.status).toBe('disconnected');
await handleConnection(
server,
jest.advanceTimersByTimeAsync(200),
new Promise((resolve) => connection.once('reconnecting', () => resolve(undefined))),
new Promise((resolve) => {
const handler = (status: string) => {
if (status === 'connected') {
connection.off('connection', handler);
resolve(status);
}
};
connection.on('connection', handler);
}),
);
expect(connection.status).toBe('connected');
jest.useRealTimers();
const secondWs = (connection as unknown as { ws: WebSocket }).ws;
expect(secondWs).not.toBe(firstWs);
const statusBefore = connection.status;
const retryBefore = (connection as unknown as { retryCount: number }).retryCount;
// Synthesize a late `close` event on the original socket — the handler
// must short-circuit because `this.ws !== ws` for the closed-over ws.
(firstWs as unknown as { onclose?: () => void }).onclose?.();
expect(connection.status).toBe(statusBefore);
expect((connection as unknown as { retryCount: number }).retryCount).toBe(retryBefore);
});

View File

@@ -0,0 +1,149 @@
import { DDPDispatcher } from '../src/DDPDispatcher';
import { MinimalDDPClient } from '../src/MinimalDDPClient';
const ddp = new MinimalDDPClient();
it('should create a block properly', () => {
const ddpDispatcher = new DDPDispatcher();
const test = ddp.call('test');
ddpDispatcher.wait(test);
expect(ddpDispatcher.queue).toEqual([
{
wait: true,
items: [test],
},
]);
});
it('should push an item to the block properly', () => {
const ddpDispatcher = new DDPDispatcher();
const test1 = ddp.call('test1');
const test2 = ddp.call('test2');
ddpDispatcher.wait(test1);
ddpDispatcher.dispatch(test2);
expect(ddpDispatcher.queue).toEqual([
{
wait: true,
items: [test1],
},
{
wait: false,
items: [test2],
},
]);
});
it('should not keep in the queue if the method doesnt wait', () => {
const ddpDispatcher = new DDPDispatcher();
const blockToRemove = ddp.call('test1');
const test2 = ddp.call('test2');
ddpDispatcher.dispatch(blockToRemove, { wait: true });
ddpDispatcher.dispatch(test2, { wait: false });
expect(ddpDispatcher.queue).toEqual([
{
wait: true,
items: [blockToRemove],
},
{
wait: false,
items: [test2],
},
]);
ddpDispatcher.removeItem(blockToRemove);
expect(ddpDispatcher.queue).toEqual([]);
});
it('should send outstanding blocks if there is no block waiting and item is added', () => {
const fn = jest.fn();
const ddpDispatcher = new DDPDispatcher();
ddpDispatcher.on('send', fn);
ddpDispatcher.dispatch(ddp.call('test1'));
expect(fn).toHaveBeenCalledTimes(1);
});
it('emits non-method payloads immediately, even when a wait block is at the head', () => {
// Regression: a connect frame dispatched while a wait `login` method is
// queued must still reach the server. Otherwise the DDP handshake never
// completes and the socket wedges open but unconnected.
const fn = jest.fn();
const ddpDispatcher = new DDPDispatcher();
ddpDispatcher.on('send', fn);
const login = ddp.call('login');
ddpDispatcher.dispatch(login, { wait: true });
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenNthCalledWith(1, login);
const connectPayload = { msg: 'connect' as const, version: '1', support: ['1'] };
ddpDispatcher.dispatch(connectPayload);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenNthCalledWith(2, connectPayload);
const subPayload = { msg: 'sub' as const, id: 'a', name: 'foo', params: [] };
ddpDispatcher.dispatch(subPayload);
expect(fn).toHaveBeenCalledTimes(3);
expect(fn).toHaveBeenNthCalledWith(3, subPayload);
// Wait block remains pending — only the wait method is queued, the
// non-method frames bypassed it.
expect(ddpDispatcher.queue).toEqual([{ wait: true, items: [login] }]);
});
it('should send the next blocks if the outstanding block was completed', () => {
const fn = jest.fn();
const ddpDispatcher = new DDPDispatcher();
ddpDispatcher.on('send', fn);
const block1 = ddp.call('block1');
const block2 = ddp.call('block2');
const block3 = ddp.call('test2');
ddpDispatcher.dispatch(block1, { wait: true });
ddpDispatcher.dispatch(block2, { wait: true });
ddpDispatcher.dispatch(block3);
expect(fn).toHaveBeenCalledTimes(1);
ddpDispatcher.removeItem(block1);
expect(fn).toHaveBeenCalledTimes(2);
expect(ddpDispatcher.queue.length).toBe(2);
expect(ddpDispatcher.queue).toEqual([
{
wait: true,
items: [block2],
},
{
wait: false,
items: [block3],
},
]);
ddpDispatcher.removeItem(block2);
expect(ddpDispatcher.queue.length).toBe(0);
expect(ddpDispatcher.queue).toEqual([]);
expect(fn).toHaveBeenCalledTimes(3);
expect(fn).toHaveBeenNthCalledWith(1, block1);
expect(fn).toHaveBeenNthCalledWith(2, block2);
expect(fn).toHaveBeenNthCalledWith(3, block3);
});

View File

@@ -0,0 +1,310 @@
import util from 'node:util';
import WS from 'jest-websocket-mock';
import { WebSocket } from 'ws';
import { fireStreamChange, fireStreamAdded, fireStreamRemove, handleConnection, handleSubscription, handleMethod } from './helpers';
import { DDPSDK } from '../src/DDPSDK';
(global as any).WebSocket = (global as any).WebSocket || WebSocket;
export let server: WS;
const callXTimes = <F extends (...args: any) => any>(fn: F, times: number): F => {
return (async (...args) => {
const methods = [].concat(...Array(times));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const _ of methods) {
await fn(...args);
}
}) as F;
};
beforeEach(async () => {
server = new WS('ws://localhost:1234/websocket');
});
afterEach(() => {
server.close();
WS.clean();
jest.useRealTimers();
});
it('should handle a stream of messages', async () => {
const cb = jest.fn();
const streamName = 'stream';
const streamParams = '123';
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const stream = sdk.stream(streamName, streamParams, cb);
await handleSubscription(server, stream.id, streamName, streamParams);
await stream.ready();
fireStreamChange(server, streamName, streamParams);
fireStreamChange(server, streamName, streamParams);
fireStreamChange(server, streamName, streamParams);
expect(cb).toHaveBeenCalledTimes(3);
fireStreamChange(server, streamName, `${streamParams}-another`);
expect(cb).toHaveBeenCalledTimes(3);
expect(cb).toHaveBeenNthCalledWith(1, 1);
sdk.connection.close();
});
it('should ignore messages other from changed', async () => {
const cb = jest.fn();
const streamName = 'stream';
const streamParams = '123';
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const stream = sdk.stream(streamName, streamParams, cb);
await handleSubscription(server, stream.id, streamName, streamParams);
await stream.ready();
fireStreamAdded(server, streamName, streamParams);
fireStreamRemove(server, streamName, streamParams);
expect(cb).toHaveBeenCalledTimes(0);
sdk.connection.close();
});
it('should handle streams after reconnect', async () => {
const cb = jest.fn();
const streamName = 'stream';
const streamParams = '123';
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const result = sdk.stream(streamName, streamParams, cb);
expect(result.isReady).toBe(false);
expect(sdk.client.subscriptions.size).toBe(1);
await handleSubscription(server, result.id, streamName, streamParams);
await result.ready();
const fire = callXTimes(fireStreamChange, 3);
await fire(server, streamName, streamParams);
expect(cb).toHaveBeenCalledTimes(3);
// Fake timers are used to avoid waiting for the reconnect timeout
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
const reconnect = new Promise((resolve) => sdk.connection.once('reconnecting', () => resolve(undefined)));
const connecting = new Promise((resolve) => sdk.connection.once('connecting', () => resolve(undefined)));
const connected = new Promise((resolve) => sdk.connection.once('connected', () => resolve(undefined)));
await handleConnection(server, jest.advanceTimersByTimeAsync(1000), reconnect, connecting, connected);
// the client should reconnect and resubscribe
await Promise.all([handleSubscription(server, result.id, streamName, streamParams), jest.advanceTimersByTimeAsync(1000)]);
fire(server, streamName, streamParams);
await jest.advanceTimersByTimeAsync(1000);
expect(cb).toHaveBeenCalledTimes(6);
jest.useRealTimers();
sdk.connection.close();
});
it('should handle an unsubscribe stream after reconnect', async () => {
const cb = jest.fn();
const streamName = 'stream';
const streamParams = '123';
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const subscription = sdk.stream(streamName, streamParams, cb);
expect(subscription.isReady).toBe(false);
expect(sdk.client.subscriptions.size).toBe(1);
await handleSubscription(server, subscription.id, streamName, streamParams);
await expect(subscription.ready()).resolves.toBe(undefined);
expect(subscription.isReady).toBe(true);
fireStreamChange(server, streamName, streamParams);
fireStreamChange(server, streamName, streamParams);
fireStreamChange(server, streamName, streamParams);
expect(cb).toHaveBeenCalledTimes(3);
// Fake timers are used to avoid waiting for the reconnect timeout
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
const reconnect = new Promise((resolve) => sdk.connection.once('reconnecting', () => resolve(undefined)));
const connecting = new Promise((resolve) => sdk.connection.once('connecting', () => resolve(undefined)));
const connected = new Promise((resolve) => sdk.connection.once('connected', () => resolve(undefined)));
await handleConnection(server, jest.advanceTimersByTimeAsync(1000), reconnect, connecting, connected);
await handleSubscription(server, subscription.id, streamName, streamParams);
expect(subscription.isReady).toBe(true);
fireStreamChange(server, streamName, streamParams);
subscription.stop();
expect(sdk.client.subscriptions.size).toBe(0);
fireStreamChange(server, streamName, streamParams);
fireStreamChange(server, streamName, streamParams);
jest.advanceTimersByTimeAsync(1000);
expect(cb).toHaveBeenCalledTimes(4);
expect(sdk.client.subscriptions.size).toBe(0);
jest.useRealTimers();
sdk.connection.close();
sdk.connection.close();
});
it('should create and connect to a stream', async () => {
const promise = DDPSDK.createAndConnect('ws://localhost:1234');
await handleConnection(server, promise);
const sdk = await promise;
sdk.connection.close();
});
it.skip('should try to loginWithToken after reconnection', async () => {
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const messageResult = {
id: 123,
token: 'token1234',
tokenExpires: { $date: 99999999 },
};
const { loginWithToken } = sdk.account;
const loginFn = jest.fn((token: string) => loginWithToken.apply(sdk.account, [token]));
sdk.account.loginWithToken = loginFn;
await handleMethod(server, 'login', [{ resume: 'token' }], JSON.stringify(messageResult), sdk.account.loginWithToken('token'));
expect(sdk.account.user?.token).toBe(messageResult.token);
expect(loginFn).toHaveBeenCalledTimes(1);
// Fake timers are used to avoid waiting for the reconnect timeout
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
const reconnect = new Promise((resolve) => sdk.connection.once('reconnecting', () => resolve(undefined)));
const connecting = new Promise((resolve) => sdk.connection.once('connecting', () => resolve(undefined)));
const connected = new Promise((resolve) => sdk.connection.once('connected', () => resolve(undefined)));
await handleConnection(server, jest.advanceTimersByTimeAsync(1000), reconnect, connecting, connected);
jest.advanceTimersByTimeAsync(1000);
expect(loginFn).toHaveBeenCalledTimes(2);
jest.useRealTimers();
sdk.connection.close();
});
describe('Method call and Disconnection cases', () => {
it('should handle properly if the message was sent after disconnection', async () => {
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const [result] = await handleMethod(server, 'method', ['args1'], '1', sdk.client.callAsync('method', 'args1'));
expect(result).toBe(1);
// Fake timers are used to avoid waiting for the reconnect timeout
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
const reconnect = new Promise((resolve) => sdk.connection.once('reconnecting', () => resolve(undefined)));
const connecting = new Promise((resolve) => sdk.connection.once('connecting', () => resolve(undefined)));
const connected = new Promise((resolve) => sdk.connection.once('connected', () => resolve(undefined)));
const callResult = sdk.client.callAsync('method', 'args2');
expect(util.inspect(callResult).includes('pending')).toBe(true);
await handleConnection(server, jest.advanceTimersByTimeAsync(1000), reconnect, connecting, connected);
const [result2] = await handleMethod(server, 'method', ['args2'], '1', callResult);
expect(util.inspect(callResult).includes('pending')).toBe(false);
expect(result2).toBe(1);
sdk.connection.close();
jest.useRealTimers();
});
it.skip('should handle properly if the message was sent before disconnection but got disconnected before receiving the response', async () => {
const sdk = DDPSDK.create('ws://localhost:1234');
await handleConnection(server, sdk.connection.connect());
const callResult = sdk.client.callAsync('method', 'args');
expect(util.inspect(callResult).includes('pending')).toBe(true);
// Fake timers are used to avoid waiting for the reconnect timeout
jest.useFakeTimers();
server.close();
WS.clean();
server = new WS('ws://localhost:1234/websocket');
const reconnect = new Promise((resolve) => sdk.connection.once('reconnecting', () => resolve(undefined)));
const connecting = new Promise((resolve) => sdk.connection.once('connecting', () => resolve(undefined)));
const connected = new Promise((resolve) => sdk.connection.once('connected', () => resolve(undefined)));
await handleConnection(server, jest.advanceTimersByTimeAsync(1000), reconnect, connecting, connected);
expect(util.inspect(callResult).includes('pending')).toBe(true);
const [result] = await handleMethod(server, 'method', ['args2'], '1', callResult);
expect(result).toBe(1);
jest.useRealTimers();
sdk.connection.close();
});
});

View File

@@ -0,0 +1,374 @@
import { MinimalDDPClient } from '../src/MinimalDDPClient';
describe('ping/pong mechanism', () => {
it('should reply with pong if no id is provided', () => {
const handlerCallback = jest.fn();
const senderCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
client.once('message', handlerCallback);
client.once('send', senderCallback);
client.handleMessage(
JSON.stringify({
msg: 'ping',
}),
);
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'ping',
});
expect(senderCallback).toHaveBeenCalledTimes(1);
expect(senderCallback).toHaveBeenCalledWith({
msg: 'pong',
});
});
it('should reply with pong if id is provided', () => {
const handlerCallback = jest.fn();
const senderCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
client.on('message', handlerCallback);
client.on('send', senderCallback);
client.handleMessage(
JSON.stringify({
msg: 'ping',
id: '123',
}),
);
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'ping',
id: '123',
});
expect(senderCallback).toHaveBeenCalledTimes(1);
expect(senderCallback).toHaveBeenCalledWith({
msg: 'pong',
id: '123',
});
});
it('should emit "pong" event if pong is received', () => {
const handlerCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
client.once('pong', handlerCallback);
client.handleMessage(
JSON.stringify({
msg: 'pong',
}),
);
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'pong',
});
});
});
describe('subscription mechanism', () => {
it('should emit "ready" event after subscribe', () => {
const handlerCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
const id = client.subscribe('test');
client.onPublish(id, handlerCallback);
client.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [id],
}),
);
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'ready',
subs: [id],
});
});
it('should emit "nosub" event if something goes wrong', () => {
const handlerCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
const id = client.subscribe('test');
client.onPublish(id, handlerCallback);
client.handleMessage(
JSON.stringify({
msg: 'nosub',
id,
}),
);
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'nosub',
id,
});
});
it('should emit "nosub" event after unsubscribe', () => {
const handlerCallback = jest.fn();
const nosubCallback = jest.fn();
const client = new MinimalDDPClient();
const id = client.subscribe('test');
client.onPublish(id, handlerCallback);
client.onNoSub(id, nosubCallback);
client.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [id],
}),
);
client.unsubscribe(id);
client.handleMessage(
JSON.stringify({
msg: 'nosub',
id,
}),
);
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'ready',
subs: [id],
});
expect(nosubCallback).toHaveBeenCalledTimes(1);
expect(nosubCallback).toHaveBeenCalledWith({
msg: 'nosub',
id,
});
});
});
describe('subscription added/changed/removed mechanism', () => {
it('should receive "added" events after subscribe', () => {
const handlerCallback = jest.fn();
const readyCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
const id = client.subscribe('test');
client.onCollection('test', handlerCallback);
client.onPublish(id, readyCallback);
client.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [id],
}),
);
client.handleMessage(
JSON.stringify({
msg: 'added',
collection: 'test',
id,
fields: {
test: 'test',
},
}),
);
expect(readyCallback).toHaveBeenCalledTimes(1);
expect(readyCallback).toHaveBeenCalledWith({
msg: 'ready',
subs: [id],
});
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'added',
collection: 'test',
id,
fields: {
test: 'test',
},
});
});
it('should receive "changed" events after subscribe', () => {
const handlerCallback = jest.fn();
const readyCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
const id = client.subscribe('test');
client.onCollection('test', handlerCallback);
client.onPublish(id, readyCallback);
client.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [id],
}),
);
client.handleMessage(
JSON.stringify({
msg: 'changed',
collection: 'test',
id,
fields: {
test: 'test',
},
}),
);
expect(readyCallback).toHaveBeenCalledTimes(1);
expect(readyCallback).toHaveBeenCalledWith({
msg: 'ready',
subs: [id],
});
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'changed',
collection: 'test',
id,
fields: {
test: 'test',
},
});
});
it('should receive "removed" events after subscribe', () => {
const handlerCallback = jest.fn();
const readyCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
const id = client.subscribe('test');
client.onCollection('test', handlerCallback);
client.onPublish(id, readyCallback);
client.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [id],
}),
);
client.handleMessage(
JSON.stringify({
msg: 'removed',
collection: 'test',
id,
}),
);
expect(readyCallback).toHaveBeenCalledTimes(1);
expect(readyCallback).toHaveBeenCalledWith({
msg: 'ready',
subs: [id],
});
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'removed',
collection: 'test',
id,
});
});
});
describe('subscription updated mechanism', () => {
it('should receive "updated" events after subscribe and before ready', () => {
const handlerCallback = jest.fn();
const readyCallback = jest.fn();
const updatedCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
const id = client.subscribe('test');
client.onCollection(id, handlerCallback);
client.onPublish(id, readyCallback);
client.onUpdate(id, updatedCallback);
client.handleMessage(
JSON.stringify({
msg: 'ready',
subs: [id],
}),
);
client.handleMessage(
JSON.stringify({
msg: 'updated',
methods: [id],
}),
);
expect(readyCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledTimes(0);
expect(updatedCallback).toHaveBeenCalledTimes(1);
expect(updatedCallback).toHaveBeenCalledWith({
msg: 'updated',
methods: [id],
});
});
});
describe('method mechanism', () => {
it('should emit "result" event after method call', () => {
const handlerCallback = jest.fn();
const client = new MinimalDDPClient(() => undefined);
const { id } = client.call('test');
client.onResult(id, handlerCallback);
client.handleMessage(
JSON.stringify({
msg: 'result',
params: [],
id,
}),
);
expect(handlerCallback).toHaveBeenCalledTimes(1);
expect(handlerCallback).toHaveBeenCalledWith({
msg: 'result',
params: [],
id,
});
});
});
describe('error mechanism', () => {
it('should throw error if the payload is not valid', () => {
const client = new MinimalDDPClient(() => undefined);
expect(() => {
client.handleMessage('test');
}).toThrow();
});
});

View File

@@ -0,0 +1,64 @@
import { TimeoutControl } from '../src/TimeoutControl';
jest.useFakeTimers();
jest.spyOn(global, 'setTimeout');
it('should call the heartbeat and timeout callbacks respecting the informed time', async function () {
const heartbeatCallback = jest.fn();
const timeoutCallback = jest.fn();
const timeout = new TimeoutControl(100);
timeout.reset();
expect(setTimeout).toHaveBeenCalledTimes(2);
timeout.on('heartbeat', heartbeatCallback);
timeout.on('timeout', timeoutCallback);
// At this point in time, the callback should not have been called yet
expect(heartbeatCallback).not.toHaveBeenCalled();
expect(timeoutCallback).not.toHaveBeenCalled();
jest.advanceTimersByTime(60);
expect(heartbeatCallback).toHaveBeenCalledTimes(1);
expect(timeoutCallback).not.toHaveBeenCalled();
jest.advanceTimersByTime(60);
expect(heartbeatCallback).toHaveBeenCalledTimes(1);
expect(timeoutCallback).toHaveBeenCalledTimes(1);
});
it('should never call the timeout callback if the reset method is called', async function () {
const heartbeatCallback = jest.fn();
const timeoutCallback = jest.fn();
const timeout = new TimeoutControl(100);
timeout.reset();
expect(setTimeout).toHaveBeenCalledTimes(2);
timeout.on('heartbeat', heartbeatCallback);
timeout.on('timeout', timeoutCallback);
// At this point in time, the callback should not have been called yet
expect(heartbeatCallback).not.toHaveBeenCalled();
expect(timeoutCallback).not.toHaveBeenCalled();
jest.advanceTimersByTime(60);
expect(heartbeatCallback).toHaveBeenCalledTimes(1);
expect(timeoutCallback).not.toHaveBeenCalled();
timeout.reset();
jest.advanceTimersByTime(60);
expect(heartbeatCallback).toHaveBeenCalledTimes(2);
expect(timeoutCallback).not.toHaveBeenCalled();
});
afterEach(() => {
jest.clearAllMocks();
});

View File

@@ -0,0 +1,64 @@
import type WS from 'jest-websocket-mock';
const acceptConnection = async (server: WS) => {
await server.nextMessage.then(async (message) => {
await expect(message).toBe('{"msg":"connect","version":"1","support":["1","pre2","pre1"]}');
server.send(`{"msg":"connected","session":"session"}`);
});
};
export const handleConnection = async (server: WS, ...client: Promise<unknown>[]) => {
await Promise.all([acceptConnection(server), ...client]);
};
export const handleConnectionAndRejects = async (server: WS, ...client: Promise<unknown>[]) => {
const suggestedVersion = '1';
return Promise.all([
server.nextMessage.then((message) => {
expect(message).toBe('{"msg":"connect","version":"1","support":["1","pre2","pre1"]}');
return server.send(`{"msg":"failed","version":"${suggestedVersion}"}`);
}),
...client,
]);
};
const handleConnectionButNoResponse = async (server: WS, method: string, params: string[]) => {
return server.nextMessage.then(async (msg) => {
if (typeof msg !== 'string') throw new Error('Expected message to be a string');
const message = JSON.parse(msg);
await expect(message).toMatchObject({
msg: 'method',
method,
params,
});
return message;
});
};
export const handleMethod = async (server: WS, method: string, params: any[], responseResult: string, ...client: Promise<unknown>[]) => {
const result = await handleConnectionButNoResponse(server, method, params);
return Promise.all([server.send(`{"msg":"result","id":"${result.id}","result":${responseResult}}`), ...client]).then((result) => {
result.shift();
return result;
});
};
export const handleSubscription = async (server: WS, id: string, streamName: string, streamParams: string) => {
await server.nextMessage.then(async (message) => {
await expect(message).toBe(
`{"msg":"sub","id":"${id}","name":"stream-${streamName}","params":["${streamParams}",{"useCollection":false,"args":[null]}]}`,
);
server.send(`{"msg":"ready","subs":["${id}"]}`);
});
};
export const fireStream = (action: 'changed' | 'removed' | 'added') => {
return (server: WS, streamName: string, streamParams: string) => {
return server.send(
`{"msg":"${action}","collection":"stream-${streamName}","id":"id","fields":{"eventName":"${streamParams}", "args":[1]}}`,
);
};
};
export const fireStreamChange = fireStream('changed');
export const fireStreamRemove = fireStream('removed');
export const fireStreamAdded = fireStream('added');

View File

@@ -0,0 +1,25 @@
import { Emitter } from '@rocket.chat/emitter';
import { wrapOnceEventIntoPromise } from '../src/wrapOnceEventIntoPromise';
it('should resolve', async () => {
const emitter = new Emitter();
const promise = wrapOnceEventIntoPromise(emitter, 'test');
emitter.emit('test', 'test');
const result = await promise;
expect(result).toBe('test');
});
it('should reject', async () => {
const emitter = new Emitter();
const promise = wrapOnceEventIntoPromise(emitter, 'test');
emitter.emit('test', { error: 'test' });
await expect(promise).rejects.toBe('test');
expect.assertions(1);
});