Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src/test/chain-simulator/utils/prepare-test-data.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,32 @@
import axios from 'axios';
import { config } from '../config/env.config';
import { fundAddress, issueMultipleEsdts, issueMultipleMetaESDTCollections, issueMultipleNftsCollections } from './chain.simulator.operations';
import { ChainSimulatorUtils } from './test.utils';

// the api serves most of this from caches the cache warmer fills on its own crons, so what the chain
// reports says nothing about what the tests will see. wait on the api's own view instead, per kind of
// data, rather than on a single interval that has to be long enough for the slowest of them
async function waitForApi(description: string, url: string, isReady: (data: any) => boolean, timeoutMs: number = 180000) {
const deadline = Date.now() + timeoutMs;
let last: any = 'no response yet';

while (Date.now() < deadline) {
try {
last = (await axios.get(url)).data;
if (isReady(last)) {
console.log(`✓ ${description}`);
return;
}
} catch (error: any) {
last = error.message;
}

await new Promise((resolve) => setTimeout(resolve, 5000));
}

throw new Error(`${description}: still not ready after ${timeoutMs}ms. ${url} last returned ${JSON.stringify(last).slice(0, 300)}`);
}

async function prepareTestData() {
try {
console.log('Starting test data preparation...');
Expand All @@ -25,7 +50,17 @@ async function prepareTestData() {
await ChainSimulatorUtils.deployPingPongSc(config.aliceAddress);
console.log('✓ Deployed PingPong smart contract');

await new Promise((resolve) => setTimeout(resolve, 30000));
await waitForApi('Tokens listed by the API', `${config.apiServiceUrl}/tokens/count`, count => count >= 5);
await waitForApi('Tokens listed on the issuer account', `${config.apiServiceUrl}/accounts/${config.aliceAddress}/tokens/count`, count => count >= 5);
// 2 NFT + 2 SFT collections, five items each; the meta-esdt ones come on top of that
await waitForApi('Collections listed by the API', `${config.apiServiceUrl}/collections/count`, count => count >= 4);
await waitForApi('NFTs listed by the API', `${config.apiServiceUrl}/nfts/count`, count => count >= 20);

// node and validator statistics are filled in by warmers on a one minute cron, and shards are
// derived from them. until those have run at least once, /shards is empty and nodes come back
// with a null rating and no status
await waitForApi('Shards reported by the API', `${config.apiServiceUrl}/shards`, shards => shards.length >= 4);
await waitForApi('Node ratings filled in by the API', `${config.apiServiceUrl}/nodes?size=1`, nodes => nodes.length > 0 && nodes[0].status !== undefined && nodes[0].tempRating !== null);

console.log('Test data preparation completed successfully!');
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion src/test/chain-simulator/utils/testSequencer.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class CustomSequencer extends Sequencer {
'delegation-legacy.cs-e2e.ts',
'accounts.cs-e2e.ts',
'stake.cs-e2e.ts',
'round.cs-e2e.ts',
'rounds.cs-e2e.ts',
'results.cs-e2e.ts',
'miniblocks.cs-e2e.ts',
];
Expand Down
63 changes: 53 additions & 10 deletions src/test/chain-simulator/websocket.subscriptions.cs-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,33 @@ const aliceEsdts: string[] = [];

describe('Websocket subscriptions e2e tests', () => {
const clients: Socket[] = [];
const connectionErrors: string[] = [];

// auto-reconnect is disabled on purpose: the subscriptions below are emitted from the 'connect'
// handler, so every reconnect would re-subscribe and the shared response arrays would collect the
// same message twice. connections are instead retried explicitly, before any operation is sent
const socketOptions = { path: '/ws/subscription', reconnection: false };

const waitForConnections = async (timeoutMs: number) => {
const deadline = Date.now() + timeoutMs;

while (Date.now() < deadline) {
if (clients.every(client => client.connected)) {
return;
}

for (const client of clients) {
if (!client.connected) {
client.connect();
}
}

await new Promise(resolve => setTimeout(resolve, 500));
}

const pending = clients.filter(client => !client.connected).length;
throw new Error(`${pending} of ${clients.length} websocket clients did not connect within ${timeoutMs}ms. Errors: ${connectionErrors.join('; ') || 'none reported'}`);
};

// --- Connect Helper ---
const connectAndSubscribe = (
Expand All @@ -97,13 +124,13 @@ describe('Websocket subscriptions e2e tests', () => {
eventResponses.set(filterKey, receivedEvents);
transferResponses.set(filterKey, receivedTransfers);

const client: Socket = io(WS_SERVER_URL, {
path: '/ws/subscription',
});
const client: Socket = io(WS_SERVER_URL, socketOptions);
clients.push(client);

// never throw from a socket callback: it escapes as an uncaughtException that jest attributes
// to whichever test happens to be running, in any file. waitForConnections reports it instead
client.on("connect_error", (err) => {
throw new Error(`${clientId} connection failed: ${err.message}`);
connectionErrors.push(`${clientId}: ${err.message}`);
});

client.on("customTransactionUpdate", (data: { transactions: any[] }) => {
Expand Down Expand Up @@ -137,12 +164,10 @@ describe('Websocket subscriptions e2e tests', () => {
};

const connectAndSubscribeGeneral = (clientId: string, subConfig: typeof client4SubscriptionConfig) => {
const client: Socket = io(WS_SERVER_URL, {
path: '/ws/subscription',
});
const client: Socket = io(WS_SERVER_URL, socketOptions);
clients.push(client);

client.on("connect_error", (err) => { throw new Error(`${clientId} connection failed: ${err.message}`); });
client.on("connect_error", (err) => { connectionErrors.push(`${clientId}: ${err.message}`); });

client.on("poolUpdate", (data: any) => generalResponses.pool.push(data));
client.on("eventsUpdate", (data: any) => generalResponses.events.push(data));
Expand Down Expand Up @@ -185,6 +210,8 @@ describe('Websocket subscriptions e2e tests', () => {

connectAndSubscribeGeneral("client4", client4SubscriptionConfig);

await waitForConnections(30000);

await new Promise(resolve => setTimeout(resolve, 10000));

log("\n--- Starting Operations ---");
Expand All @@ -207,7 +234,15 @@ describe('Websocket subscriptions e2e tests', () => {
await axios.post(`${config.chainSimulatorUrl}/simulator/generate-blocks/10`);

log("Waiting for WS messages...");
await new Promise(resolve => setTimeout(resolve, 35000));

// the broadcaster commits its cursor only after it sees the round that follows the one it just
// sent. a simulator that has stopped producing blocks never provides that round, so it times
// out and replays the whole window on its next tick. one block per wait step keeps it moving,
// which is why this is a loop of short sleeps rather than a single long one
for (let i = 0; i < 30; i++) {
await axios.post(`${config.chainSimulatorUrl}/simulator/generate-blocks/1`);
await new Promise(resolve => setTimeout(resolve, 1000));
}

} catch (e: any) {
console.error("Error in beforeAll:", e.message);
Expand All @@ -216,7 +251,15 @@ describe('Websocket subscriptions e2e tests', () => {
});

afterAll(() => {
clients.forEach(client => client.connected && client.disconnect());
// unconditionally: a client that is disconnected or mid-handshake would otherwise be skipped
// and keep its handlers and timers alive for the rest of the run, which is shared (--runInBand)
for (const client of clients) {
client.removeAllListeners();
client.disconnect();
client.close();
}

clients.length = 0;
});

it('should receive TXs sent by Alice for Client 1', () => {
Expand Down
Loading