Skip to content

Commit 2ea0e00

Browse files
committed
test(e2e): add web socket server tests
1 parent 7c47ce0 commit 2ea0e00

File tree

5 files changed

+249
-1
lines changed

5 files changed

+249
-1
lines changed

.github/workflows/continuous-integration-e2e.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ jobs:
7979
run: |
8080
yarn workspace @cardano-sdk/e2e test:wallet:epoch0
8181
yarn workspace @cardano-sdk/e2e test:projection
82+
yarn workspace @cardano-sdk/e2e test:ws
8283
8384
- name: Wait for epoch 3
8485
run: |

packages/e2e/jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ module.exports = {
3434
project('providers'),
3535
project('wallet_epoch_0'),
3636
project('wallet_epoch_3'),
37+
project('ws-server'),
3738
{
3839
...commonProjectProps,
3940
displayName: 'wallet-real-ada',

packages/e2e/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"test:ogmios": "jest -c jest.config.js --forceExit --selectProjects ogmios --runInBand --verbose",
3232
"test:pg-boss": "jest -c jest.config.js --forceExit --selectProjects pg-boss --runInBand --verbose",
3333
"test:providers": "jest -c jest.config.js --forceExit --selectProjects providers --runInBand --verbose",
34-
"test:wallet": "yarn wait-for-network-init ; yarn test:wallet:epoch0 && { yarn wait-for-network-epoch-3 ; yarn test:wallet:epoch3 ; }",
34+
"test:wallet": "yarn wait-for-network-init ; yarn test:wallet:epoch0 && yarn test:ws && { yarn wait-for-network-epoch-3 ; yarn test:wallet:epoch3 ; }",
3535
"test:wallet:epoch0": "jest -c jest.config.js --forceExit --selectProjects wallet_epoch_0 --runInBand --verbose",
3636
"test:wallet:epoch3": "jest -c jest.config.js --forceExit --selectProjects wallet_epoch_3 --runInBand --verbose",
3737
"test:wallet-real-ada": "NETWORK_SPEED=slow jest -c jest.config.js --forceExit --selectProjects wallet-real-ada --runInBand --verbose",
@@ -46,6 +46,7 @@
4646
"test:web-extension:watch:run": "yarn test:web-extension:run --watch",
4747
"test:web-extension:watch": "run-s test:web-extension:build test:web-extension:watch:bg",
4848
"test:web-extension:watch:bg": "run-p test:web-extension:watch:build test:web-extension:watch:run",
49+
"test:ws": "jest -c jest.config.js --forceExit --selectProjects ws-server --runInBand --verbose",
4950
"local-network:common": "PRE_CONWAY=${PRE_CONWAY:-stable} DISABLE_DB_CACHE=${DISABLE_DB_CACHE:-true} SUBMIT_API_ARGS='--testnet-magic 888' USE_BLOCKFROST=false __FIX_UMASK__=$(chmod -R a+r ../../compose/placeholder-secrets) docker compose --env-file ../cardano-services/environments/.env.local -p local-network-e2e -f docker-compose.yml -f ../../compose/common.yml -f ../../compose/$(uname -m).yml $FILES up",
5051
"local-network:up": "FILES='' yarn local-network:common",
5152
"local-network:single:up": "FILES='' yarn local-network:common cardano-node file-server local-testnet ogmios postgres",
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { CardanoWsClient } from '@cardano-sdk/cardano-services-client';
2+
import { getEnv, walletVariables } from '../../src';
3+
import { logger } from '@cardano-sdk/util-dev';
4+
5+
const env = getEnv([...walletVariables]);
6+
7+
describe('Web Socket', () => {
8+
let client: CardanoWsClient;
9+
10+
const openClient = () => (client = new CardanoWsClient({ logger }, { url: new URL(env.WS_PROVIDER_URL) }));
11+
12+
const closeClient = () => (client ? client.close() : Promise.resolve());
13+
14+
afterEach(closeClient);
15+
16+
it('CardanoWsClient.epoch$ emits on epoch rollover', (done) => {
17+
openClient();
18+
19+
// If it emits on epoch rollover ok, otherwise the test fails on timeout
20+
const subscription = client.epoch$.subscribe(() => {
21+
subscription.unsubscribe();
22+
done();
23+
});
24+
});
25+
});
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { CardanoWsClient } from '@cardano-sdk/cardano-services-client';
2+
import {
3+
CardanoWsServer,
4+
GenesisData,
5+
createDnsResolver,
6+
getOgmiosCardanoNode,
7+
util
8+
} from '@cardano-sdk/cardano-services';
9+
import { HealthCheckResponse, WsProvider } from '@cardano-sdk/core';
10+
import { OgmiosCardanoNode } from '@cardano-sdk/ogmios';
11+
import { Pool } from 'pg';
12+
import { filter, firstValueFrom } from 'rxjs';
13+
import { getEnv, walletVariables } from '../../src';
14+
import { getPort } from 'get-port-please';
15+
import { logger } from '@cardano-sdk/util-dev';
16+
17+
const env = getEnv([...walletVariables, 'DB_SYNC_CONNECTION_STRING', 'OGMIOS_URL']);
18+
19+
const wsProviderReady = (provider: WsProvider) =>
20+
new Promise<void>((resolve, reject) => {
21+
// eslint-disable-next-line prefer-const
22+
let timeout: NodeJS.Timeout | undefined;
23+
24+
const subscription = provider.health$.subscribe(({ ok }) => {
25+
if (ok) {
26+
if (timeout) clearTimeout(timeout);
27+
subscription.unsubscribe();
28+
resolve();
29+
}
30+
});
31+
32+
timeout = setTimeout(() => {
33+
subscription.unsubscribe();
34+
reject(new Error('WsProvider timeout'));
35+
}, 10_000);
36+
37+
timeout.unref();
38+
});
39+
40+
const wsProviderReadyAgain = (provider: WsProvider, close: () => Promise<unknown>) =>
41+
new Promise<void>(async (resolve, reject) => {
42+
const oks: boolean[] = [];
43+
let closed = false;
44+
// eslint-disable-next-line prefer-const
45+
let timeout: NodeJS.Timeout | undefined;
46+
47+
const subscription = provider.health$.subscribe(({ ok }) => {
48+
oks.push(ok);
49+
50+
if (closed && ok) {
51+
if (timeout) clearTimeout(timeout);
52+
subscription.unsubscribe();
53+
54+
try {
55+
const [, ...last] = oks;
56+
57+
// The first emitted event is the ok buffered one
58+
// next we expect at least one not ok event i.e. the close function had the desired effect
59+
// last we expect one more ok event when provider is operational once again
60+
expect(oks.length).toBeGreaterThanOrEqual(3);
61+
expect(oks[0]).toBeTruthy();
62+
expect(oks[1]).toBeFalsy();
63+
expect(last.some((result) => result));
64+
65+
resolve();
66+
} catch (error) {
67+
reject(error);
68+
}
69+
}
70+
});
71+
72+
try {
73+
await close();
74+
} catch (error) {
75+
reject(error);
76+
}
77+
78+
closed = true;
79+
80+
timeout = setTimeout(() => {
81+
subscription.unsubscribe();
82+
reject(new Error('WsProvider timeout'));
83+
}, 10_000);
84+
85+
timeout.unref();
86+
});
87+
88+
describe('Web Socket', () => {
89+
let db: Pool;
90+
let cardanoNode: OgmiosCardanoNode;
91+
let genesisData: GenesisData;
92+
let port: number;
93+
94+
let client: CardanoWsClient;
95+
let server: CardanoWsServer;
96+
97+
const openClient = (heartbeatInterval = 55) =>
98+
(client = new CardanoWsClient({ logger }, { heartbeatInterval, url: new URL(`ws://localhost:${port}/ws`) }));
99+
100+
const openServer = (heartbeatTimeout = 60) =>
101+
(server = new CardanoWsServer(
102+
{ cardanoNode, db, genesisData, logger },
103+
{ dbCacheTtl: 120, heartbeatTimeout, port }
104+
));
105+
106+
const closeClient = () => (client ? client.close() : Promise.resolve());
107+
const closeServer = () => (server ? new Promise<void>((resolve) => server.close(resolve)) : Promise.resolve());
108+
109+
const listenToClientHealthFor15Seconds = async () => {
110+
const health: HealthCheckResponse[] = [];
111+
const subscription = client.health$.subscribe((value) => health.push(value));
112+
113+
// Listen on client.health$ for 15"
114+
await new Promise((resolve) => setTimeout(resolve, 15_000));
115+
116+
subscription.unsubscribe();
117+
118+
return health;
119+
};
120+
121+
beforeAll(async () => {
122+
const dnsResolver = createDnsResolver({ factor: 1.1, maxRetryTime: 1000 }, logger);
123+
124+
cardanoNode = await getOgmiosCardanoNode(dnsResolver, logger, { ogmiosUrl: new URL(env.OGMIOS_URL) });
125+
db = new Pool({ connectionString: env.DB_SYNC_CONNECTION_STRING });
126+
genesisData = await util.loadGenesisData('local-network/config/network/cardano-node/config.json');
127+
port = await getPort();
128+
129+
await cardanoNode.initialize();
130+
await cardanoNode.start();
131+
});
132+
133+
afterAll(() => Promise.all([cardanoNode.shutdown(), db.end()]));
134+
135+
afterEach(() => Promise.all([closeClient(), closeServer()]));
136+
137+
it('Server can re-connect to DB if NOTIFY connection drops', async () => {
138+
// Close server db connection from DB server side
139+
const query =
140+
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE pid <> pg_backend_pid() AND query = 'LISTEN sdk_tip'";
141+
142+
openServer();
143+
await wsProviderReady(server);
144+
145+
await wsProviderReadyAgain(server, () => db.query(query));
146+
});
147+
148+
it('Client can re-connect to server if web socket connection drops', async () => {
149+
openServer();
150+
await wsProviderReady(server);
151+
152+
openClient();
153+
await wsProviderReady(client);
154+
155+
await wsProviderReadyAgain(client, async () => {
156+
// Close the server and open a new one
157+
await closeServer();
158+
openServer();
159+
});
160+
});
161+
162+
it('Server disconnects clients on heartbeat timeout', async () => {
163+
// Open a server with 3" heartbeat timeout
164+
openServer(3);
165+
await wsProviderReady(server);
166+
167+
openClient();
168+
await wsProviderReady(client);
169+
170+
const health = await listenToClientHealthFor15Seconds();
171+
172+
// Considering the server performs timeouts check every 10"
173+
// We expect the heath state of the client goes up and down more time
174+
expect(health.length).toBeGreaterThanOrEqual(3);
175+
// We expect the heath state of the client goes up at least twice
176+
expect(health.filter(({ ok }) => ok).length).toBeGreaterThanOrEqual(2);
177+
// We expect the heath state of the client goes down at least once
178+
expect(health.some(({ ok }) => !ok)).toBeTruthy();
179+
});
180+
181+
it("Server doesn't disconnects clients without heartbeat timeouts", async () => {
182+
// Open a server with 3" heartbeat timeout
183+
openServer(3);
184+
await wsProviderReady(server);
185+
186+
// Open a client with 2" heartbeat interval
187+
openClient(2);
188+
await wsProviderReady(client);
189+
190+
const health = await listenToClientHealthFor15Seconds();
191+
192+
// We expect only the buffered ok heath state
193+
expect(health.length).toBe(1);
194+
expect(health[0].ok).toBeTruthy();
195+
});
196+
197+
describe('CardanoWsClient.networkInfoProvider', () => {
198+
it('It throws when disconnected but when starting', async () => {
199+
openServer();
200+
await wsProviderReady(server);
201+
202+
openClient();
203+
204+
// This test doesn't calls wsProviderReady(client)
205+
// to check the provider doesn't throw if called before its init sequence completed
206+
await expect(client.networkInfoProvider.ledgerTip()).resolves.toHaveProperty('blockNo');
207+
208+
await closeServer();
209+
await firstValueFrom(client.health$.pipe(filter(({ ok }) => !ok)));
210+
211+
await expect(client.networkInfoProvider.ledgerTip()).rejects.toThrowError('CONNECTION_FAILURE');
212+
});
213+
214+
it('If called when still starting, it throws on connect error', async () => {
215+
openClient();
216+
217+
await expect(client.networkInfoProvider.ledgerTip()).rejects.toThrowError('CONNECTION_FAILURE');
218+
});
219+
});
220+
});

0 commit comments

Comments
 (0)