diff --git a/apps/whale/__sanity__/WhaleSanityContainer.sanity.ts b/apps/whale/__sanity__/WhaleSanityContainer.sanity.ts new file mode 100644 index 0000000000..ca6451650e --- /dev/null +++ b/apps/whale/__sanity__/WhaleSanityContainer.sanity.ts @@ -0,0 +1,59 @@ +import { WhaleSanityContainer } from '@defichain/testcontainers' +import waitForExpect from 'wait-for-expect' + +const whale = new WhaleSanityContainer() + +// TODO(kodemon/eli-lim): +// would it make sense to use WhaleApiClient for sanity tests, +// instead of raw http requests? + +beforeAll(async () => { + await whale.start() + + async function mockRealisticState (): Promise { + await whale.blockchain.waitForWalletCoinbaseMaturity() + await whale.blockchain.waitForWalletBalanceGTE(100) + + // TODO(kodemon/eli-lim): Create tokens, pool pairs, etc. to sanity test the endpoints + } + await mockRealisticState() + + await waitForExpect(async () => { + const response = await whale.get('/_actuator/probes/readiness') + const json = await response.json() + expect(json.details.model.status).toStrictEqual('up') + expect(json.details.defid.blocks).toBeGreaterThanOrEqual(100) + }, 60_000) +}) + +afterAll(async () => { + await whale.stop() +}) + +describe('/_actuator', () => { + describe('/_actuator/probes/liveness', () => { + test('Status in JSON body is ok', async () => { + const response = await whale.get('/_actuator/probes/liveness') + expect(await response.json()).toStrictEqual({ + details: { + defid: { status: 'up' }, + model: { status: 'up' } + }, + error: {}, + info: { + defid: { status: 'up' }, + model: { status: 'up' } + }, + status: 'ok' + }) + expect(response.status).toStrictEqual(200) + }) + }) + + describe('/_actuator/probes/readiness', () => { + test('Status code is 503', async () => { + const response = await whale.get('/_actuator/probes/readiness') + expect(response.status).toStrictEqual(503) + }) + }) +}) diff --git a/jest.config.js b/jest.config.js index fd4b9c6c20..2ef2290e9e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,6 +12,9 @@ module.exports = { verbose: true, clearMocks: true, testTimeout: 180000, + testPathIgnorePatterns: [ + '__sanity__' + ], coveragePathIgnorePatterns: [ '/node_modules/', '/examples/', diff --git a/jest.sanity.js b/jest.sanity.js new file mode 100644 index 0000000000..42cb3039c9 --- /dev/null +++ b/jest.sanity.js @@ -0,0 +1,9 @@ +const config = require('./jest.config.js') + +module.exports = { + ...config, + testRegex: '((\\.|/)(sanity))\\.ts$', + testPathIgnorePatterns: [], + globalSetup: './jest.sanity.setup.js', + testTimeout: 300000 +} diff --git a/jest.sanity.setup.js b/jest.sanity.setup.js new file mode 100644 index 0000000000..c993b35187 --- /dev/null +++ b/jest.sanity.setup.js @@ -0,0 +1,36 @@ +const Dockerode = require('dockerode') +const path = require('path') +const { pack } = require('tar-fs') + +const apps = ['whale'] + +module.exports = async function () { + console.log('\nPreloading sanity images, this may take a while...') + await Promise.all(apps.map(build)) +} + +/** + * Builds a new image with a :sanity tag that can be pulled into unit tests and + * run on the current state of the code base. These steps are required to + * ensure that we are sanity testing against the current code state and not + * pre-built solutions which is tested seperately during our standard unit + * tests. + * + * @remarks Images are built with tar + * @see https://github.com/apocas/dockerode/issues/432 + */ +async function build (app) { + console.log(`Building '${app}:sanity' image`) + const docker = new Dockerode() + const image = pack(path.resolve(__dirname)) + const stream = await docker.buildImage(image, { + t: `${app}:sanity`, + buildargs: { + APP: app + } + }) + await new Promise((resolve, reject) => { + docker.modem.followProgress(stream, (err, res) => (err != null) ? reject(err) : resolve(res)) + }) + console.log(`Finished '${app}:sanity' image`) +} diff --git a/package-lock.json b/package-lock.json index c37a67bb94..4881ecca74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6554,6 +6554,25 @@ "@types/superagent": "*" } }, + "node_modules/@types/tar-fs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/tar-fs/-/tar-fs-2.0.1.tgz", + "integrity": "sha512-qlsQyIY9sN7p221xHuXKNoMfUenOcvEBN4zI8dGsYbYCqHtTarXOEXSIgUnK+GcR0fZDse6pAIc5pIrCh9NefQ==", + "peer": true, + "dependencies": { + "@types/node": "*", + "@types/tar-stream": "*" + } + }, + "node_modules/@types/tar-stream": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-2.2.2.tgz", + "integrity": "sha512-1AX+Yt3icFuU6kxwmPakaiGrJUwG44MpuiqPg4dSolRFk6jmvs4b3IbUol9wKDLIgU76gevn3EwE8y/DkSJCZQ==", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/tiny-secp256k1": { "version": "1.0.0", "dev": true, @@ -22272,7 +22291,6 @@ }, "node_modules/tar-fs": { "version": "2.1.1", - "dev": true, "license": "MIT", "dependencies": { "chownr": "^1.1.1", @@ -22283,7 +22301,6 @@ }, "node_modules/tar-fs/node_modules/chownr": { "version": "1.1.4", - "dev": true, "license": "ISC" }, "node_modules/tar-stream": { @@ -25073,6 +25090,7 @@ "@defichain/jellyfish-network": "^0.0.0", "cross-fetch": "^3.1.5", "dockerode": "^3.3.1", + "tar-fs": "^2.1.1", "uuid": "^8.3.2" }, "devDependencies": { @@ -25080,6 +25098,7 @@ "@types/uuid": "^8.3.4" }, "peerDependencies": { + "@types/tar-fs": "^2.0.1", "defichain": "^0.0.0" } }, @@ -26791,6 +26810,7 @@ "@types/uuid": "^8.3.4", "cross-fetch": "^3.1.5", "dockerode": "^3.3.1", + "tar-fs": "^2.1.1", "uuid": "^8.3.2" } }, @@ -29905,6 +29925,25 @@ "@types/superagent": "*" } }, + "@types/tar-fs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/tar-fs/-/tar-fs-2.0.1.tgz", + "integrity": "sha512-qlsQyIY9sN7p221xHuXKNoMfUenOcvEBN4zI8dGsYbYCqHtTarXOEXSIgUnK+GcR0fZDse6pAIc5pIrCh9NefQ==", + "peer": true, + "requires": { + "@types/node": "*", + "@types/tar-stream": "*" + } + }, + "@types/tar-stream": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-2.2.2.tgz", + "integrity": "sha512-1AX+Yt3icFuU6kxwmPakaiGrJUwG44MpuiqPg4dSolRFk6jmvs4b3IbUol9wKDLIgU76gevn3EwE8y/DkSJCZQ==", + "peer": true, + "requires": { + "@types/node": "*" + } + }, "@types/tiny-secp256k1": { "version": "1.0.0", "dev": true, @@ -33984,6 +34023,7 @@ "@types/uuid": "^8.3.4", "cross-fetch": "^3.1.5", "dockerode": "^3.3.1", + "tar-fs": "^2.1.1", "uuid": "^8.3.2" } }, @@ -37098,6 +37138,25 @@ "@types/superagent": "*" } }, + "@types/tar-fs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/tar-fs/-/tar-fs-2.0.1.tgz", + "integrity": "sha512-qlsQyIY9sN7p221xHuXKNoMfUenOcvEBN4zI8dGsYbYCqHtTarXOEXSIgUnK+GcR0fZDse6pAIc5pIrCh9NefQ==", + "peer": true, + "requires": { + "@types/node": "*", + "@types/tar-stream": "*" + } + }, + "@types/tar-stream": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-2.2.2.tgz", + "integrity": "sha512-1AX+Yt3icFuU6kxwmPakaiGrJUwG44MpuiqPg4dSolRFk6jmvs4b3IbUol9wKDLIgU76gevn3EwE8y/DkSJCZQ==", + "peer": true, + "requires": { + "@types/node": "*" + } + }, "@types/tiny-secp256k1": { "version": "1.0.0", "dev": true, @@ -47428,7 +47487,6 @@ }, "tar-fs": { "version": "2.1.1", - "dev": true, "requires": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -47437,8 +47495,7 @@ }, "dependencies": { "chownr": { - "version": "1.1.4", - "dev": true + "version": "1.1.4" } } }, @@ -56845,7 +56902,6 @@ }, "tar-fs": { "version": "2.1.1", - "dev": true, "requires": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -56854,8 +56910,7 @@ }, "dependencies": { "chownr": { - "version": "1.1.4", - "dev": true + "version": "1.1.4" } } }, diff --git a/package.json b/package.json index 83ad8a819c..221a116c42 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "prepare": "husky install", "lint": "eslint . --fix", "test": "jest --maxWorkers=100%", + "sanity": "jest --maxWorkers=100% --config=jest.sanity.js", "ci:test": "jest --ci --coverage --forceExit --maxWorkers=4", "all:clean": "rm -rf ./packages/**/dist && rm -rf ./apps/dist && rm -rf ./packages/**/tsconfig.build.tsbuildinfo", "all:build": "lerna run build", diff --git a/packages/testcontainers/package.json b/packages/testcontainers/package.json index 4ef2817847..326f9b66d5 100644 --- a/packages/testcontainers/package.json +++ b/packages/testcontainers/package.json @@ -17,9 +17,11 @@ "@defichain/jellyfish-network": "^0.0.0", "cross-fetch": "^3.1.5", "dockerode": "^3.3.1", + "tar-fs": "^2.1.1", "uuid": "^8.3.2" }, "peerDependencies": { + "@types/tar-fs": "^2.0.1", "defichain": "^0.0.0" }, "devDependencies": { diff --git a/packages/testcontainers/src/containers/AppContainer/WhaleSanityContainer.ts b/packages/testcontainers/src/containers/AppContainer/WhaleSanityContainer.ts new file mode 100644 index 0000000000..74dce180c7 --- /dev/null +++ b/packages/testcontainers/src/containers/AppContainer/WhaleSanityContainer.ts @@ -0,0 +1,51 @@ +import { MasterNodeRegTestContainer } from '../RegTestContainer/Masternode' +import { AppContainer } from '.' +import { waitForCondition } from '../../utils' + +export class WhaleSanityContainer extends AppContainer { + constructor (port?: number, blockchain?: MasterNodeRegTestContainer) { + super('whale', port, blockchain) + } + + /** + * Start the whale container by initiating a build procedure, instantiate the + * underlying blockchain node, and create a container instance to send sanity + * requests to. + * + * We provide the blockchain node ip and port to the internal whale configuration + * which links it to the node allowing it to hit the chain with RPC requests. + * + * @remarks + * + * The method performs a wait for condition to ensure the container is ready + * before the start method is considered resolved. Otherwise the unit tests + * will run before the container is ready which can result in various network + * or request errors. + */ + public async start (): Promise { + const { hostRegTestIp, hostRegTestPort } = await this.startMasterNode() + + this.container = await this.docker.createContainer({ + name: this.name, + Image: this.image, + Tty: true, + Env: [ + `WHALE_DEFID_URL=http://testcontainers-user:testcontainers-password@${hostRegTestIp}:${hostRegTestPort}`, + 'WHALE_NETWORK=regtest', + 'WHALE_DATABASE_PROVIDER=memory' + ], + ExposedPorts: { '3000/tcp': {} }, + HostConfig: { + PortBindings: { '3000/tcp': [{ HostPort: this.port.toString() }] }, + PublishAllPorts: true + } + }) + + await this.container.start() + + await waitForCondition(async () => { + const res = await this.get('/_actuator/probes/liveness') + return res.status === 200 + }, 30_000) // 30s + } +} diff --git a/packages/testcontainers/src/containers/AppContainer/index.ts b/packages/testcontainers/src/containers/AppContainer/index.ts new file mode 100644 index 0000000000..7221cc3731 --- /dev/null +++ b/packages/testcontainers/src/containers/AppContainer/index.ts @@ -0,0 +1,95 @@ +import { fetch, Response } from 'cross-fetch' + +import { DockerContainer } from '../DockerContainer' +import { MasterNodeRegTestContainer } from '../RegTestContainer/Masternode' +import { v4 as uuidv4 } from 'uuid' + +/** + * App Container + * + * Typically used for "sanity tests" - tests that are run seperately from the + * established unit tests to test against a simulated production environment, + * ensuring our containerised apps are resolving incoming requests as expected. + * + * We introduce the container environment so that they can be run via unit tests + * to create uniformity in our approach to testing. Reducing the cognitive + * complexity of having to run multiple automated solutions to ensure release quality. + * + * This solution works by building a new docker image using our root Dockerfile + * and running instances on random ports to support parallel testing allowing for + * vertical scaling in our test environments. + */ +export abstract class AppContainer extends DockerContainer { + public readonly name = this.generateName() + + constructor ( + public readonly app: string, + public readonly port = getRandomPort(3000, 5000), + public readonly blockchain: MasterNodeRegTestContainer = new MasterNodeRegTestContainer() + ) { + super(`${app}:sanity`) + } + + public abstract start (): Promise + + /** + * Start the blockchain master node and return its hosting details to be used + * by the sanity containers. + * + * @returns Host details + */ + public async startMasterNode (): Promise<{ + hostRegTestIp: string + hostRegTestPort: string + }> { + await this.blockchain.start() + + const hostRegTestIp = 'host.docker.internal' // TODO(eli-lim): Works on linux? + const hostRegTestPort = await this.blockchain.getPort('19554/tcp') + + return { hostRegTestIp, hostRegTestPort } + } + + /** + * Shuts down and removes any running sanity instance for this session aiming to + * reduce the amount of lingering docker containers as a result of running tests. + */ + public async stop (): Promise { + await this.container?.stop() + await this.container?.remove({ v: true }) + await this.blockchain.stop() + } + + public generateName (): string { + return `${this.app}-${uuidv4()}` + } + + public async post (endpoint: string, data?: any): Promise { + return await this.fetch(endpoint, { + method: 'POST', + body: JSON.stringify(data) + }) + } + + public async get (endpoint: string): Promise { + return await this.fetch(endpoint, { + method: 'GET' + }) + } + + public async fetch (endpoint: string, init: RequestInit = {}): Promise { + const url = await this.getUrl() + return await fetch(`${url}${endpoint}`, init) + } + + public async getUrl (): Promise { + return `http://127.0.0.1:${this.port}` + } +} + +/** + * @see https://stackoverflow.com/a/7228322 + */ +function getRandomPort (min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1) + min) +} diff --git a/packages/testcontainers/src/containers/DockerContainer.ts b/packages/testcontainers/src/containers/DockerContainer.ts index dcbfc98cac..8f458c2603 100644 --- a/packages/testcontainers/src/containers/DockerContainer.ts +++ b/packages/testcontainers/src/containers/DockerContainer.ts @@ -136,7 +136,7 @@ export abstract class DockerContainer { } } -async function hasImageLocally (image: string, docker: Dockerode): Promise { +export async function hasImageLocally (image: string, docker: Dockerode): Promise { return await new Promise((resolve, reject) => { docker.getImage(image).inspect((error, result) => { resolve(!(error instanceof Error)) diff --git a/packages/testcontainers/src/index.ts b/packages/testcontainers/src/index.ts index e8e5b4da70..6cf000a972 100644 --- a/packages/testcontainers/src/index.ts +++ b/packages/testcontainers/src/index.ts @@ -24,3 +24,5 @@ export * from './containers/RegTestContainer/ContainerGroup' export * from './utils' export * from './containers/RegTestContainer/LoanContainer' + +export * from './containers/AppContainer/WhaleSanityContainer'