diff --git a/.github/workflows/check-package-lock.yml b/.github/workflows/check-package-lock.yml new file mode 100644 index 0000000000..5fb8e63e5d --- /dev/null +++ b/.github/workflows/check-package-lock.yml @@ -0,0 +1,53 @@ +name: Check Package Lock File + +permissions: + contents: read + +concurrency: + group: check-package-lock-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: + - main # Run on push to main branch only + pull_request: + branches: + - "**" # Run on PR to any branch + +jobs: + verify-package-lock: + name: Verify package-lock.json exists + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check if package-lock.json exists + run: | + if [ ! -f "package-lock.json" ]; then + echo "ERROR: package-lock.json file is missing from the repository" + echo "This file is required to ensure consistent dependency versions across all environments" + echo "Please ensure package-lock.json is committed with your changes" + exit 1 + fi + echo "SUCCESS: package-lock.json file is present" + + - name: Verify package-lock.json is not empty + run: | + if [ ! -s "package-lock.json" ]; then + echo "ERROR: package-lock.json file exists but is empty" + echo "Please run 'npm install' to regenerate the lock file" + exit 1 + fi + echo "SUCCESS: package-lock.json file is valid and not empty" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Validate package-lock.json is valid and in sync + run: npm ci --dry-run --ignore-scripts diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 309a909b7a..9dfbd54d08 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -58,6 +58,11 @@ jobs: ports: - 3306:3306 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - name: Checkout repository uses: actions/checkout@v3 diff --git a/src/modules/http-client/http-client-module-manager.js b/src/modules/http-client/http-client-module-manager.js index adbbb364d2..f915559c6a 100644 --- a/src/modules/http-client/http-client-module-manager.js +++ b/src/modules/http-client/http-client-module-manager.js @@ -66,6 +66,12 @@ class HttpClientModuleManager extends BaseModuleManager { return this.getImplementation().module.initializeAfterMiddlewares(this.authService); } } + + async close() { + if (this.initialized && this.getImplementation().module.close) { + return this.getImplementation().module.close(); + } + } } export default HttpClientModuleManager; diff --git a/src/modules/http-client/implementation/express-http-client.js b/src/modules/http-client/implementation/express-http-client.js index c6a75edc9a..b532c834b3 100644 --- a/src/modules/http-client/implementation/express-http-client.js +++ b/src/modules/http-client/implementation/express-http-client.js @@ -53,11 +53,30 @@ class ExpressHttpClient { ); this.httpsServer.listen(this.config.port); } else { - this.app.listen(this.config.port); + this.server = this.app.listen(this.config.port); } this.logger.info(`Node listening on port: ${this.config.port}`); } + async close() { + return new Promise((resolve, reject) => { + const serverToClose = this.httpsServer || this.server; + if (serverToClose) { + serverToClose.close((err) => { + if (err) { + this.logger.error(`Error closing HTTP server: ${err.message}`); + reject(err); + } else { + this.logger.info('HTTP server closed successfully'); + resolve(); + } + }); + } else { + resolve(); + } + }); + } + selectMiddlewares(options) { const middlewares = []; if (options.rateLimit) middlewares.push(rateLimiterMiddleware(this.config.rateLimiter)); diff --git a/src/modules/network/implementation/libp2p-service.js b/src/modules/network/implementation/libp2p-service.js index 112ad76ee2..fb6f0e22e0 100644 --- a/src/modules/network/implementation/libp2p-service.js +++ b/src/modules/network/implementation/libp2p-service.js @@ -116,6 +116,14 @@ class Libp2pService { this.logger.info(`Network ID is ${this.config.id}, connection port is ${port}`); } + async stop() { + if (this.node) { + this.logger.info('Stopping libp2p node...'); + await this.node.stop(); + this.logger.info('Libp2p node stopped'); + } + } + async onPeerConnected(listener) { this.node.connectionManager.on('peer:connect', listener); } diff --git a/src/modules/network/network-module-manager.js b/src/modules/network/network-module-manager.js index 4216734a05..f048dcd447 100644 --- a/src/modules/network/network-module-manager.js +++ b/src/modules/network/network-module-manager.js @@ -11,6 +11,12 @@ class NetworkModuleManager extends BaseModuleManager { } } + async stop() { + if (this.initialized) { + return this.getImplementation().module.stop(); + } + } + async onPeerConnected(listener) { if (this.initialized) { return this.getImplementation().module.onPeerConnected(listener); diff --git a/src/service/blockchain-interval-cleanup.js b/src/service/blockchain-interval-cleanup.js new file mode 100644 index 0000000000..86e58e0a65 --- /dev/null +++ b/src/service/blockchain-interval-cleanup.js @@ -0,0 +1,37 @@ +/** + * Helper function to clean up blockchain-specific intervals. + * + * Services like ProofingService and ClaimRewardsService store intervals + * using a `${blockchainId}Interval` property pattern. This utility provides + * a consistent way to clean up those intervals. + * + * @param {Object} options - Configuration options + * @param {Object} options.service - The service instance containing the intervals + * @param {Object} options.blockchainModuleManager - The blockchain module manager + * @param {Object} options.logger - The logger instance + * @param {string} options.serviceName - Name of the service (for logging) + * @param {string} options.logPrefix - Log prefix e.g., '[CLAIM]' or '[PROOFING]' + */ +function cleanupBlockchainIntervals({ + service, + blockchainModuleManager, + logger, + serviceName, + logPrefix, +}) { + logger.info(`${logPrefix} Starting ${serviceName} cleanup`); + + for (const blockchainId of blockchainModuleManager.getImplementationNames()) { + const intervalKey = `${blockchainId}Interval`; + if (service[intervalKey]) { + logger.debug(`${logPrefix} Clearing interval for blockchain ${blockchainId}`); + clearInterval(service[intervalKey]); + // eslint-disable-next-line no-param-reassign + service[intervalKey] = null; + } + } + + logger.info(`${logPrefix} ${serviceName} cleanup completed`); +} + +export default cleanupBlockchainIntervals; diff --git a/src/service/claim-rewards-service.js b/src/service/claim-rewards-service.js index 218e418c94..e19be17f49 100644 --- a/src/service/claim-rewards-service.js +++ b/src/service/claim-rewards-service.js @@ -1,4 +1,5 @@ import { CLAIM_REWARDS_BATCH_SIZE, CLAIM_REWARDS_INTERVAL } from '../constants/constants.js'; +import cleanupBlockchainIntervals from './blockchain-interval-cleanup.js'; class ClaimRewardsService { constructor(ctx) { @@ -92,6 +93,16 @@ class ClaimRewardsService { } } + cleanup() { + cleanupBlockchainIntervals({ + service: this, + blockchainModuleManager: this.blockchainModuleManager, + logger: this.logger, + serviceName: 'ClaimRewardsService', + logPrefix: '[CLAIM]', + }); + } + async claimRewards(blockchainId) { const identityId = await this.blockchainModuleManager.getIdentityId(blockchainId); const nodeDelegatorAddresses = await this.blockchainModuleManager.getDelegators( diff --git a/src/service/proofing-service.js b/src/service/proofing-service.js index d08ff12893..9140a8e81e 100644 --- a/src/service/proofing-service.js +++ b/src/service/proofing-service.js @@ -9,6 +9,7 @@ import { TRIPLES_VISIBILITY, PROOFING_MAX_ATTEMPTS, } from '../constants/constants.js'; +import cleanupBlockchainIntervals from './blockchain-interval-cleanup.js'; class ProofingService { constructor(ctx) { @@ -485,18 +486,14 @@ class ProofingService { return `${blockchainId}-${epoch}-${activeProofPeriodStartBlock}`; } - // Add cleanup method to stop intervals cleanup() { - this.logger.info('[PROOFING] Starting ProofingService cleanup'); - for (const blockchainId of this.blockchainModuleManager.getImplementationNames()) { - const intervalKey = `${blockchainId}Interval`; - if (this[intervalKey]) { - this.logger.debug(`Clearing interval for blockchain ${blockchainId}`); - clearInterval(this[intervalKey]); - this[intervalKey] = null; - } - } - this.logger.info('[PROOFING] ProofingService cleanup completed'); + cleanupBlockchainIntervals({ + service: this, + blockchainModuleManager: this.blockchainModuleManager, + logger: this.logger, + serviceName: 'ProofingService', + logPrefix: '[PROOFING]', + }); } } diff --git a/test/bdd/steps/common.mjs b/test/bdd/steps/common.mjs index 0fbe66079a..10284476b9 100644 --- a/test/bdd/steps/common.mjs +++ b/test/bdd/steps/common.mjs @@ -11,6 +11,24 @@ import MockOTNode from '../../utilities/MockOTNode.mjs'; const stepsUtils = new StepsUtils(); +/** + * Extracts the first blockchain configuration for use with DkgClientHelper. + * @param {Object} localBlockchains - The localBlockchains state object + * @returns {Object} Blockchain config with name, publicKey, privateKey, and rpc + */ +function getFirstBlockchainConfig(localBlockchains) { + const firstBlockchainId = Object.keys(localBlockchains)[0]; + const firstBlockchain = localBlockchains[firstBlockchainId]; + const firstWallet = firstBlockchain.getWallets()[0]; + + return { + name: firstBlockchainId, + publicKey: firstWallet.address, + privateKey: firstWallet.privateKey, + rpc: `http://localhost:${firstBlockchain.port}`, + }; +} + Given( /^I setup (\d+)[ additional]* node[s]*$/, { timeout: 30000 }, @@ -64,14 +82,7 @@ Given( if (response.error) { assert.fail(`Error while initializing node${nodeIndex}: ${response.error}`); } else { - // todo if started - const client = new DkgClientHelper({ - endpoint: 'http://localhost', - port: rpcPort, - maxNumberOfRetries: 5, - frequency: 2, - contentType: 'all', - }); + // Build blockchain options BEFORE creating the client let clientBlockchainOptions = {}; Object.keys(this.state.localBlockchains).forEach((blockchainId, index) => { const blockchain = this.state.localBlockchains[blockchainId]; @@ -87,6 +98,15 @@ Given( }; }); + const client = new DkgClientHelper({ + endpoint: 'http://localhost', + port: rpcPort, + maxNumberOfRetries: 5, + frequency: 2, + contentType: 'all', + blockchain: getFirstBlockchainConfig(this.state.localBlockchains), + }); + this.state.nodes[nodeIndex] = { client, forkedNode, @@ -145,23 +165,34 @@ Given( fs.rmSync(appDataPath, { recursive: true, force: true }); const nodeInstance = new MockOTNode(nodeConfiguration); - await nodeInstance.start(); // This will skip startNetworkModule + + try { + await nodeInstance.start(); // This will skip startNetworkModule - const client = new DkgClientHelper({ - endpoint: 'http://localhost', - port: rpcPort, - useSSL: false, - timeout: 25, - loglevel: 'trace', - }); + const client = new DkgClientHelper({ + endpoint: 'http://localhost', + port: rpcPort, + useSSL: false, + timeout: 25, + loglevel: 'trace', + blockchain: getFirstBlockchainConfig(this.state.localBlockchains), + }); - this.state.bootstraps.push({ - client, - otNodeInstance: nodeInstance, - configuration: nodeConfiguration, - nodeRpcUrl: `http://localhost:${rpcPort}`, - fileService: nodeInstance.fileService, - }); + this.state.bootstraps.push({ + client, + otNodeInstance: nodeInstance, + configuration: nodeConfiguration, + nodeRpcUrl: `http://localhost:${rpcPort}`, + fileService: nodeInstance.fileService, + }); + } catch (error) { + // Ensure node is stopped if there's an error after starting + this.logger.error(`Error during bootstrap initialization: ${error.message}`); + if (nodeInstance.stop) { + await nodeInstance.stop(); + } + throw error; + } } ); // diff --git a/test/bdd/steps/hooks.mjs b/test/bdd/steps/hooks.mjs index 476415d29b..f2f9a62cbc 100644 --- a/test/bdd/steps/hooks.mjs +++ b/test/bdd/steps/hooks.mjs @@ -10,7 +10,7 @@ process.env.NODE_ENV = NODE_ENVIRONMENTS.TEST; BeforeAll(() => {}); -Before(function beforeMethod(testCase, done) { +Before(function beforeMethod(testCase) { this.logger = console; this.logger.log('\n🟑 Starting scenario:', testCase.pickle.name); // Initialize variables @@ -24,20 +24,22 @@ Before(function beforeMethod(testCase, done) { fs.mkdirSync(logDir, { recursive: true }); this.state.scenarionLogDir = logDir; this.logger.log('πŸ“ Scenario logs:', logDir); - done(); }); -After(async function afterMethod(testCase, done) { +After({ timeout: 30000 }, async function afterMethod(testCase) { const tripleStoreConfiguration = []; const databaseNames = []; - const promises = []; + const cleanupPromises = []; + // Stop all nodes first and wait for them to shut down + const stopPromises = []; + for (const key in this.state.nodes) { const node = this.state.nodes[key]; if (node.forkedNode) { node.forkedNode.kill(); } else if (node.otNodeInstance?.stop) { - promises.push(node.otNodeInstance.stop()); + stopPromises.push(node.otNodeInstance.stop()); } tripleStoreConfiguration.push({ @@ -45,14 +47,14 @@ After(async function afterMethod(testCase, done) { }); databaseNames.push(node.configuration.operationalDatabase.databaseName); const dataFolderPath = node.fileService.getDataFolderPath(); - promises.push(node.fileService.removeFolder(dataFolderPath)); + cleanupPromises.push(node.fileService.removeFolder(dataFolderPath)); } for (const node of this.state.bootstraps) { if (node.forkedNode) { node.forkedNode.kill(); } else if (node.otNodeInstance?.stop) { - promises.push(node.otNodeInstance.stop()); + stopPromises.push(node.otNodeInstance.stop()); } tripleStoreConfiguration.push({ @@ -60,12 +62,20 @@ After(async function afterMethod(testCase, done) { }); databaseNames.push(node.configuration.operationalDatabase.databaseName); const dataFolderPath = node.fileService.getDataFolderPath(); - promises.push(node.fileService.removeFolder(dataFolderPath)); + cleanupPromises.push(node.fileService.removeFolder(dataFolderPath)); } + // Wait for all nodes to stop before continuing + this.logger.log('⏸️ Stopping all nodes...'); + await Promise.all(stopPromises); + this.logger.log('βœ… All nodes stopped'); + + // Give a moment for ports to be fully released + await new Promise(resolve => setTimeout(resolve, 500)); + for (const localBlockchain in this.state.localBlockchains) { this.logger.info(`πŸ›‘ Stopping local blockchain ${localBlockchain}`); - promises.push(this.state.localBlockchains[localBlockchain].stop()); + cleanupPromises.push(this.state.localBlockchains[localBlockchain].stop()); this.state.localBlockchains[localBlockchain] = null; } @@ -78,38 +88,57 @@ After(async function afterMethod(testCase, done) { for (const db of databaseNames) { const sql = `DROP DATABASE IF EXISTS \`${db}\`;`; - promises.push(con.promise().query(sql)); + cleanupPromises.push(con.promise().query(sql)); } for (const config of tripleStoreConfiguration) { - promises.push((async () => { - const tripleStoreModuleManager = new TripleStoreModuleManager({ - config, - logger: this.logger, - }); - await tripleStoreModuleManager.initialize(); - for (const impl of tripleStoreModuleManager.getImplementationNames()) { - const { tripleStoreConfig } = tripleStoreModuleManager.getImplementation(impl); - for (const repo of Object.keys(tripleStoreConfig.repositories)) { - this.logger.log('πŸ—‘ Removing triple store repository:', repo); - await tripleStoreModuleManager.deleteRepository(impl, repo); + // Skip if tripleStore module is not defined + if (!config?.modules?.tripleStore) { + continue; + } + + cleanupPromises.push((async () => { + try { + const tripleStoreModuleManager = new TripleStoreModuleManager({ + config, + logger: this.logger, + }); + await tripleStoreModuleManager.initialize(); + for (const impl of tripleStoreModuleManager.getImplementationNames()) { + const { tripleStoreConfig } = tripleStoreModuleManager.getImplementation(impl); + for (const repo of Object.keys(tripleStoreConfig.repositories)) { + this.logger.log('πŸ—‘ Removing triple store repository:', repo); + await tripleStoreModuleManager.deleteRepository(impl, repo); + } } + } catch (error) { + // Log but don't fail cleanup if tripleStore cleanup fails + this.logger.warn(`⚠️ Could not clean up tripleStore: ${error.message}`); } })()); } - await Promise.all(promises); + await Promise.all(cleanupPromises); con.end(); this.logger.log('\nβœ… Completed scenario:', testCase.pickle.name); this.logger.log(`πŸ“„ Location: ${testCase.gherkinDocument.uri}:${testCase.gherkinDocument.feature.location.line}`); this.logger.log(`🟒 Status: ${testCase.result.status}`); this.logger.log(`⏱ Duration: ${testCase.result.duration} milliseconds\n`); - done(); }); AfterAll(async () => {}); -process.on('unhandledRejection', () => { +process.on('unhandledRejection', (reason, promise) => { + // Ignore expected libp2p errors in test environment + // These occur because MockOTNode skips network module startup, + // but forked nodes still try peer discovery + const ignoredErrorCodes = ['ERR_LOOKUP_FAILED', 'ERR_NOT_FOUND']; + if (reason?.code && ignoredErrorCodes.includes(reason.code)) { + console.warn(`⚠️ Ignoring expected test environment error: ${reason.code}`); + return; + } + + console.error('Unhandled Rejection at:', promise, 'reason:', reason); process.abort(); }); diff --git a/test/utilities/MockOTNode.mjs b/test/utilities/MockOTNode.mjs index 8c347e77d6..4e9b47db70 100644 --- a/test/utilities/MockOTNode.mjs +++ b/test/utilities/MockOTNode.mjs @@ -5,4 +5,50 @@ export default class MockOTNode extends OTNode { this.logger.info('[Mock] Skipping startNetworkModule in test'); // Do nothing } -} \ No newline at end of file + + async stop() { + this.logger.info('[Mock] Stopping node...'); + try { + // Stop command executor + const commandExecutor = this.container?.resolve('commandExecutor'); + if (commandExecutor) { + await commandExecutor.commandExecutorShutdown(); + } + + // Stop HTTP server + const httpClientModuleManager = this.container?.resolve('httpClientModuleManager'); + if (httpClientModuleManager?.close) { + await httpClientModuleManager.close(); + } + + // Stop network module (libp2p) + const networkModuleManager = this.container?.resolve('networkModuleManager'); + if (networkModuleManager?.stop) { + await networkModuleManager.stop(); + } + + // Cleanup proofing service intervals + const proofingService = this.container?.resolve('proofingService'); + if (proofingService?.cleanup) { + proofingService.cleanup(); + } + + // Cleanup claim rewards service intervals + const claimRewardsService = this.container?.resolve('claimRewardsService'); + if (claimRewardsService?.cleanup) { + claimRewardsService.cleanup(); + } + + // Cleanup sync service intervals + const syncService = this.container?.resolve('syncService'); + if (syncService?.cleanup) { + syncService.cleanup(); + } + + this.logger.info('[Mock] Node stopped successfully'); + } catch (error) { + this.logger.error(`[Mock] Error stopping node: ${error.message}`); + throw error; + } + } +}