diff --git a/src/test/chain-simulator/utils/prepare-test-data.ts b/src/test/chain-simulator/utils/prepare-test-data.ts index e02a454b9..9e2ce0e25 100644 --- a/src/test/chain-simulator/utils/prepare-test-data.ts +++ b/src/test/chain-simulator/utils/prepare-test-data.ts @@ -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...'); @@ -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) { diff --git a/src/test/chain-simulator/utils/testSequencer.js b/src/test/chain-simulator/utils/testSequencer.js index f1a70a4a8..5fcdfa1b9 100644 --- a/src/test/chain-simulator/utils/testSequencer.js +++ b/src/test/chain-simulator/utils/testSequencer.js @@ -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', ]; diff --git a/src/test/chain-simulator/websocket.subscriptions.cs-e2e.ts b/src/test/chain-simulator/websocket.subscriptions.cs-e2e.ts index 7721f4508..cf6df420b 100644 --- a/src/test/chain-simulator/websocket.subscriptions.cs-e2e.ts +++ b/src/test/chain-simulator/websocket.subscriptions.cs-e2e.ts @@ -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 = ( @@ -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[] }) => { @@ -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)); @@ -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 ---"); @@ -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); @@ -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', () => {