├── .dockerignore ├── .editorconfig ├── .eslintignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md └── workflows │ └── nodejs.yml ├── .gitignore ├── .npmrc ├── .prettierrc.js ├── .yarnrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── lerna.json ├── package.json ├── packages ├── .eslintrc.js ├── dockest │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── readiness-check │ │ └── package.json │ ├── scripts │ │ └── kebab-case-files-in-dir.ts │ ├── src │ │ ├── @types.ts │ │ ├── constants.ts │ │ ├── errors.ts │ │ ├── index.spec.ts │ │ ├── index.ts │ │ ├── logger.ts │ │ ├── readiness-check │ │ │ ├── container-is-healthy-readiness-check.spec.ts │ │ │ ├── container-is-healthy-readiness-check.ts │ │ │ ├── create-postgres-readiness-check.ts │ │ │ ├── create-redis-readiness-check.ts │ │ │ ├── create-web-readiness-check.ts │ │ │ ├── index.ts │ │ │ ├── with-no-stop.ts │ │ │ ├── with-retry.ts │ │ │ ├── zero-exit-code-readiness-check.spec.ts │ │ │ └── zero-exit-code-readiness-check.ts │ │ ├── run │ │ │ ├── bootstrap │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── get-parsed-compose-file.spec.ts.snap │ │ │ │ ├── configure-logger.ts │ │ │ │ ├── create-docker-event-emitter.ts │ │ │ │ ├── create-docker-service-event-stream.ts │ │ │ │ ├── get-compose-files-with-version.spec.ts │ │ │ │ ├── get-compose-files-with-version.ts │ │ │ │ ├── get-parsed-compose-file.spec.ts │ │ │ │ ├── get-parsed-compose-file.ts │ │ │ │ ├── index.ts │ │ │ │ ├── merge-compose-files-2.spec.yml │ │ │ │ ├── merge-compose-files.spec.ts │ │ │ │ ├── merge-compose-files.spec.yml │ │ │ │ ├── merge-compose-files.ts │ │ │ │ ├── setup-exit-handler.ts │ │ │ │ ├── transform-dockest-services-to-runners.spec.ts │ │ │ │ ├── transform-dockest-services-to-runners.ts │ │ │ │ └── write-compose-file.ts │ │ │ ├── create-container-die-check.ts │ │ │ ├── debug-mode.ts │ │ │ ├── log-writer.spec.ts │ │ │ ├── log-writer.ts │ │ │ ├── run-jest.ts │ │ │ ├── teardown.ts │ │ │ └── wait-for-services │ │ │ │ ├── check-connection.spec.ts │ │ │ │ ├── check-connection.ts │ │ │ │ ├── docker-compose-up.ts │ │ │ │ ├── fix-runner-host-access-on-linux.ts │ │ │ │ ├── index.spec.ts │ │ │ │ ├── index.ts │ │ │ │ ├── resolve-container-id.ts │ │ │ │ ├── run-readiness-check.spec.ts │ │ │ │ ├── run-readiness-check.ts │ │ │ │ └── run-runner-commands.ts │ │ ├── test-helper │ │ │ └── index.ts │ │ ├── test-utils.ts │ │ └── utils │ │ │ ├── __snapshots__ │ │ │ └── format-zod-error.test.ts.snap │ │ │ ├── custom-zod-error-map.test.ts │ │ │ ├── custom-zod-error-map.ts │ │ │ ├── execa-wrapper.ts │ │ │ ├── format-zod-error.test.ts │ │ │ ├── format-zod-error.ts │ │ │ ├── get-opts.spec.ts │ │ │ ├── get-opts.ts │ │ │ ├── get-run-mode.ts │ │ │ ├── hash-code.spec.ts │ │ │ ├── hash-code.ts │ │ │ ├── network │ │ │ ├── bridge-network-exists.ts │ │ │ ├── create-bridge-network.ts │ │ │ ├── join-bridge-network.ts │ │ │ ├── leave-bridge-network.ts │ │ │ └── remove-bridge-network.ts │ │ │ ├── select-port-mapping.spec.ts │ │ │ ├── select-port-mapping.ts │ │ │ ├── sleep-with-log.ts │ │ │ ├── sleep.spec.ts │ │ │ ├── sleep.ts │ │ │ ├── teardown-single.ts │ │ │ └── trim.ts │ ├── test-helper │ │ └── package.json │ ├── tsconfig.json │ ├── tsconfig.publish.json │ └── yarn.lock └── examples │ ├── aws-codebuild │ ├── package.json │ ├── src │ │ ├── .gitignore │ │ ├── CI.Dockerfile │ │ ├── README.md │ │ ├── app │ │ │ ├── Dockerfile │ │ │ ├── index.js │ │ │ └── package.json │ │ ├── buildspec.yml │ │ ├── codebuild_build.sh │ │ ├── codebuild_prebuild.sh │ │ ├── codebuild_test.sh │ │ ├── docker-compose.yml │ │ ├── dockest.ts │ │ ├── integration-test │ │ │ └── hello-world.spec.ts │ │ ├── jest.config.js │ │ ├── package.json │ │ └── tsconfig.json │ └── yarn.lock │ ├── docker-in-docker │ ├── .dockerignore │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── package.json │ ├── prepare.sh │ ├── run.sh │ ├── src │ │ ├── app │ │ │ ├── Dockerfile │ │ │ ├── index.js │ │ │ └── package.json │ │ ├── docker-compose.yml │ │ ├── dockest.ts │ │ ├── integration-test │ │ │ └── hello-world.spec.ts │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── run-test.sh │ │ └── tsconfig.json │ └── yarn.lock │ ├── multiple-compose-files │ ├── .sequelizerc │ ├── README.md │ ├── docker-compose-postgres.yml │ ├── docker-compose-redis.yml │ ├── dockest.ts │ ├── jest.config.js │ ├── jest.setup.ts │ ├── package.json │ ├── postgres │ │ ├── app.spec.ts │ │ ├── app.ts │ │ ├── config │ │ │ └── postgresConfig.js │ │ ├── migrations │ │ │ └── 20190101001337-create-user.js │ │ ├── models │ │ │ └── index.ts │ │ └── seeders │ │ │ └── 20190101001337-demo-user.js │ ├── redis │ │ ├── app.spec.ts │ │ └── app.ts │ ├── tsconfig.json │ └── yarn.lock │ ├── multiple-resources │ ├── .sequelizerc │ ├── README.md │ ├── docker-compose.yml │ ├── dockest.ts │ ├── jest.config.js │ ├── jest.setup.ts │ ├── kafka-1-kafkajs │ │ ├── app.spec.ts │ │ └── app.ts │ ├── knexfile.js │ ├── package.json │ ├── postgres-1-sequelize │ │ ├── app.spec.ts │ │ ├── app.ts │ │ ├── config │ │ │ └── postgresConfig.js │ │ ├── data.json │ │ ├── migrations │ │ │ └── 20190101001337-create-user.js │ │ ├── models │ │ │ └── index.ts │ │ └── seeders │ │ │ └── 20190101001337-demo-user.js │ ├── postgres-2-knex │ │ ├── app.spec.ts │ │ ├── app.ts │ │ ├── data.json │ │ ├── migrations │ │ │ └── 20190101001337-create-banana.js │ │ ├── models │ │ │ └── index.ts │ │ └── seeders │ │ │ ├── 20180101001337-kill-bananas.js │ │ │ └── 20190101001337-demo-banana.js │ ├── redis-1-ioredis │ │ ├── __snapshots__ │ │ │ └── app.spec.ts.snap │ │ ├── app.spec.ts │ │ ├── app.ts │ │ └── data.json │ ├── tsconfig.json │ └── yarn.lock │ └── node-to-node │ ├── README.md │ ├── docker-compose.yml │ ├── dockest.ts │ ├── index.spec.ts │ ├── index.ts │ ├── jest.config.js │ ├── orders │ ├── .dockerignore │ ├── Dockerfile │ ├── index.js │ ├── package.json │ └── yarn.lock │ ├── package.json │ ├── tsconfig.json │ ├── users │ ├── .dockerignore │ ├── Dockerfile │ ├── index.js │ ├── package.json │ └── yarn.lock │ └── yarn.lock ├── pipeline └── updateNextVersion.js ├── resources └── img │ ├── favicon.png │ ├── logo.png │ └── logo.svg └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | */node_modules 2 | *.log 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | insert_final_newline = false 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | tools 2 | dist 3 | docs 4 | node_modules -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | ### github.ref 4 | # tags refs/tags/v0.0.1 5 | # master refs/heads/master 6 | 7 | on: 8 | push: 9 | tags: 10 | - 'v*' 11 | branches: 12 | - master 13 | pull_request: 14 | branches: 15 | - master 16 | 17 | jobs: 18 | ####### Print: Prints some context relating to the job to make future debugging easier 19 | print: 20 | name: Print context 21 | runs-on: ubuntu-latest 22 | timeout-minutes: 10 23 | 24 | steps: 25 | - name: Install Compose 26 | uses: ndeloof/install-compose-action@v0.0.1 27 | with: 28 | legacy: true # will also install in PATH as `docker-compose` 29 | - uses: actions/checkout@v4 30 | - uses: actions/setup-node@v3 31 | with: 32 | node-version: 18.x 33 | - name: Print context 🎉 34 | env: 35 | GH_REF: ${{ github.ref }} 36 | run: | 37 | echo "GH_REF: ${GH_REF}" 38 | echo "docker-compose version: $(docker-compose version)" 39 | echo "docker version: $(docker version)" 40 | 41 | ####### Lint 42 | lint: 43 | name: Lint 44 | runs-on: ubuntu-latest 45 | timeout-minutes: 10 46 | 47 | steps: 48 | - name: Install Compose 49 | uses: ndeloof/install-compose-action@v0.0.1 50 | with: 51 | legacy: true # will also install in PATH as `docker-compose` 52 | - uses: actions/checkout@v4 53 | - uses: actions/setup-node@v3 54 | with: 55 | node-version: 18.x 56 | - run: yarn prep 57 | - run: yarn lint 58 | 59 | ####### Unit tests 60 | unit_tests: 61 | name: 'Unit tests' 62 | runs-on: ubuntu-latest 63 | timeout-minutes: 10 64 | 65 | steps: 66 | - name: Install Compose 67 | uses: ndeloof/install-compose-action@v0.0.1 68 | with: 69 | legacy: true # will also install in PATH as `docker-compose` 70 | - uses: actions/checkout@v4 71 | - uses: actions/setup-node@v3 72 | with: 73 | node-version: 18.x 74 | registry-url: https://registry.npmjs.org/ 75 | - run: yarn prep 76 | - run: yarn test:unit 77 | 78 | ####### Integration tests 79 | integration_tests: 80 | name: 'Integration tests' 81 | runs-on: ubuntu-latest 82 | timeout-minutes: 10 83 | 84 | steps: 85 | - name: Install Compose 86 | uses: ndeloof/install-compose-action@v0.0.1 87 | with: 88 | legacy: true # will also install in PATH as `docker-compose` 89 | - uses: actions/checkout@v4 90 | - uses: actions/setup-node@v3 91 | with: 92 | node-version: 18.x 93 | registry-url: https://registry.npmjs.org/ 94 | - run: yarn prep 95 | - run: yarn test:examples --concurrency 2 96 | 97 | ####### Publish next to npm 98 | npm_publish_next: 99 | name: Publish next to npm 100 | runs-on: ubuntu-latest 101 | timeout-minutes: 10 102 | needs: [lint, unit_tests, integration_tests] 103 | if: startsWith(github.ref, 'refs/heads/master') 104 | 105 | steps: 106 | - name: Install Compose 107 | uses: ndeloof/install-compose-action@v0.0.1 108 | with: 109 | legacy: true # will also install in PATH as `docker-compose` 110 | - uses: actions/checkout@v4 111 | - uses: actions/setup-node@v3 112 | with: 113 | node-version: 18.x 114 | registry-url: https://registry.npmjs.org/ 115 | - name: yarn publish next 116 | env: 117 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 118 | run: | 119 | yarn install 120 | node pipeline/updateNextVersion.js 121 | cd packages/dockest 122 | yarn install 123 | yarn publish --tag next 124 | 125 | ####### Publish to npm 126 | npm_publish: 127 | name: Publish to npm 128 | runs-on: ubuntu-latest 129 | timeout-minutes: 10 130 | needs: [lint, unit_tests, integration_tests] 131 | if: startsWith(github.ref, 'refs/tags/') 132 | 133 | steps: 134 | - name: Install Compose 135 | uses: ndeloof/install-compose-action@v0.0.1 136 | with: 137 | legacy: true # will also install in PATH as `docker-compose` 138 | - uses: actions/checkout@v4 139 | - uses: actions/setup-node@v3 140 | with: 141 | node-version: 18.x 142 | registry-url: https://registry.npmjs.org/ 143 | - name: yarn publish 144 | env: 145 | IS_ALPHA: ${{ contains(github.ref, 'alpha')}} 146 | IS_BETA: ${{ contains(github.ref, 'beta')}} 147 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 148 | run: | 149 | if [ ${IS_ALPHA} = true ]; then export NPM_TAG="--tag alpha"; fi 150 | if [ ${IS_BETA} = true ]; then export NPM_TAG="--tag beta"; fi 151 | cd packages/dockest 152 | yarn install 153 | yarn publish ${NPM_TAG} 154 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # node.js 6 | # 7 | node_modules 8 | npm-debug.log 9 | yarn-error.log 10 | junit.xml 11 | test-report.xml 12 | .eslintcache 13 | 14 | # Others 15 | # 16 | trash 17 | 18 | \.vscode/ 19 | \.vs/ 20 | 21 | # Dockest dev 22 | # 23 | dist 24 | docker-compose.dockest-generated.yml 25 | docker-compose.dockest-generated-runner.yml 26 | dockest-error.json 27 | dockest.log 28 | 29 | # Webstorm 30 | .idea/* 31 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'max-len': 'off', 3 | printWidth: 120, 4 | semi: true, 5 | singleQuote: true, 6 | tabWidth: 2, 7 | trailingComma: 'all', 8 | proseWrap: 'always', 9 | } 10 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | registry "https://registry.npmjs.org" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Erik Engervall (erik.engervall@gmail.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 7 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 8 | persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 11 | Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 14 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 15 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 16 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3.1.0", 3 | "npmClient": "yarn", 4 | "packages": [ 5 | "packages/dockest", 6 | "packages/examples/**/*" 7 | ], 8 | "concurrency": 1 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dockest-monorepo", 3 | "version": "0.0.0", 4 | "homepage": "https://github.com/erikengervall/dockest#readme", 5 | "bugs": { 6 | "url": "https://github.com/erikengervall/dockest/issues" 7 | }, 8 | "license": "MIT", 9 | "author": "Erik Engervall (https://github.com/erikengervall)", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/erikengervall/dockest.git" 13 | }, 14 | "engines": { 15 | "node": ">=18.0.0" 16 | }, 17 | "scripts": { 18 | "clean": "lerna run clean --stream && lerna clean --yes && rm -rf ./node_modules && yarn cache clean && yarn && yarn prep", 19 | "dev:link": "lerna run dev:dockest:link --stream && lerna run dev:examples:link --stream", 20 | "dev:link:list": "ls -la ~/.config/yarn/link/@examples", 21 | "dev:unlink": "lerna run dev:examples:unlink --stream && lerna run dev:dockest:unlink --stream", 22 | "yarn:locks": "yarn --force && lerna exec \"yarn --force\" --ignore @examples/aws-codebuild--src --ignore @examples/docker-in-docker--src --ignore @examples/docker-in-docker--app", 23 | "yarn:upgrade": "yarn upgrade --latest && lerna exec \"yarn upgrade --latest\" --ignore @examples/aws-codebuild--src --ignore @examples/docker-in-docker--src --ignore @examples/docker-in-docker--app", 24 | "prep:root:install:deps": "yarn", 25 | "prep:root": "yarn prep:root:install:deps", 26 | "prep:dockest:install:deps": "lerna exec \"yarn\" --stream --scope dockest", 27 | "prep:dockest:build": "yarn lerna run build --stream --scope dockest", 28 | "prep:dockest": "yarn prep:dockest:install:deps && yarn prep:dockest:build", 29 | "prep:examples:install:deps": "lerna exec \"yarn\" --stream --scope @examples/* --ignore @examples/aws-codebuild--src --ignore @examples/docker-in-docker--src --ignore @examples/docker-in-docker--app", 30 | "prep:examples:build": "yarn lerna run build --stream --scope @examples/*", 31 | "prep:examples": "yarn prep:examples:install:deps && yarn prep:examples:build", 32 | "prep": "yarn prep:root && yarn prep:dockest && yarn prep:examples", 33 | "lint": "eslint \"./packages/**/*.ts\"", 34 | "test": "yarn test:unit && yarn test:examples", 35 | "test:concurrent": "yarn test:unit && lerna run test:examples --stream --concurrency 10", 36 | "test:examples": "lerna run test:examples --stream --ignore @examples/aws-codebuild --ignore @examples/docker-in-docker", 37 | "test:unit": "lerna run test:unit --stream" 38 | }, 39 | "devDependencies": { 40 | "@typescript-eslint/eslint-plugin": "^6.8.1", 41 | "@typescript-eslint/parser": "^6.8.0", 42 | "eslint": "^8.52.0", 43 | "eslint-config-prettier": "^9.0.0", 44 | "eslint-plugin-import": "^2.28.1", 45 | "eslint-plugin-no-only-tests": "^3.1.0", 46 | "eslint-plugin-prettier": "^5.0.0", 47 | "lerna": "^3.20.2", 48 | "prettier": "^3.0.3", 49 | "prettier-eslint": "^16.1.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | root: true, 4 | extends: [ 5 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 6 | 'plugin:import/errors', // https://github.com/benmosher/eslint-plugin-import 7 | 'plugin:import/warnings', // https://github.com/benmosher/eslint-plugin-import 8 | 'plugin:import/typescript', // https://github.com/benmosher/eslint-plugin-import 9 | 'prettier', 10 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 11 | ], 12 | plugins: ['@typescript-eslint', 'no-only-tests'], 13 | parserOptions: { 14 | ecmaVersion: 2019, // Allows for the parsing of modern ECMAScript features 15 | sourceType: 'module', // Allows for the use of imports 16 | ecmaFeatures: { 17 | modules: true, 18 | }, 19 | }, 20 | env: { 21 | node: true, 22 | es6: true, 23 | jest: true, 24 | }, 25 | rules: { 26 | 'no-console': 'warn', 27 | 28 | 'no-only-tests/no-only-tests': 'error', 29 | 30 | '@typescript-eslint/explicit-function-return-type': 'off', 31 | '@typescript-eslint/no-use-before-define': 'off', 32 | '@typescript-eslint/no-explicit-any': 'off', 33 | '@typescript-eslint/no-empty-interface': 'off', 34 | 35 | 'import/order': ['error', { groups: ['builtin', 'external', 'index', 'sibling', 'parent', 'internal'] }], 36 | 'import/newline-after-import': ['error', { count: 1 }], 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /packages/dockest/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testPathIgnorePatterns: ['/node_modules/'], 5 | roots: ['/src'], 6 | }; 7 | -------------------------------------------------------------------------------- /packages/dockest/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dockest", 3 | "version": "3.1.0", 4 | "main": "dist/index.js", 5 | "module": "dist/index.js", 6 | "engines": { 7 | "node": ">=18.0.0" 8 | }, 9 | "files": [ 10 | "dist/**/*", 11 | "test-helper/**/*", 12 | "readiness-check/**/*" 13 | ], 14 | "description": "Dockest is an integration testing tool aimed at alleviating the process of evaluating unit tests whilst running multi-container Docker applications.", 15 | "keywords": [ 16 | "docker", 17 | "docker-compose", 18 | "jest", 19 | "nodejs", 20 | "node", 21 | "integration testing" 22 | ], 23 | "homepage": "https://erikengervall.github.io/dockest/", 24 | "bugs": { 25 | "url": "https://github.com/erikengervall/dockest/issues" 26 | }, 27 | "license": "MIT", 28 | "author": "Erik Engervall (https://github.com/erikengervall)", 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/erikengervall/dockest.git" 32 | }, 33 | "scripts": { 34 | "prepublishOnly": "yarn build:publish", 35 | "dev:dockest:link": "yarn link", 36 | "dev:dockest:unlink": "yarn unlink", 37 | "clean": "rm -rf dist", 38 | "build": "node_modules/.bin/tsc", 39 | "build:publish": "yarn clean && yarn tsc --project tsconfig.publish.json", 40 | "test:unit": "node_modules/.bin/jest . --forceExit --detectOpenHandles" 41 | }, 42 | "dependencies": { 43 | "chalk": "^3.0.0", 44 | "execa": "^4.0.0", 45 | "is-docker": "^2.0.0", 46 | "js-yaml": "^3.13.1", 47 | "rxjs": "^6.5.4", 48 | "toposort": "^2.0.2", 49 | "zod": "^3.22.4", 50 | "zod-validation-error": "^2.0.0" 51 | }, 52 | "devDependencies": { 53 | "@types/jest": "^24.9.1", 54 | "@types/js-yaml": "^3.12.2", 55 | "@types/node": "^18.11.9", 56 | "@types/toposort": "^2.0.3", 57 | "jest": "^29.5.0", 58 | "mockdate": "^2.0.5", 59 | "ts-jest": "^29.1.0", 60 | "ts-node": "^10.9.1", 61 | "typescript": "^5.1.3" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/dockest/readiness-check/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dockest/readiness-check", 3 | "private": true, 4 | "main": "../dist/readiness-check", 5 | "typings": "../dist/readiness-check/index.d.ts" 6 | } 7 | -------------------------------------------------------------------------------- /packages/dockest/scripts/kebab-case-files-in-dir.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | function isLowerCase(input: string): boolean { 4 | return input === input.toLowerCase() && input !== input.toUpperCase(); 5 | } 6 | 7 | export function kebabCaseFilesInDir({ dirPath }: { dirPath: string }): void { 8 | const filesInDir = fs.readdirSync(dirPath); 9 | for (const filename of filesInDir) { 10 | const newFileName = filename 11 | .split('') 12 | .map((character, characterIndex) => { 13 | if (characterIndex === 0) { 14 | return character.toLowerCase(); 15 | } 16 | 17 | if (['.', '_', '-'].includes(character)) { 18 | return character; 19 | } 20 | 21 | if (!isLowerCase(character)) { 22 | return `-${character.toLowerCase()}`; 23 | } 24 | 25 | return character; 26 | }) 27 | .join(''); 28 | fs.renameSync(`${dirPath}/${filename}`, `${dirPath}/${newFileName}`); 29 | } 30 | } 31 | 32 | const dirPath = process.argv.slice(2)[0]; 33 | kebabCaseFilesInDir({ dirPath }); 34 | -------------------------------------------------------------------------------- /packages/dockest/src/@types.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { Logger } from './logger'; 3 | import { DockerEventEmitter } from './run/bootstrap/create-docker-event-emitter'; 4 | import { DockerServiceEventStream } from './run/bootstrap/create-docker-service-event-stream'; 5 | import { LogWriterModeType } from './run/log-writer'; 6 | 7 | type ContainerId = string; 8 | type ServiceName = string; 9 | 10 | export interface ReadinessCheck { 11 | (args: { runner: Runner }): Promise | Observable; 12 | } 13 | 14 | export interface Runner { 15 | commands: Commands; 16 | containerId: ContainerId; 17 | dependsOn: Runner[]; 18 | dockerComposeFileService: DockerComposeFileService; 19 | dockerEventStream$: DockerServiceEventStream; 20 | logger: Logger; 21 | readinessCheck: ReadinessCheck; 22 | serviceName: ServiceName; 23 | host?: string; 24 | isBridgeNetworkMode?: boolean; 25 | } 26 | 27 | export interface RunnersObj { 28 | [key: string]: Runner; 29 | } 30 | 31 | export type DockerComposePortObjectFormat = { 32 | /** The publicly exposed port */ 33 | published: number; 34 | /** The port inside the container */ 35 | target: number; 36 | }; 37 | 38 | export type DockerComposePortFormat = DockerComposePortObjectFormat; 39 | 40 | export interface DockerComposeFileService { 41 | /** Expose ports */ 42 | ports?: DockerComposePortFormat[]; 43 | [key: string]: any; 44 | } 45 | 46 | export interface DockerComposeFile { 47 | version: string; 48 | services: { 49 | [key: string]: DockerComposeFileService; 50 | }; 51 | } 52 | 53 | export type Commands = (string | ((containerId: string) => string))[]; 54 | 55 | export interface DockestService { 56 | serviceName: ServiceName; 57 | commands?: Commands; 58 | dependsOn?: DockestService[]; 59 | readinessCheck?: ReadinessCheck; 60 | } 61 | 62 | export interface MutablesConfig { 63 | /** Jest has finished executing and has returned a result */ 64 | jestRanWithResult: boolean; 65 | runners: RunnersObj; 66 | dockerEventEmitter: DockerEventEmitter; 67 | runnerLookupMap: Map; 68 | teardownOrder: null | Array; 69 | } 70 | 71 | type Jest = typeof import('jest'); 72 | type JestOpts = Partial[0]>; 73 | 74 | export interface DockestOpts { 75 | composeFile: string | string[]; 76 | logLevel: number; 77 | /** Run dockest sequentially */ 78 | runInBand: boolean; 79 | /** Skip port connectivity checks */ 80 | skipCheckConnection: boolean; 81 | 82 | jestLib: Jest; 83 | 84 | containerLogs: { 85 | /** Method for gathering logs 86 | * "per-service": One log file per service 87 | * "aggregate": One log file for all services 88 | * "pipe-stdout": Pipe logs to stdout 89 | */ 90 | modes: LogWriterModeType[]; 91 | /** Only collect logs for the specified services. */ 92 | serviceNameFilter?: string[]; 93 | /** Where should the logs be written to? Defaults to "./" */ 94 | logPath: string; 95 | }; 96 | 97 | composeOpts: { 98 | /** Recreate dependent containers. Incompatible with --no-recreate. */ 99 | alwaysRecreateDeps: boolean; 100 | /** Build images before starting containers. */ 101 | build: boolean; 102 | /** Recreate containers even if their configuration and image haven't changed. */ 103 | forceRecreate: boolean; 104 | /** Don't build an image, even if it's missing. */ 105 | noBuild: boolean; 106 | /** Produce monochrome output. */ 107 | noColor: boolean; 108 | /** Don't start linked services. */ 109 | noDeps: boolean; 110 | /** If containers already exist, don't recreate them. Incompatible with --force-recreate and -V. */ 111 | noRecreate: boolean; 112 | /** Pull without printing progress information. */ 113 | quietPull: boolean; 114 | }; 115 | 116 | debug?: boolean; 117 | dumpErrors?: boolean; 118 | exitHandler?: null | ((error: ErrorPayload) => any); 119 | 120 | /** https://jestjs.io/docs/en/cli */ 121 | jestOpts: JestOpts; 122 | } 123 | 124 | export type TestRunModeType = 'docker-local-socket' | 'docker-injected-host-socket' | 'host'; 125 | 126 | interface InternalConfig { 127 | hostname: string; 128 | runMode: TestRunModeType; 129 | perfStart: number; 130 | } 131 | 132 | export interface DockestConfig extends InternalConfig, DockestOpts { 133 | jestOpts: JestOpts; 134 | mutables: MutablesConfig; 135 | } 136 | 137 | export interface ErrorPayload { 138 | trap: string; 139 | code?: number; 140 | error?: Error; 141 | promise?: Promise; 142 | reason?: Error | any; 143 | signal?: any; 144 | } 145 | -------------------------------------------------------------------------------- /packages/dockest/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const LOG_LEVEL = { 2 | NOTHING: 0, 3 | ERROR: 1, 4 | WARN: 2, 5 | INFO: 3, 6 | DEBUG: 4, 7 | }; 8 | 9 | export const GENERATED_COMPOSE_FILE_PATH = `${process.cwd()}/docker-compose.dockest-generated.yml`; 10 | 11 | export const DOCKEST_ATTACH_TO_PROCESS = 'DOCKEST_ATTACH_TO_PROCESS'; 12 | 13 | export const BRIDGE_NETWORK_NAME = `dockest_bridge_network`; 14 | 15 | export const DOCKEST_HOST_ADDRESS = 'host.dockest-runner.internal'; 16 | export const DEFAULT_HOST_NAME = 'host.docker.internal'; 17 | 18 | /** 19 | * Released 2017-05-06: https://github.com/facebook/jest/releases/tag/v20.0.0 20 | */ 21 | export const MINIMUM_JEST_VERSION = '20.0.0'; 22 | -------------------------------------------------------------------------------- /packages/dockest/src/errors.ts: -------------------------------------------------------------------------------- 1 | import { DockestConfig, Runner } from './@types'; 2 | import { DockerEventType } from './run/bootstrap/create-docker-event-emitter'; 3 | 4 | export interface Payload { 5 | runner?: Runner; 6 | error?: Error | string; 7 | event?: DockerEventType; 8 | } 9 | 10 | export class BaseError extends Error { 11 | public static DockestConfig: DockestConfig; 12 | public payload: Payload; 13 | 14 | public constructor(message: string, payload: Payload = {}) { 15 | super(message); 16 | this.payload = payload; 17 | 18 | if (Error.captureStackTrace) { 19 | // Maintains proper stack trace for where our error was thrown (only available on V8) 20 | Error.captureStackTrace(this, BaseError); 21 | } 22 | } 23 | } 24 | 25 | export class DockestError extends BaseError { 26 | public constructor(message: string, payload?: Payload) { 27 | super(message, payload); 28 | 29 | this.name = 'DockestError'; 30 | } 31 | } 32 | 33 | export class ConfigurationError extends BaseError { 34 | public constructor(message: string, payload?: Payload) { 35 | super(message, payload); 36 | 37 | this.name = 'ConfigurationError'; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/dockest/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import * as allTheStuff from './index'; 2 | 3 | describe('index', () => { 4 | describe('happy', () => { 5 | it('should match expected interface', () => { 6 | expect(allTheStuff).toMatchInlineSnapshot(` 7 | { 8 | "Dockest": [Function], 9 | "execa": [Function], 10 | "logLevel": { 11 | "DEBUG": 4, 12 | "ERROR": 1, 13 | "INFO": 3, 14 | "NOTHING": 0, 15 | "WARN": 2, 16 | }, 17 | "sleep": [Function], 18 | "sleepWithLog": [Function], 19 | } 20 | `); 21 | }); 22 | 23 | it('should be able to instantiate Dockest without options', () => { 24 | new allTheStuff.Dockest(); 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/dockest/src/index.ts: -------------------------------------------------------------------------------- 1 | import { DockestConfig, DockestOpts, DockestService } from './@types'; 2 | import { MINIMUM_JEST_VERSION } from './constants'; 3 | import { BaseError, ConfigurationError } from './errors'; 4 | import { Logger } from './logger'; 5 | import { bootstrap } from './run/bootstrap'; 6 | import { debugMode } from './run/debug-mode'; 7 | import { createLogWriter } from './run/log-writer'; 8 | import { runJest } from './run/run-jest'; 9 | import { teardown } from './run/teardown'; 10 | import { waitForServices } from './run/wait-for-services'; 11 | import { setGlobalCustomZodErrorMap } from './utils/custom-zod-error-map'; 12 | import { getOpts } from './utils/get-opts'; 13 | 14 | export { DockestService } from './@types'; 15 | export { LOG_LEVEL as logLevel } from './constants'; 16 | export { execaWrapper as execa } from './utils/execa-wrapper'; 17 | export { sleep } from './utils/sleep'; 18 | export { sleepWithLog } from './utils/sleep-with-log'; 19 | 20 | setGlobalCustomZodErrorMap(); 21 | 22 | export class Dockest { 23 | private config: DockestConfig; 24 | 25 | public constructor(opts?: Partial) { 26 | this.config = getOpts(opts); 27 | 28 | Logger.logLevel = this.config.logLevel; 29 | BaseError.DockestConfig = this.config; 30 | 31 | if (this.config.jestLib.getVersion() < MINIMUM_JEST_VERSION) { 32 | throw new ConfigurationError( 33 | `Outdated Jest version (${this.config.jestLib.getVersion()}). Upgrade to at least ${MINIMUM_JEST_VERSION}`, 34 | ); 35 | } 36 | } 37 | 38 | public run = async (dockestServices: DockestService[]) => { 39 | this.config.perfStart = Date.now(); 40 | 41 | const logWriter = createLogWriter({ 42 | mode: this.config.containerLogs.modes, 43 | serviceNameFilter: this.config.containerLogs.serviceNameFilter, 44 | logPath: this.config.containerLogs.logPath, 45 | }); 46 | 47 | const { 48 | composeFile, 49 | composeOpts, 50 | debug, 51 | dumpErrors, 52 | exitHandler, 53 | hostname, 54 | runMode, 55 | jestLib, 56 | jestOpts, 57 | mutables, 58 | perfStart, 59 | runInBand, 60 | skipCheckConnection, 61 | } = this.config; 62 | 63 | await bootstrap({ 64 | composeFile, 65 | dockestServices, 66 | dumpErrors, 67 | exitHandler, 68 | runMode, 69 | mutables, 70 | perfStart, 71 | }); 72 | 73 | await waitForServices({ 74 | composeOpts, 75 | mutables, 76 | hostname, 77 | runMode, 78 | runInBand, 79 | skipCheckConnection, 80 | logWriter, 81 | }); 82 | await debugMode({ debug, mutables }); 83 | const { success } = await runJest({ jestLib, jestOpts, mutables }); 84 | await teardown({ hostname, runMode, mutables, perfStart, logWriter }); 85 | 86 | success ? process.exit(0) : process.exit(1); 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /packages/dockest/src/logger.ts: -------------------------------------------------------------------------------- 1 | import readline from 'readline'; 2 | import { greenBright, redBright, yellowBright } from 'chalk'; 3 | import { LOG_LEVEL } from './constants'; 4 | 5 | interface LoggerPayload { 6 | data?: { 7 | [key: string]: any; 8 | }; 9 | endingNewLines?: number; 10 | service?: string; 11 | startingNewLines?: number; 12 | success?: boolean; 13 | symbol?: string; 14 | } 15 | 16 | export type LogMethod = (message: string, payload?: LoggerPayload) => void; 17 | 18 | const getLogArgs = (message: string, payload: LoggerPayload): string[] => { 19 | const { data = undefined, service, symbol, endingNewLines = 0, startingNewLines = 0, success } = payload; 20 | let logArgs: string[] = []; 21 | 22 | if (startingNewLines > 0) { 23 | logArgs = logArgs.concat(new Array(startingNewLines).fill('\n')); 24 | } 25 | 26 | const derivedService = service || 'Dockest'; 27 | const derivedSymbol = symbol || '🌈'; 28 | logArgs.push(`${derivedSymbol} ${derivedService} ${derivedSymbol} ${success ? greenBright(message) : message}`); 29 | 30 | if (data && Logger.logLevel === LOG_LEVEL.DEBUG) { 31 | logArgs.push(JSON.stringify(data, null, 2)); 32 | } 33 | 34 | if (endingNewLines > 0) { 35 | logArgs = logArgs.concat(new Array(endingNewLines).fill('\n')); 36 | } 37 | 38 | return logArgs; 39 | }; 40 | 41 | export class Logger { 42 | public static logLevel: number = LOG_LEVEL.INFO; 43 | 44 | public static error: LogMethod = (message, payload = {}) => { 45 | if (Logger.logLevel >= LOG_LEVEL.ERROR) { 46 | console.error(...getLogArgs(message, payload).map((logArg) => redBright(logArg))); // eslint-disable-line no-console 47 | } 48 | }; 49 | 50 | public static warn: LogMethod = (message, payload = {}) => { 51 | if (Logger.logLevel >= LOG_LEVEL.WARN) { 52 | console.warn(...getLogArgs(message, payload).map((logArg) => yellowBright(logArg))); // eslint-disable-line no-console 53 | } 54 | }; 55 | 56 | public static info: LogMethod = (message, payload = {}) => { 57 | if (Logger.logLevel >= LOG_LEVEL.INFO) { 58 | console.info(...getLogArgs(message, payload)); // eslint-disable-line no-console 59 | } 60 | }; 61 | 62 | public static debug: LogMethod = (message, payload = {}) => { 63 | if (Logger.logLevel >= LOG_LEVEL.DEBUG) { 64 | console.debug(...getLogArgs(message, payload)); // eslint-disable-line no-console 65 | } 66 | }; 67 | 68 | public static replacePrevLine = ({ message, isLast = false }: { message: string; isLast?: boolean }) => { 69 | readline.cursorTo(process.stdout, 0, undefined); 70 | process.stdout.write(message); 71 | 72 | if (isLast) { 73 | process.stdout.write('\n\n'); 74 | } 75 | }; 76 | 77 | public static measurePerformance = (perfStart: number, opts: { logPrefix?: string } = {}) => { 78 | if (perfStart !== 0) { 79 | const perfTime = Math.floor((Date.now() - perfStart) / 1000); 80 | let hours: number | string = Math.floor(perfTime / 3600); 81 | let minutes: number | string = Math.floor((perfTime - hours * 3600) / 60); 82 | let seconds: number | string = perfTime - hours * 3600 - minutes * 60; 83 | 84 | if (hours < 10) { 85 | hours = `0${hours}`; 86 | } 87 | if (minutes < 10) { 88 | minutes = `0${minutes}`; 89 | } 90 | if (seconds < 10) { 91 | seconds = `0${seconds}`; 92 | } 93 | 94 | Logger.info(`${opts.logPrefix || ''} Elapsed time: ${hours}:${minutes}:${seconds}`); 95 | } 96 | }; 97 | 98 | private serviceName = ''; 99 | private runnerSymbol = '🦇 '; 100 | public constructor(serviceName?: string) { 101 | this.serviceName = serviceName || 'UNKNOWN'; 102 | } 103 | 104 | public setRunnerSymbol = (symbol: string) => { 105 | this.runnerSymbol = symbol; 106 | }; 107 | 108 | public error: LogMethod = (message, payload = {}) => 109 | Logger.error(message, { ...payload, service: this.serviceName, symbol: this.runnerSymbol }); 110 | 111 | public warn: LogMethod = (message, payload = {}) => 112 | Logger.warn(message, { ...payload, service: this.serviceName, symbol: this.runnerSymbol }); 113 | 114 | public info: LogMethod = (message, payload = {}) => 115 | Logger.info(message, { ...payload, service: this.serviceName, symbol: this.runnerSymbol }); 116 | 117 | public debug: LogMethod = (message, payload = {}) => 118 | Logger.debug(message, { ...payload, service: this.serviceName, symbol: this.runnerSymbol }); 119 | } 120 | -------------------------------------------------------------------------------- /packages/dockest/src/readiness-check/container-is-healthy-readiness-check.spec.ts: -------------------------------------------------------------------------------- 1 | import { Observable, ReplaySubject, from } from 'rxjs'; 2 | import { containerIsHealthyReadinessCheck } from './container-is-healthy-readiness-check'; 3 | import { createRunner } from '../test-utils'; 4 | 5 | const toPromise = (input: Promise | Observable): Promise => from(input).toPromise(); 6 | 7 | describe('containerIsHealthyReadinessCheck', () => { 8 | it('fails when the die event is emitted', (done) => { 9 | const dockerEventsStream$ = new ReplaySubject(); 10 | const runner = createRunner({ dockerEventStream$: dockerEventsStream$ as any }); 11 | 12 | dockerEventsStream$.next({ service: runner.serviceName, action: 'kill' }); 13 | 14 | toPromise(containerIsHealthyReadinessCheck({ runner })) 15 | .then(() => { 16 | done.fail('Should throw.'); 17 | }) 18 | .catch((err) => { 19 | expect(err.message).toEqual('Container unexpectedly died.'); 20 | done(); 21 | }); 22 | }); 23 | 24 | it('fails when the kill event is emitted', (done) => { 25 | const dockerEventsStream$ = new ReplaySubject(); 26 | const runner = createRunner({ dockerEventStream$: dockerEventsStream$ as any }); 27 | dockerEventsStream$.next({ service: runner.serviceName, action: 'die' }); 28 | 29 | toPromise(containerIsHealthyReadinessCheck({ runner })) 30 | .then(() => { 31 | done.fail('Should throw.'); 32 | }) 33 | .catch((err) => { 34 | expect(err.message).toEqual('Container unexpectedly died.'); 35 | done(); 36 | }); 37 | }); 38 | 39 | it('succeeds when the health_status event is emitted', async () => { 40 | const dockerEventStream$ = new ReplaySubject(); 41 | const runner = createRunner({ dockerEventStream$: dockerEventStream$ as any }); 42 | dockerEventStream$.next({ 43 | service: runner.serviceName, 44 | action: 'health_status', 45 | attributes: { healthStatus: 'healthy' }, 46 | }); 47 | const result = await toPromise(containerIsHealthyReadinessCheck({ runner })); 48 | expect(result).toEqual(undefined); 49 | }); 50 | 51 | it('does not resolve in case a unhealthy event is emitted', (done) => { 52 | const dockerEventStream$ = new ReplaySubject(); 53 | const runner = createRunner({ dockerEventStream$: dockerEventStream$ as any }); 54 | 55 | let healthCheckDidResolve = false; 56 | 57 | toPromise(containerIsHealthyReadinessCheck({ runner })) 58 | .then((result) => { 59 | expect(result).toEqual(undefined); 60 | healthCheckDidResolve = true; 61 | }) 62 | .catch((err) => { 63 | done.fail(err); 64 | }); 65 | 66 | dockerEventStream$.next({ 67 | service: runner.serviceName, 68 | action: 'health_status', 69 | attributes: { healthStatus: 'unhealthy' }, 70 | }); 71 | 72 | runner.dockerEventStream$.subscribe((event) => { 73 | if (event.action === 'health_status') { 74 | if (event.attributes.healthStatus === 'unhealthy') { 75 | expect(healthCheckDidResolve).toEqual(false); 76 | dockerEventStream$.next({ 77 | service: runner.serviceName, 78 | action: 'health_status', 79 | attributes: { healthStatus: 'healthy' }, 80 | }); 81 | } else if (event.attributes.healthStatus === 'healthy') { 82 | // defer so this check is run after the healthcheck promise did resolve 83 | Promise.resolve().then(() => { 84 | expect(healthCheckDidResolve).toEqual(true); 85 | done(); 86 | }); 87 | } 88 | } else { 89 | done.fail('Unexpected Event was emitted'); 90 | } 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /packages/dockest/src/readiness-check/container-is-healthy-readiness-check.ts: -------------------------------------------------------------------------------- 1 | import { filter, mapTo, take } from 'rxjs/operators'; 2 | import { withNoStop } from './with-no-stop'; 3 | import { ReadinessCheck } from '../@types'; 4 | 5 | export const containerIsHealthyReadinessCheck: ReadinessCheck = withNoStop(({ runner }) => { 6 | return runner.dockerEventStream$.pipe( 7 | filter((event) => event.action === 'health_status' && event.attributes.healthStatus === 'healthy'), 8 | mapTo(undefined), 9 | take(1), 10 | ); 11 | }); 12 | -------------------------------------------------------------------------------- /packages/dockest/src/readiness-check/create-postgres-readiness-check.ts: -------------------------------------------------------------------------------- 1 | import { withNoStop } from './with-no-stop'; 2 | import { withRetry } from './with-retry'; 3 | import { ReadinessCheck, Runner } from '../@types'; 4 | import { execaWrapper } from '../utils/execa-wrapper'; 5 | 6 | type Config = { 7 | POSTGRES_DB: string; 8 | POSTGRES_USER: string; 9 | POSTGRES_PASSWORD?: string; 10 | }; 11 | 12 | type MaybePromise = T | Promise; 13 | 14 | type ConfigMapper = Config | ((dockerComposeFileService: Runner) => MaybePromise); 15 | 16 | const defaultConfigMapper: ConfigMapper = (runner) => { 17 | return runner.dockerComposeFileService.environment; 18 | }; 19 | 20 | const postgresReadinessCheck = 21 | (configMapper: ConfigMapper): ReadinessCheck => 22 | async ({ runner }) => { 23 | const config = await (typeof configMapper === 'function' ? configMapper(runner) : configMapper); 24 | 25 | if (!config?.POSTGRES_DB) { 26 | throw new Error("Value 'POSTGRES_DB' was not provided."); 27 | } 28 | if (!config?.POSTGRES_USER) { 29 | throw new Error("Value 'POSTGRES_USER' was not provided."); 30 | } 31 | 32 | const passwordEnvironmentVariable = config?.POSTGRES_PASSWORD ? `PGPASSWORD='${config.POSTGRES_PASSWORD}'` : ''; 33 | // Ref: http://postgresguide.com/utilities/psql.html 34 | const command = `docker exec ${runner.containerId} bash -c " \ 35 | ${passwordEnvironmentVariable} psql \ 36 | -h localhost \ 37 | -d ${config.POSTGRES_DB} \ 38 | -U ${config.POSTGRES_USER} \ 39 | -c 'select 1'"`; 40 | 41 | execaWrapper(command, { runner: runner }); 42 | }; 43 | 44 | export const createPostgresReadinessCheck = (args?: { config?: ConfigMapper; retryCount?: number }): ReadinessCheck => { 45 | return withNoStop( 46 | withRetry(postgresReadinessCheck(args?.config ?? defaultConfigMapper), { 47 | retryCount: args?.retryCount ?? 30, 48 | }), 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /packages/dockest/src/readiness-check/create-redis-readiness-check.ts: -------------------------------------------------------------------------------- 1 | import { withNoStop } from './with-no-stop'; 2 | import { withRetry } from './with-retry'; 3 | import { ReadinessCheck, Runner } from '../@types'; 4 | import { execaWrapper } from '../utils/execa-wrapper'; 5 | 6 | type PortConfig = number | ((runner: Runner) => MaybePromise); 7 | type MaybePromise = T | Promise; 8 | 9 | const defaultPortConfig: PortConfig = () => 6379; 10 | 11 | const redisReadinessCheck = 12 | (portConfig: PortConfig): ReadinessCheck => 13 | async (args) => { 14 | const port = await (typeof portConfig === 'function' ? portConfig(args.runner) : portConfig); 15 | const command = `docker exec ${args.runner.containerId} redis-cli \ 16 | -h localhost \ 17 | -p ${port} \ 18 | PING`; 19 | 20 | execaWrapper(command, { runner: args.runner }); 21 | }; 22 | 23 | export const createRedisReadinessCheck = (args?: { port?: PortConfig; retryCount?: number }): ReadinessCheck => 24 | withNoStop( 25 | withRetry(redisReadinessCheck(args?.port ?? defaultPortConfig), { 26 | retryCount: args?.retryCount ?? 30, 27 | }), 28 | ); 29 | -------------------------------------------------------------------------------- /packages/dockest/src/readiness-check/create-web-readiness-check.ts: -------------------------------------------------------------------------------- 1 | import { withNoStop } from './with-no-stop'; 2 | import { withRetry } from './with-retry'; 3 | import { ReadinessCheck, Runner } from '../@types'; 4 | import { execaWrapper } from '../utils/execa-wrapper'; 5 | 6 | type PortConfig = number | ((runner: Runner) => MaybePromise); 7 | type MaybePromise = T | Promise; 8 | 9 | const webReadinessCheck = 10 | (portConfig: PortConfig): ReadinessCheck => 11 | async (args) => { 12 | const port = await (typeof portConfig === 'function' ? portConfig(args.runner) : portConfig); 13 | const command = `docker exec ${args.runner.containerId} \ 14 | sh -c "wget --quiet --tries=1 --spider http://localhost:${port}/.well-known/healthcheck"`; 15 | 16 | execaWrapper(command, { runner: args.runner }); 17 | }; 18 | 19 | export const createWebReadinessCheck = (args?: { port?: PortConfig; retryCount?: number }): ReadinessCheck => 20 | withNoStop(withRetry(webReadinessCheck(args?.port ?? 3000), { retryCount: args?.retryCount ?? 30 })); 21 | -------------------------------------------------------------------------------- /packages/dockest/src/readiness-check/index.ts: -------------------------------------------------------------------------------- 1 | export { withNoStop } from './with-no-stop'; 2 | export { withRetry } from './with-retry'; 3 | 4 | // default readiness checks 5 | export { containerIsHealthyReadinessCheck } from './container-is-healthy-readiness-check'; 6 | export { createPostgresReadinessCheck } from './create-postgres-readiness-check'; 7 | export { createRedisReadinessCheck } from './create-redis-readiness-check'; 8 | export { createWebReadinessCheck } from './create-web-readiness-check'; 9 | export { zeroExitCodeReadinessCheck } from './zero-exit-code-readiness-check'; 10 | -------------------------------------------------------------------------------- /packages/dockest/src/readiness-check/with-no-stop.ts: -------------------------------------------------------------------------------- 1 | import { from, race } from 'rxjs'; 2 | import { filter, map } from 'rxjs/operators'; 3 | import { ReadinessCheck } from '../@types'; 4 | import { DockestError } from '../errors'; 5 | import { isDieEvent, isKillEvent } from '../run/bootstrap/create-docker-event-emitter'; 6 | 7 | /** 8 | * The wrapped readiness check will fail if the container dies or gets killed. 9 | */ 10 | export const withNoStop = 11 | (input: ReadinessCheck): ReadinessCheck => 12 | (args) => 13 | race( 14 | from(input(args)), 15 | args.runner.dockerEventStream$.pipe( 16 | filter((event) => isDieEvent(event) || isKillEvent(event)), 17 | map((event) => { 18 | throw new DockestError('Container unexpectedly died.', { runner: args.runner, event }); 19 | }), 20 | ), 21 | ); 22 | -------------------------------------------------------------------------------- /packages/dockest/src/readiness-check/with-retry.ts: -------------------------------------------------------------------------------- 1 | import { from, of } from 'rxjs'; 2 | import { delay, mergeMap, retryWhen, takeWhile, tap } from 'rxjs/operators'; 3 | import { ReadinessCheck } from '../@types'; 4 | import { DockestError } from '../errors'; 5 | 6 | const LOG_PREFIX = '[Readiness Retry]'; 7 | 8 | /** 9 | * Retry a readiness check for the specified amount before failing/succeeding. 10 | */ 11 | export const withRetry = 12 | ( 13 | input: ReadinessCheck, 14 | opts: { 15 | retryCount: number; 16 | }, 17 | ): ReadinessCheck => 18 | (args) => { 19 | return of(input).pipe( 20 | mergeMap((readinessCheck) => from(readinessCheck(args))), 21 | retryWhen((errors) => { 22 | let retries = 0; 23 | 24 | return errors.pipe( 25 | tap((value) => { 26 | retries = retries + 1; 27 | args.runner.logger.warn(`${LOG_PREFIX} Error: ${value.message}`); 28 | args.runner.logger.debug(`${LOG_PREFIX} Timeout after ${ 29 | opts.retryCount - retries 30 | } retries (Retry count set to ${opts.retryCount}). 31 | `); 32 | }), 33 | takeWhile(() => { 34 | if (retries < opts.retryCount) { 35 | return true; 36 | } 37 | 38 | throw new DockestError(`${LOG_PREFIX} Timed out`, { runner: args.runner }); 39 | }), 40 | delay(1000), 41 | ); 42 | }), 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /packages/dockest/src/readiness-check/zero-exit-code-readiness-check.spec.ts: -------------------------------------------------------------------------------- 1 | import { Observable, ReplaySubject, from } from 'rxjs'; 2 | import { zeroExitCodeReadinessCheck } from './zero-exit-code-readiness-check'; 3 | import { createRunner } from '../test-utils'; 4 | 5 | const toPromise = (input: Promise | Observable): Promise => from(input).toPromise(); 6 | 7 | describe('happy', () => { 8 | it('succeeds when a "0" die event is emitted', async () => { 9 | const dockerEventsStream$ = new ReplaySubject(); 10 | const runner = createRunner({ dockerEventStream$: dockerEventsStream$ as any }); 11 | 12 | dockerEventsStream$.next({ service: runner.serviceName, action: 'die', attributes: { exitCode: '0' } }); 13 | 14 | try { 15 | await toPromise(zeroExitCodeReadinessCheck({ runner })); 16 | } catch (error) { 17 | expect(true).toBe("Shouldn't throw."); 18 | } 19 | }); 20 | }); 21 | 22 | describe('sad', () => { 23 | it('fails when a non "0" die event is emitted', async () => { 24 | const dockerEventsStream$ = new ReplaySubject(); 25 | const runner = createRunner({ dockerEventStream$: dockerEventsStream$ as any }); 26 | 27 | dockerEventsStream$.next({ service: runner.serviceName, action: 'die', attributes: { exitCode: '1' } }); 28 | 29 | try { 30 | await toPromise(zeroExitCodeReadinessCheck({ runner })); 31 | expect(true).toBe('Should throw.'); 32 | } catch (error) { 33 | expect(error).toMatchInlineSnapshot(`[DockestError: Container exited with the wrong exit code '1'.]`); 34 | } 35 | }); 36 | 37 | it('fails when a kill event is emitted', async () => { 38 | const dockerEventsStream$ = new ReplaySubject(); 39 | const runner = createRunner({ dockerEventStream$: dockerEventsStream$ as any }); 40 | 41 | dockerEventsStream$.next({ service: runner.serviceName, action: 'kill' }); 42 | 43 | try { 44 | await toPromise(zeroExitCodeReadinessCheck({ runner })); 45 | expect(true).toBe('Should throw.'); 46 | } catch (error) { 47 | expect(error).toMatchInlineSnapshot(`[DockestError: Received kill event.]`); 48 | } 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /packages/dockest/src/readiness-check/zero-exit-code-readiness-check.ts: -------------------------------------------------------------------------------- 1 | import { race } from 'rxjs'; 2 | import { filter, map, mapTo, take } from 'rxjs/operators'; 3 | import { ReadinessCheck } from '../@types'; 4 | import { DockestError } from '../errors'; 5 | import { isDieEvent, isKillEvent } from '../run/bootstrap/create-docker-event-emitter'; 6 | 7 | /** 8 | * A readiness check that succeeds when the service exits with the exit code 0. 9 | */ 10 | export const zeroExitCodeReadinessCheck: ReadinessCheck = (args) => 11 | race( 12 | args.runner.dockerEventStream$.pipe( 13 | filter(isDieEvent), 14 | map((event) => { 15 | if (event.attributes.exitCode !== '0') { 16 | throw new DockestError(`Container exited with the wrong exit code '${event.attributes.exitCode}'.`, { 17 | runner: args.runner, 18 | event, 19 | }); 20 | } 21 | return event; 22 | }), 23 | mapTo(undefined), 24 | // complete stream (promise) after first successful health_status event was emitted. 25 | take(1), 26 | ), 27 | args.runner.dockerEventStream$.pipe( 28 | filter(isKillEvent), 29 | map((event) => { 30 | throw new DockestError(`Received kill event.`, { 31 | runner: args.runner, 32 | event, 33 | }); 34 | }), 35 | ), 36 | ); 37 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/__snapshots__/get-parsed-compose-file.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getParsedComposeFile happy should throw error for old port format 1`] = ` 4 | [DockestError: Invalid Composefile 5 | [ZodValidationError for "ComposeFile"] 6 | Expected object, received string at "services.redis_old.ports[0]"] 7 | `; 8 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/configure-logger.ts: -------------------------------------------------------------------------------- 1 | import { DockestConfig } from '../../@types'; 2 | import { hashCode } from '../../utils/hash-code'; 3 | 4 | const LOG_SYMBOLS: readonly string[] = [ 5 | '🐉 ', 6 | '🐒 ', 7 | '🐙 ', 8 | '🐞 ', 9 | '🐥 ', 10 | '🐼 ', 11 | '🐿 ', 12 | '🦂 ', 13 | '🦃 ', 14 | '🦄 ', 15 | '🦊 ', 16 | '🦋 ', 17 | '🦍 ', 18 | '🦖 ', 19 | '🦚 ', 20 | ]; 21 | 22 | export const configureLogger = (runners: DockestConfig['mutables']['runners']) => { 23 | let LOG_SYMBOLS_CLONE = LOG_SYMBOLS.slice(0); 24 | 25 | Object.values(runners).forEach(({ serviceName, logger }) => { 26 | const nameHash = Math.abs(hashCode(serviceName)); 27 | 28 | if (LOG_SYMBOLS_CLONE.length === 0) { 29 | LOG_SYMBOLS_CLONE = LOG_SYMBOLS.slice(0); 30 | } 31 | 32 | const index = nameHash % LOG_SYMBOLS_CLONE.length; 33 | const LOG_SYMBOL = LOG_SYMBOLS_CLONE.splice(index, 1)[0]; 34 | 35 | logger.setRunnerSymbol(LOG_SYMBOL); 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/create-docker-event-emitter.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import execa from 'execa'; /* eslint-disable-line import/default */ 3 | 4 | const parseJsonSafe = (data: string) => { 5 | try { 6 | return JSON.parse(data); 7 | } catch (err) { 8 | return null; 9 | } 10 | }; 11 | 12 | export interface DockerComposeEventInterface< 13 | TActionName extends string, 14 | TAdditionalAttributes extends Record = Record, 15 | > { 16 | time: string; 17 | type: 'container'; 18 | action: TActionName; 19 | id: string; 20 | service: string; 21 | attributes: { 22 | image: string; 23 | name: string; 24 | } & TAdditionalAttributes; 25 | } 26 | 27 | export type CreateDockerComposeEvent = DockerComposeEventInterface<'create'>; 28 | export type AttachDockerComposeEvent = DockerComposeEventInterface<'attach'>; 29 | export type StartDockerComposeEvent = DockerComposeEventInterface<'start'>; 30 | export type HealthStatusDockerComposeEvent = DockerComposeEventInterface< 31 | 'health_status', 32 | { healthStatus: 'healthy' | 'unhealthy' } 33 | >; 34 | export type KillDockerComposeEvent = DockerComposeEventInterface<'kill'>; 35 | export type DieDockerComposeEvent = DockerComposeEventInterface< 36 | 'die', 37 | { 38 | exitCode: string; 39 | } 40 | >; 41 | 42 | export type DockerEventType = 43 | | CreateDockerComposeEvent 44 | | AttachDockerComposeEvent 45 | | StartDockerComposeEvent 46 | | HealthStatusDockerComposeEvent 47 | | KillDockerComposeEvent 48 | | DieDockerComposeEvent; 49 | 50 | export type UnknownDockerComposeEvent = DockerComposeEventInterface; 51 | 52 | export type DockerEventEmitterListener = (event: DockerEventType) => void; 53 | 54 | export interface DockerEventEmitter { 55 | addListener(serviceName: string, eventListener: DockerEventEmitterListener): void; 56 | removeListener(serviceName: string, eventListener: DockerEventEmitterListener): void; 57 | destroy(): void; 58 | } 59 | 60 | export const isDieEvent = (event: DockerEventType): event is DieDockerComposeEvent => event.action === 'die'; 61 | export const isKillEvent = (event: DockerEventType): event is KillDockerComposeEvent => event.action === 'kill'; 62 | 63 | export const createDockerEventEmitter = (composeFilePath: string): DockerEventEmitter => { 64 | const command = `docker-compose --file ${composeFilePath} events --json`; 65 | 66 | const childProcess = execa(command, { shell: true, reject: false }); 67 | 68 | if (!childProcess.stdout) { 69 | childProcess.kill(); 70 | throw new Error('Event Process has not output stream.'); 71 | } 72 | 73 | const emitter = new EventEmitter(); 74 | 75 | // without this line only the first data event is fired (in some undefinable cases) 76 | childProcess.then(() => { 77 | return undefined; 78 | }); 79 | 80 | childProcess.stdout.addListener('data', (chunk) => { 81 | const lines: string[] = chunk.toString().split(`\n`).filter(Boolean); 82 | 83 | for (const line of lines) { 84 | const data: UnknownDockerComposeEvent = parseJsonSafe(line); 85 | if (!data) return; 86 | 87 | // convert health status to friendlier format 88 | if (data.action.startsWith('health_status: ')) { 89 | const healthStatus = data.action 90 | .replace('health_status: ', '') 91 | .trim() as HealthStatusDockerComposeEvent['attributes']['healthStatus']; 92 | data.action = 'health_status'; 93 | (data as HealthStatusDockerComposeEvent).attributes.healthStatus = healthStatus; 94 | } 95 | 96 | emitter.emit(data.service, data); 97 | } 98 | }); 99 | 100 | return Object.assign(emitter, { 101 | destroy: () => childProcess.cancel(), 102 | }); 103 | }; 104 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/create-docker-service-event-stream.ts: -------------------------------------------------------------------------------- 1 | import { fromEventPattern, Observable } from 'rxjs'; 2 | import { shareReplay } from 'rxjs/operators'; 3 | import { DockerEventEmitter, DockerEventType } from './create-docker-event-emitter'; 4 | 5 | export type DockerServiceEventStream = Observable; 6 | 7 | export const createDockerServiceEventStream = ( 8 | serviceName: string, 9 | eventEmitter: DockerEventEmitter, 10 | ): DockerServiceEventStream => { 11 | return ( 12 | fromEventPattern( 13 | (handler) => { 14 | eventEmitter.addListener(serviceName, handler); 15 | }, 16 | (handler) => { 17 | eventEmitter.removeListener(serviceName, handler); 18 | }, 19 | ) 20 | // Every new subscriber should receive access to all previous emitted events, because of this we use shareReplay. 21 | .pipe(shareReplay()) 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/get-compose-files-with-version.spec.ts: -------------------------------------------------------------------------------- 1 | import { getComposeFilesWithVersion } from './get-compose-files-with-version'; 2 | import { DOCKER_COMPOSE_FILE } from '../../test-utils'; 3 | 4 | const nodeProcess: any = { cwd: () => __dirname }; 5 | 6 | describe('getComposeFilesWithVersion', () => { 7 | it('should inject version into the mergedComposeFiles if "docker compose config" trimmed version', () => { 8 | const { dockerComposeFileWithVersion } = getComposeFilesWithVersion( 9 | 'merge-compose-files.spec.yml', 10 | { ...DOCKER_COMPOSE_FILE, version: undefined }, 11 | nodeProcess, 12 | ); 13 | 14 | expect(dockerComposeFileWithVersion).toMatchInlineSnapshot(` 15 | { 16 | "services": { 17 | "redis": { 18 | "image": "redis:5.0.3-alpine", 19 | "ports": [ 20 | { 21 | "published": 6379, 22 | "target": 6379, 23 | }, 24 | ], 25 | }, 26 | }, 27 | "version": "3.8", 28 | } 29 | `); 30 | }); 31 | 32 | it('should not inject version into the mergedComposeFiles it wasnt trimmed', () => { 33 | const { dockerComposeFileWithVersion } = getComposeFilesWithVersion( 34 | 'merge-compose-files.spec.yml', 35 | { ...DOCKER_COMPOSE_FILE, version: '3.9' }, 36 | nodeProcess, 37 | ); 38 | 39 | expect(dockerComposeFileWithVersion).toMatchInlineSnapshot(` 40 | { 41 | "services": { 42 | "redis": { 43 | "image": "redis:5.0.3-alpine", 44 | "ports": [ 45 | { 46 | "published": 6379, 47 | "target": 6379, 48 | }, 49 | ], 50 | }, 51 | }, 52 | "version": "3.9", 53 | } 54 | `); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/get-compose-files-with-version.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import path from 'path'; 3 | import { DockerComposeFile, DockestConfig } from '../../@types'; 4 | import { DockestError } from '../../errors'; 5 | import { Logger } from '../../logger'; 6 | 7 | const DOCKEST_COMPOSE_FILE_VERSION = 3.8; 8 | const VERSION_REG_EXP = /version: .?(\d\.\d|\d).?/; 9 | 10 | /** 11 | * `docker-compose config` trims the `version` property, so we need to inject it before outputting the generated compose file 12 | */ 13 | export function getComposeFilesWithVersion( 14 | composeFile: DockestConfig['composeFile'], 15 | dockerComposeFile: Omit & { version?: string }, 16 | /** @testable */ 17 | nodeProcess = process, 18 | ): { 19 | dockerComposeFileWithVersion: DockerComposeFile; 20 | } { 21 | let versionNumber: string | number | undefined = dockerComposeFile.version; 22 | 23 | if (!versionNumber) { 24 | const firstComposeFile = Array.isArray(composeFile) ? composeFile[0] : composeFile; 25 | const firstComposeFileContent = readFileSync(path.join(nodeProcess.cwd(), firstComposeFile), { encoding: 'utf8' }); 26 | const versionMatch = firstComposeFileContent.match(VERSION_REG_EXP); 27 | 28 | if (!versionMatch) { 29 | throw new DockestError(`Unable to find required field 'version' field in ${firstComposeFile}`); 30 | } 31 | 32 | versionNumber = versionMatch[1]; 33 | } 34 | 35 | versionNumber = parseFloat(versionNumber); 36 | if (Math.trunc(versionNumber) < 3) { 37 | throw new DockestError(`Incompatible docker-compose file version (${versionNumber}). Please use >=3`); 38 | } else if (versionNumber < DOCKEST_COMPOSE_FILE_VERSION) { 39 | Logger.warn( 40 | `Outdated docker-compose file version (${versionNumber}). Automatically upgraded to '${DOCKEST_COMPOSE_FILE_VERSION}'.`, 41 | ); 42 | 43 | versionNumber = DOCKEST_COMPOSE_FILE_VERSION; 44 | } 45 | 46 | return { 47 | dockerComposeFileWithVersion: { 48 | ...dockerComposeFile, 49 | version: versionNumber.toString(), 50 | }, 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/get-parsed-compose-file.spec.ts: -------------------------------------------------------------------------------- 1 | import { getParsedComposeFile } from './get-parsed-compose-file'; 2 | 3 | const COMPOSE_FILE = ` 4 | version: '3.8' 5 | services: 6 | redis: 7 | image: redis:5.0.3-alpine 8 | ports: 9 | - published: 6379 10 | target: 6379 11 | `; 12 | const COMPOSE_FILE2 = ` 13 | version: '3.8' 14 | services: 15 | redis: 16 | image: redis:5.0.3-alpine 17 | ports: 18 | - published: 6379 19 | target: '6379' 20 | `; 21 | const COMPOSE_FILE_WITH_OLD_PORT_FORMAT = ` 22 | version: '3.8' 23 | services: 24 | redis_old: 25 | image: redis:5.0.3-alpine 26 | ports: 27 | - 6379:1337 28 | `; 29 | 30 | describe('getParsedComposeFile', () => { 31 | describe('happy', () => { 32 | it('should work', () => { 33 | const { dockerComposeFile } = getParsedComposeFile(COMPOSE_FILE); 34 | 35 | expect(dockerComposeFile).toMatchInlineSnapshot(` 36 | { 37 | "services": { 38 | "redis": { 39 | "image": "redis:5.0.3-alpine", 40 | "ports": [ 41 | { 42 | "published": 6379, 43 | "target": 6379, 44 | }, 45 | ], 46 | }, 47 | }, 48 | "version": "3.8", 49 | } 50 | `); 51 | }); 52 | 53 | it('should handle string ports', () => { 54 | const { dockerComposeFile } = getParsedComposeFile(COMPOSE_FILE2); 55 | 56 | expect(dockerComposeFile).toMatchInlineSnapshot(` 57 | { 58 | "services": { 59 | "redis": { 60 | "image": "redis:5.0.3-alpine", 61 | "ports": [ 62 | { 63 | "published": 6379, 64 | "target": 6379, 65 | }, 66 | ], 67 | }, 68 | }, 69 | "version": "3.8", 70 | } 71 | `); 72 | }); 73 | 74 | it('should throw error for old port format', () => { 75 | try { 76 | getParsedComposeFile(COMPOSE_FILE_WITH_OLD_PORT_FORMAT); 77 | 78 | expect(true).toEqual(false); 79 | } catch (error) { 80 | expect(error).toMatchSnapshot(); 81 | } 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/get-parsed-compose-file.ts: -------------------------------------------------------------------------------- 1 | import { safeLoad } from 'js-yaml'; 2 | import { z } from 'zod'; 3 | import { DockestError } from '../../errors'; 4 | import { formatZodError } from '../../utils/format-zod-error'; 5 | 6 | const StringNumber = z.union([z.number(), z.string()]).transform((port) => { 7 | if (typeof port === 'string') { 8 | return parseInt(port, 10); 9 | } 10 | 11 | return port; 12 | }); 13 | type StringNumber = z.infer; 14 | 15 | const Port = z 16 | .object({ 17 | published: StringNumber, 18 | target: StringNumber, 19 | }) 20 | .passthrough(); 21 | type Port = z.infer; 22 | 23 | const Environment = z.record(z.union([z.string(), z.number()])); 24 | type Environment = z.infer; 25 | 26 | const Service = z 27 | .object({ 28 | environment: Environment.optional(), 29 | image: z.string().optional(), 30 | ports: z.array(Port), 31 | }) 32 | .passthrough(); 33 | type Service = z.infer; 34 | 35 | const ComposeFile = z 36 | .object({ 37 | version: z.string().optional(), 38 | services: z.record(Service), 39 | }) 40 | .passthrough(); 41 | type ComposeFile = z.infer; 42 | 43 | export function getParsedComposeFile(mergedComposeFiles: string): { 44 | dockerComposeFile: ComposeFile; 45 | } { 46 | const loadedMergedComposeFiles = safeLoad(mergedComposeFiles); 47 | const parsedMergedComposeFiles = ComposeFile.safeParse(loadedMergedComposeFiles); 48 | 49 | if (!parsedMergedComposeFiles.success) { 50 | throw new DockestError(`Invalid Composefile 51 | ${formatZodError(parsedMergedComposeFiles.error, 'ComposeFile')}`); 52 | } 53 | 54 | return { 55 | dockerComposeFile: parsedMergedComposeFiles.data, 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/index.ts: -------------------------------------------------------------------------------- 1 | import { configureLogger } from './configure-logger'; 2 | import { createDockerEventEmitter } from './create-docker-event-emitter'; 3 | import { getComposeFilesWithVersion } from './get-compose-files-with-version'; 4 | import { getParsedComposeFile } from './get-parsed-compose-file'; 5 | import { mergeComposeFiles } from './merge-compose-files'; 6 | import { setupExitHandler } from './setup-exit-handler'; 7 | import { transformDockestServicesToRunners } from './transform-dockest-services-to-runners'; 8 | import { writeComposeFile } from './write-compose-file'; 9 | import { DockestConfig, DockestService } from '../../@types'; 10 | 11 | export const bootstrap = async ({ 12 | composeFile, 13 | dockestServices, 14 | dumpErrors, 15 | exitHandler, 16 | runMode, 17 | mutables, 18 | perfStart, 19 | }: { 20 | composeFile: DockestConfig['composeFile']; 21 | dockestServices: DockestService[]; 22 | dumpErrors: DockestConfig['dumpErrors']; 23 | exitHandler: DockestConfig['exitHandler']; 24 | runMode: DockestConfig['runMode']; 25 | mutables: DockestConfig['mutables']; 26 | perfStart: DockestConfig['perfStart']; 27 | }) => { 28 | setupExitHandler({ dumpErrors, exitHandler, mutables, perfStart }); 29 | 30 | const { mergedComposeFiles } = await mergeComposeFiles(composeFile); 31 | 32 | const { dockerComposeFile } = getParsedComposeFile(mergedComposeFiles); 33 | 34 | const { dockerComposeFileWithVersion } = getComposeFilesWithVersion(composeFile, dockerComposeFile); 35 | 36 | const composeFilePath = writeComposeFile(mergedComposeFiles, dockerComposeFileWithVersion); 37 | 38 | const dockerEventEmitter = createDockerEventEmitter(composeFilePath); 39 | 40 | mutables.runners = transformDockestServicesToRunners({ 41 | dockerComposeFile: dockerComposeFileWithVersion, 42 | dockestServices, 43 | runMode, 44 | dockerEventEmitter, 45 | }); 46 | 47 | mutables.dockerEventEmitter = dockerEventEmitter; 48 | 49 | configureLogger(mutables.runners); 50 | }; 51 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/merge-compose-files-2.spec.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | postgres: 5 | environment: 6 | POSTGRES_DB: nobueno 7 | POSTGRES_PASSWORD: is 8 | POSTGRES_USER: ramda 9 | image: postgres:9.6-alpine 10 | ports: 11 | - published: 5433 12 | target: 5432 13 | protocol: tcp 14 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/merge-compose-files.spec.ts: -------------------------------------------------------------------------------- 1 | import { safeLoad } from 'js-yaml'; 2 | import { mergeComposeFiles } from './merge-compose-files'; 3 | 4 | const nodeProcess: any = { cwd: () => __dirname }; 5 | 6 | /** 7 | * Some notes on the recent V2 docker-compose config update and how it affects Dockest 8 | * @ref https://github.com/erikengervall/dockest/issues/283 9 | */ 10 | 11 | /** compose-file.yml 12 | version: '3.8' 13 | 14 | services: 15 | redis: 16 | image: redis:5.0.3-alpine 17 | ports: 18 | - 6379:6379/tcp 19 | */ 20 | 21 | /** pre V2 docker-compose config 22 | services: 23 | redis: 24 | image: redis:5.0.3-alpine 25 | ports: 26 | - protocol: tcp 27 | published: 6379 28 | target: 6379 29 | version: '3.8' 30 | */ 31 | 32 | /** post V2 docker-compose config 33 | services: 34 | redis: 35 | image: redis:5.0.3-alpine 36 | networks: 37 | default: null 38 | ports: 39 | - mode: ingress 40 | target: 6379 41 | published: 6379 42 | protocol: tcp 43 | networks: 44 | default: 45 | name: bootstrap_default 46 | */ 47 | 48 | describe('mergeComposeFiles', () => { 49 | describe('happy', () => { 50 | it('should work for single compose file', async () => { 51 | const { mergedComposeFiles } = await mergeComposeFiles('merge-compose-files.spec.yml', nodeProcess); 52 | 53 | const loadedComposeFiles = safeLoad(mergedComposeFiles); 54 | 55 | expect(loadedComposeFiles).toMatchObject({ 56 | services: { 57 | redis: expect.any(Object), 58 | }, 59 | }); 60 | 61 | // expect(safeLoad(mergedComposeFiles)).toMatchObject({ 62 | // networks: { 63 | // default: { 64 | // name: 'bootstrap_default', 65 | // }, 66 | // }, 67 | // services: { 68 | // redis: { 69 | // image: 'redis:5.0.3-alpine', 70 | // networks: { 71 | // default: null, 72 | // }, 73 | // ports: [ 74 | // { 75 | // protocol: 'tcp', 76 | // published: '6379', 77 | // target: 6379, 78 | // }, 79 | // ], 80 | // }, 81 | // }, 82 | // name: 'bootstrap', 83 | // }); 84 | }); 85 | 86 | it('should work for multiple compose files', async () => { 87 | const { mergedComposeFiles } = await mergeComposeFiles( 88 | ['merge-compose-files.spec.yml', 'merge-compose-files-2.spec.yml'], 89 | nodeProcess, 90 | ); 91 | 92 | const loadedComposeFiles = safeLoad(mergedComposeFiles); 93 | 94 | expect(loadedComposeFiles).toMatchObject({ 95 | services: { 96 | redis: expect.any(Object), 97 | postgres: expect.any(Object), 98 | }, 99 | }); 100 | 101 | // expect(loadedComposeFiles).toMatchObject({ 102 | // name: 'bootstrap', 103 | // networks: { 104 | // default: { 105 | // name: 'bootstrap_default', 106 | // }, 107 | // }, 108 | // }); 109 | 110 | // expect((loadedComposeFiles as any).services.postgres).toMatchObject({ 111 | // environment: { 112 | // POSTGRES_DB: 'nobueno', 113 | // POSTGRES_PASSWORD: 'is', 114 | // POSTGRES_USER: 'ramda', 115 | // }, 116 | // image: 'postgres:9.6-alpine', 117 | // networks: { 118 | // default: null, 119 | // }, 120 | // ports: [ 121 | // { 122 | // protocol: 'tcp', 123 | // published: '5433', 124 | // target: 5432, 125 | // }, 126 | // ], 127 | // }); 128 | // expect((loadedComposeFiles as any).services.redis).toMatchObject({ 129 | // image: 'redis:5.0.3-alpine', 130 | // networks: { 131 | // default: null, 132 | // }, 133 | // ports: [ 134 | // { 135 | // protocol: 'tcp', 136 | // published: '6379', 137 | // target: 6379, 138 | // }, 139 | // ], 140 | // }); 141 | }); 142 | }); 143 | 144 | describe('sad', () => { 145 | it('should throw if invalid name of compose file', async () => { 146 | try { 147 | await mergeComposeFiles('this-file-does-not-exist.yml', nodeProcess); 148 | expect(true).toBe('Should throw.'); 149 | } catch (error) { 150 | expect(error).toMatchInlineSnapshot(`[DockestError: Invalid Compose file(s)]`); 151 | } 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/merge-compose-files.spec.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | redis: 5 | image: redis:5.0.3-alpine 6 | ports: 7 | - published: 6379 8 | target: 6379 9 | protocol: tcp 10 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/merge-compose-files.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { DockestConfig } from '../../@types'; 3 | import { DockestError } from '../../errors'; 4 | import { execaWrapper } from '../../utils/execa-wrapper'; 5 | 6 | export async function mergeComposeFiles(composeFile: DockestConfig['composeFile'], nodeProcess = process) { 7 | const composeFiles = []; 8 | if (Array.isArray(composeFile)) { 9 | composeFiles.push(...composeFile); 10 | } else { 11 | composeFiles.push(composeFile); 12 | } 13 | 14 | const dockerComposeConfigCommand = `${composeFiles.reduce( 15 | (commandAcc, composePath) => (commandAcc += ` -f ${path.join(nodeProcess.cwd(), composePath)}`), 16 | 'docker-compose', 17 | )} config`; 18 | 19 | const { stderr, exitCode, stdout } = execaWrapper(dockerComposeConfigCommand, { 20 | execaOpts: { reject: false }, 21 | logStdout: true, 22 | }); 23 | 24 | let mergedComposeFiles = stdout; 25 | 26 | if (exitCode !== 0) { 27 | throw new DockestError('Invalid Compose file(s)', { 28 | error: stderr, 29 | }); 30 | } 31 | 32 | // For some reason the published ports are wrapped in quotes, so we remove them for consistency 33 | mergedComposeFiles = mergedComposeFiles.replace(/published: "(\d{4})"/g, 'published: $1'); 34 | mergedComposeFiles = mergedComposeFiles.replace(/ZOOKEEPER_CLIENT_PORT: "(\d{4})"/g, 'ZOOKEEPER_CLIENT_PORT: $1'); 35 | 36 | return { 37 | mergedComposeFiles, 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/setup-exit-handler.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | import { DockestConfig } from '../../@types'; 4 | import { BaseError } from '../../errors'; 5 | import { Logger } from '../../logger'; 6 | import { teardownSingle } from '../../utils/teardown-single'; 7 | 8 | export interface ErrorPayload { 9 | trap: string; 10 | code?: number; 11 | error?: Error; 12 | promise?: Promise; 13 | reason?: Error | any; 14 | signal?: any; 15 | } 16 | 17 | const LOG_PREFIX = '[Exit Handler]'; 18 | 19 | export const setupExitHandler = ({ 20 | dumpErrors, 21 | exitHandler: customExitHandler, 22 | mutables, 23 | mutables: { runners }, 24 | perfStart, 25 | }: { 26 | dumpErrors: DockestConfig['dumpErrors']; 27 | exitHandler: DockestConfig['exitHandler']; 28 | mutables: DockestConfig['mutables']; 29 | perfStart: DockestConfig['perfStart']; 30 | }): void => { 31 | let exitInProgress = false; 32 | 33 | const exitHandler = async (errorPayload: ErrorPayload) => { 34 | if (exitInProgress) { 35 | return; 36 | } 37 | 38 | // Ensure the exit handler is only invoced once 39 | exitInProgress = true; 40 | 41 | if (mutables.jestRanWithResult) { 42 | return; 43 | } 44 | 45 | if (errorPayload.reason instanceof BaseError) { 46 | const { 47 | payload: { error, runner, ...restPayload }, 48 | message, 49 | name, 50 | stack, 51 | } = errorPayload.reason; 52 | 53 | const logPayload: any = { 54 | data: { 55 | name, 56 | stack, 57 | }, 58 | }; 59 | 60 | runner && (logPayload.data.serviceName = runner.serviceName); 61 | runner && runner.containerId && (logPayload.data.containerId = runner.containerId); 62 | 63 | error && (logPayload.data.error = error); 64 | 65 | restPayload && 66 | typeof restPayload === 'object' && 67 | Object.keys(restPayload).length > 0 && 68 | (logPayload.data.restPayload = restPayload); 69 | 70 | Logger.error(`${LOG_PREFIX} ${message}`, logPayload); 71 | } else { 72 | Logger.error(`${LOG_PREFIX} ${JSON.stringify(errorPayload, null, 2)}`); 73 | } 74 | 75 | if (customExitHandler && typeof customExitHandler === 'function') { 76 | await customExitHandler(errorPayload); 77 | } 78 | 79 | for (const runner of Object.values(runners)) { 80 | await teardownSingle({ runner }); 81 | } 82 | 83 | if (dumpErrors === true) { 84 | const dumpPath = `${process.cwd()}/dockest-error.json`; 85 | const dumpPayload = { 86 | errorPayload, 87 | timestamp: new Date(), 88 | }; 89 | 90 | try { 91 | fs.writeFileSync(dumpPath, JSON.stringify(dumpPayload, null, 2)); 92 | } catch (dumpError) { 93 | Logger.debug(`Failed to dump error to ${dumpPath}`, { data: { dumpError, dumpPayload } }); 94 | } 95 | } 96 | 97 | Logger.measurePerformance(perfStart, { logPrefix: LOG_PREFIX }); 98 | process.exit(errorPayload.code || 1); 99 | }; 100 | 101 | // keeps the program from closing instantly 102 | process.stdin.resume(); // FIXME: causes "Jest has detected the following 1 open handle potentially keeping Jest from exiting:" 103 | 104 | // do something when app is closing 105 | process.on('exit', async (code) => exitHandler({ trap: 'exit', code })); 106 | 107 | // catches ctrl+c event 108 | process.on('SIGINT', async (signal) => exitHandler({ trap: 'SIGINT', signal })); 109 | 110 | // catches "kill pid" (for example: nodemon restart) 111 | process.on('SIGUSR1', async () => exitHandler({ trap: 'SIGUSR1' })); 112 | process.on('SIGUSR2', async () => exitHandler({ trap: 'SIGUSR2' })); 113 | 114 | // catches uncaught exceptions 115 | process.on('uncaughtException', async (error) => exitHandler({ trap: 'uncaughtException', error })); 116 | 117 | // catches unhandled promise rejections 118 | process.on('unhandledRejection', async (reason, promise) => 119 | exitHandler({ trap: 'unhandledRejection', reason, promise }), 120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/transform-dockest-services-to-runners.spec.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { transformDockestServicesToRunners } from './transform-dockest-services-to-runners'; 3 | import { DockerComposeFile, DockestService } from '../../@types'; 4 | 5 | const serviceName = 'service1'; 6 | const dockerComposeFile: DockerComposeFile = { 7 | version: '3.8', 8 | services: { 9 | [serviceName]: { ports: [{ published: 3000, target: 3000 }] }, 10 | }, 11 | }; 12 | const dockestServices: DockestService[] = [{ serviceName: serviceName }]; 13 | 14 | describe('transformDockestServicesToRunners', () => { 15 | describe('happy', () => { 16 | it('should work', () => { 17 | const runners = transformDockestServicesToRunners({ 18 | dockerComposeFile, 19 | dockestServices, 20 | runMode: 'host', 21 | dockerEventEmitter: new EventEmitter() as any, 22 | }); 23 | 24 | expect(runners).toMatchInlineSnapshot(` 25 | { 26 | "service1": { 27 | "commands": [], 28 | "containerId": "", 29 | "dependsOn": [], 30 | "dockerComposeFileService": { 31 | "ports": [ 32 | { 33 | "published": 3000, 34 | "target": 3000, 35 | }, 36 | ], 37 | }, 38 | "dockerEventStream$": Observable { 39 | "_isScalar": false, 40 | "operator": [Function], 41 | "source": Observable { 42 | "_isScalar": false, 43 | "_subscribe": [Function], 44 | }, 45 | }, 46 | "logger": Logger { 47 | "debug": [Function], 48 | "error": [Function], 49 | "info": [Function], 50 | "runnerSymbol": "🦇 ", 51 | "serviceName": "service1", 52 | "setRunnerSymbol": [Function], 53 | "warn": [Function], 54 | }, 55 | "readinessCheck": [Function], 56 | "serviceName": "service1", 57 | }, 58 | } 59 | `); 60 | }); 61 | }); 62 | 63 | describe('sad', () => { 64 | it(`should throw if serviceName can't be found in Compose file`, () => { 65 | const invalidServiceName = 'does-not-match--should-throw'; 66 | const dockestServices: DockestService[] = [{ serviceName: invalidServiceName }]; 67 | 68 | expect(() => 69 | transformDockestServicesToRunners({ 70 | dockerComposeFile, 71 | dockestServices, 72 | runMode: 'host', 73 | dockerEventEmitter: new EventEmitter() as any, 74 | }), 75 | ).toThrow( 76 | `Unable to find compose service "${invalidServiceName}", make sure that the serviceName corresponds with your Compose File's service`, 77 | ); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/transform-dockest-services-to-runners.ts: -------------------------------------------------------------------------------- 1 | import { DockerEventEmitter } from './create-docker-event-emitter'; 2 | import { createDockerServiceEventStream } from './create-docker-service-event-stream'; 3 | import { DockerComposeFile, DockestConfig, DockestService, Runner, RunnersObj } from '../../@types'; 4 | import { ConfigurationError } from '../../errors'; 5 | import { Logger } from '../../logger'; 6 | 7 | export const transformDockestServicesToRunners = ({ 8 | dockerComposeFile, 9 | dockestServices, 10 | runMode, 11 | dockerEventEmitter, 12 | }: { 13 | dockerComposeFile: DockerComposeFile; 14 | dockestServices: DockestService[]; 15 | runMode: DockestConfig['runMode']; 16 | dockerEventEmitter: DockerEventEmitter; 17 | }) => { 18 | const createRunner = (dockestService: DockestService) => { 19 | const { commands = [], dependsOn = [], readinessCheck = () => Promise.resolve(), serviceName } = dockestService; 20 | 21 | const dockerComposeFileService = dockerComposeFile.services[serviceName]; 22 | if (!dockerComposeFileService) { 23 | throw new ConfigurationError( 24 | `Unable to find compose service "${serviceName}", make sure that the serviceName corresponds with your Compose File's service`, 25 | ); 26 | } 27 | 28 | const runner: Runner = { 29 | commands, 30 | containerId: '', 31 | dependsOn: dependsOn.map(createRunner), 32 | dockerComposeFileService, 33 | dockerEventStream$: createDockerServiceEventStream(serviceName, dockerEventEmitter), 34 | logger: new Logger(serviceName), 35 | readinessCheck, 36 | serviceName, 37 | }; 38 | 39 | if (runMode === 'docker-injected-host-socket') { 40 | runner.host = serviceName; 41 | runner.isBridgeNetworkMode = true; 42 | } 43 | 44 | return runner; 45 | }; 46 | 47 | return dockestServices.reduce((acc: RunnersObj, dockestService) => { 48 | acc[dockestService.serviceName] = createRunner(dockestService); 49 | 50 | return acc; 51 | }, {}); 52 | }; 53 | -------------------------------------------------------------------------------- /packages/dockest/src/run/bootstrap/write-compose-file.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { DockerComposeFile } from '../../@types'; 3 | import { GENERATED_COMPOSE_FILE_PATH, DOCKEST_ATTACH_TO_PROCESS } from '../../constants'; 4 | 5 | export const writeComposeFile = (mergedComposeFiles: string, composeFileAsObject: DockerComposeFile): string => { 6 | // set environment variable that can be used with the test-helpers 7 | // jest.runCLI will pass this environment variable into the testcase runners 8 | process.env[DOCKEST_ATTACH_TO_PROCESS] = JSON.stringify(composeFileAsObject); 9 | 10 | fs.writeFileSync(GENERATED_COMPOSE_FILE_PATH, mergedComposeFiles); 11 | return GENERATED_COMPOSE_FILE_PATH; 12 | }; 13 | -------------------------------------------------------------------------------- /packages/dockest/src/run/create-container-die-check.ts: -------------------------------------------------------------------------------- 1 | import { interval, Subject, race } from 'rxjs'; 2 | import { takeUntil, tap, first, mapTo } from 'rxjs/operators'; 3 | import { Runner } from '../@types'; 4 | 5 | export const createContainerDieCheck = ({ runner }: { runner: Runner }) => { 6 | const { dockerEventStream$ } = runner; 7 | const stop$ = new Subject(); 8 | const cancel$ = new Subject(); 9 | 10 | const info$ = interval(1000).pipe( 11 | takeUntil(stop$), 12 | tap(() => { 13 | runner.logger.info('Container is still running...'); 14 | }), 15 | ); 16 | 17 | const containerDies$ = dockerEventStream$.pipe( 18 | takeUntil(stop$), 19 | first((event) => event.action === 'die'), 20 | ); 21 | 22 | return { 23 | service: runner.serviceName, 24 | done: race(containerDies$, info$, cancel$) 25 | .pipe( 26 | tap({ 27 | next: () => { 28 | stop$.next(); 29 | stop$.complete(); 30 | }, 31 | }), 32 | mapTo(undefined), 33 | ) 34 | .toPromise(), 35 | cancel: () => { 36 | cancel$.complete(); 37 | }, 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /packages/dockest/src/run/debug-mode.ts: -------------------------------------------------------------------------------- 1 | import { DockestConfig } from '../@types'; 2 | import { Logger } from '../logger'; 3 | import { sleep } from '../utils/sleep'; 4 | 5 | export const debugMode = async ({ 6 | debug, 7 | mutables: { runners }, 8 | }: { 9 | debug: DockestConfig['debug']; 10 | mutables: DockestConfig['mutables']; 11 | }) => { 12 | if (debug) { 13 | Logger.info(`Debug mode enabled, containers are kept running and Jest will not run.`); 14 | 15 | Object.values(runners).forEach((runner) => Logger.info(`[${runner.serviceName}]: ${runner.containerId}`)); 16 | 17 | await sleep(1000 * 60 * 60 * 24); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /packages/dockest/src/run/log-writer.spec.ts: -------------------------------------------------------------------------------- 1 | import { Readable, Writable } from 'stream'; 2 | import { createLogWriter } from './log-writer'; 3 | 4 | let writableMap: { 5 | [name: string]: Writable; 6 | }; 7 | 8 | let resultMap: { 9 | [name: string]: { 10 | text: string; 11 | }; 12 | }; 13 | 14 | jest.mock('execa', () => { 15 | return (command: string, args: Array) => { 16 | if (command !== 'docker-compose') { 17 | fail('The mock is only expected to handle docker-compose execution.'); 18 | } 19 | 20 | const serviceName = args.slice(0).pop() as string; 21 | 22 | const result = new Promise((resolve) => resolve()); 23 | const stdout = Readable.from([`mock text from ${serviceName}\n`]); 24 | (result as any).stdout = stdout; 25 | return result; 26 | }; 27 | }); 28 | 29 | jest.mock('fs', () => ({ 30 | ...jest.requireActual('fs'), 31 | createWriteStream: (name: string) => { 32 | if (!writableMap[name]) { 33 | writableMap[name] = new Writable({ 34 | write: (chunk, _, next) => { 35 | if (!resultMap[name]) { 36 | resultMap[name] = { 37 | text: '', 38 | }; 39 | } 40 | resultMap[name].text += chunk.toString(); 41 | next(); 42 | }, 43 | }); 44 | } 45 | return writableMap[name]; 46 | }, 47 | })); 48 | 49 | // for the tests we mock the input and output in order to check whether stuff is forwarded correctly. 50 | beforeEach(() => { 51 | writableMap = {}; 52 | resultMap = {}; 53 | }); 54 | 55 | test('it can be created', () => { 56 | createLogWriter({ 57 | logPath: './', 58 | mode: ['aggregate'], 59 | serviceNameFilter: [], 60 | }); 61 | }); 62 | 63 | test('can collect aggregated logs', async () => { 64 | const writer = createLogWriter({ 65 | logPath: '.', 66 | mode: ['aggregate'], 67 | }); 68 | 69 | writer.register('foo', '1'); 70 | await new Promise((res) => setImmediate(res)); 71 | expect(resultMap).toMatchInlineSnapshot(` 72 | { 73 | "dockest.log": { 74 | "text": "mock text from foo 75 | ", 76 | }, 77 | } 78 | `); 79 | }); 80 | 81 | test('can collect individual logs', async () => { 82 | const writer = createLogWriter({ 83 | logPath: '.', 84 | mode: ['per-service'], 85 | }); 86 | 87 | writer.register('foo', '1'); 88 | await new Promise((res) => setImmediate(res)); 89 | expect(resultMap).toMatchInlineSnapshot(` 90 | { 91 | "foo.dockest.log": { 92 | "text": "mock text from foo 93 | ", 94 | }, 95 | } 96 | `); 97 | }); 98 | 99 | test('can collect individual and aggregated logs', async () => { 100 | const writer = createLogWriter({ 101 | logPath: '.', 102 | mode: ['aggregate', 'per-service'], 103 | }); 104 | 105 | writer.register('foo', '1'); 106 | 107 | await new Promise((res) => setImmediate(res)); 108 | expect(resultMap).toMatchInlineSnapshot(` 109 | { 110 | "dockest.log": { 111 | "text": "mock text from foo 112 | ", 113 | }, 114 | "foo.dockest.log": { 115 | "text": "mock text from foo 116 | ", 117 | }, 118 | } 119 | `); 120 | }); 121 | 122 | test('can collect individual and aggregated logs from multiple services', async () => { 123 | const writer = createLogWriter({ 124 | logPath: '.', 125 | mode: ['aggregate', 'per-service'], 126 | }); 127 | 128 | writer.register('foo', '1'); 129 | writer.register('bar', '2'); 130 | 131 | await new Promise((res) => setImmediate(res)); 132 | expect(resultMap).toMatchInlineSnapshot(` 133 | { 134 | "bar.dockest.log": { 135 | "text": "mock text from bar 136 | ", 137 | }, 138 | "dockest.log": { 139 | "text": "mock text from foo 140 | mock text from bar 141 | ", 142 | }, 143 | "foo.dockest.log": { 144 | "text": "mock text from foo 145 | ", 146 | }, 147 | } 148 | `); 149 | }); 150 | -------------------------------------------------------------------------------- /packages/dockest/src/run/log-writer.ts: -------------------------------------------------------------------------------- 1 | import { createWriteStream, WriteStream } from 'fs'; 2 | import { join } from 'path'; 3 | import execa from 'execa'; /* eslint-disable-line import/default */ 4 | import { GENERATED_COMPOSE_FILE_PATH } from '../constants'; 5 | import { DockestError } from '../errors'; 6 | import { Logger } from '../logger'; 7 | 8 | export type LogWriterModeType = 'per-service' | 'aggregate' | 'pipe-stdout'; 9 | 10 | const DEFAULT_LOG_SYMBOL = Symbol('DEFAULT_LOG'); 11 | 12 | export type LogWriter = { 13 | register: (serviceName: string, containerId: string) => void; 14 | destroy: () => Promise; 15 | }; 16 | 17 | export const createLogWriter = ({ 18 | mode, 19 | logPath, 20 | serviceNameFilter, 21 | }: { 22 | mode: LogWriterModeType[]; 23 | logPath: string; 24 | serviceNameFilter?: string[]; 25 | }) => { 26 | const writeStreamMap = new Map(); 27 | 28 | if (mode.includes('aggregate')) { 29 | const writeStream = createWriteStream(join(logPath, `dockest.log`)); 30 | writeStreamMap.set(DEFAULT_LOG_SYMBOL, writeStream); 31 | } 32 | 33 | const getDefaultWriteStream = () => { 34 | const stream = writeStreamMap.get(DEFAULT_LOG_SYMBOL); 35 | if (!stream) { 36 | throw new DockestError('Could not find default log stream.'); 37 | } 38 | return stream; 39 | }; 40 | 41 | const createOrGetFileStream = (serviceName: string) => { 42 | let stream = writeStreamMap.get(serviceName); 43 | if (!stream) { 44 | stream = createWriteStream(join(logPath, `${serviceName}.dockest.log`)); 45 | writeStreamMap.set(serviceName, stream); 46 | stream.on('error', (error) => { 47 | throw new DockestError('Unexpected error thrown for stream\n\n.' + String(error), { error }); 48 | }); 49 | } 50 | return stream; 51 | }; 52 | 53 | const getWriteStream = (serviceName: string) => { 54 | return createOrGetFileStream(serviceName); 55 | }; 56 | 57 | const register = (serviceName: string, containerId: string) => { 58 | if (serviceNameFilter && serviceNameFilter.includes(serviceName) === false) { 59 | Logger.debug(`Skip log collection for service ${serviceName} with containerId: ${containerId}.`); 60 | return; 61 | } 62 | 63 | Logger.debug(`Registering log collection for ${serviceName} with containerId: ${containerId}`); 64 | 65 | const logCollectionProcess = execa(`docker-compose`, [ 66 | '-f', 67 | GENERATED_COMPOSE_FILE_PATH, 68 | 'logs', 69 | '-f', 70 | '--no-color', 71 | serviceName, 72 | ]); 73 | 74 | if (!logCollectionProcess.stdout) { 75 | throw new DockestError('Process has no stdout.'); 76 | } 77 | if (mode.includes('pipe-stdout')) { 78 | logCollectionProcess.stdout.pipe(process.stdout, { end: false }); 79 | } 80 | if (mode.includes('per-service')) { 81 | const writeStream = getWriteStream(serviceName); 82 | logCollectionProcess.stdout.pipe(writeStream, { end: false }); 83 | } 84 | if (mode.includes('aggregate')) { 85 | const writeStream = getDefaultWriteStream(); 86 | logCollectionProcess.stdout.pipe(writeStream, { end: false }); 87 | } 88 | 89 | // execa returns a lazy promise. 90 | logCollectionProcess.then(() => undefined); 91 | }; 92 | 93 | const destroy = async () => { 94 | for (const stream of writeStreamMap.values()) { 95 | stream.end(); 96 | } 97 | }; 98 | 99 | return { 100 | register, 101 | destroy, 102 | }; 103 | }; 104 | -------------------------------------------------------------------------------- /packages/dockest/src/run/run-jest.ts: -------------------------------------------------------------------------------- 1 | import { DockestConfig } from '../@types'; 2 | import { Logger } from '../logger'; 3 | 4 | export const runJest = async ({ 5 | jestLib, 6 | jestOpts, 7 | jestOpts: { projects }, 8 | mutables, 9 | }: { 10 | jestLib: DockestConfig['jestLib']; 11 | jestOpts: DockestConfig['jestOpts']; 12 | mutables: DockestConfig['mutables']; 13 | }) => { 14 | Logger.info('DockestServices running, running Jest', { endingNewLines: 1 }); 15 | 16 | // typecasting required due to runCLI's first argument's messy typings: `yargs.Arguments>` 17 | const { results } = await jestLib.runCLI(jestOpts as any, projects ?? []); 18 | 19 | const { success, numFailedTests, numTotalTests } = results; 20 | 21 | success 22 | ? Logger.info(`[Jest] All tests passed`, { startingNewLines: 1, endingNewLines: 1, success: true }) 23 | : Logger.error(`[Jest] ${numFailedTests}/${numTotalTests} tests failed`, { endingNewLines: 1 }); 24 | 25 | mutables.jestRanWithResult = true; 26 | 27 | return { 28 | success, 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /packages/dockest/src/run/teardown.ts: -------------------------------------------------------------------------------- 1 | import { LogWriter } from './log-writer'; 2 | import { DockestConfig } from '../@types'; 3 | import { DockestError } from '../errors'; 4 | import { Logger } from '../logger'; 5 | import { leaveBridgeNetwork } from '../utils/network/leave-bridge-network'; 6 | import { removeBridgeNetwork } from '../utils/network/remove-bridge-network'; 7 | import { teardownSingle } from '../utils/teardown-single'; 8 | 9 | export const teardown = async ({ 10 | hostname, 11 | runMode, 12 | mutables: { runnerLookupMap, dockerEventEmitter, teardownOrder }, 13 | perfStart, 14 | logWriter, 15 | }: { 16 | hostname: DockestConfig['hostname']; 17 | runMode: DockestConfig['runMode']; 18 | mutables: DockestConfig['mutables']; 19 | perfStart: DockestConfig['perfStart']; 20 | logWriter: LogWriter; 21 | }) => { 22 | if (teardownOrder) { 23 | for (const serviceName of teardownOrder) { 24 | const runner = runnerLookupMap.get(serviceName); 25 | if (!runner) { 26 | throw new DockestError('Could not find service in lookup map.'); 27 | } 28 | await teardownSingle({ runner }); 29 | } 30 | } else { 31 | for (const runner of runnerLookupMap.values()) { 32 | await teardownSingle({ runner }); 33 | } 34 | } 35 | 36 | if (runMode === 'docker-injected-host-socket') { 37 | await leaveBridgeNetwork({ containerId: hostname }); 38 | await removeBridgeNetwork(); 39 | } 40 | 41 | dockerEventEmitter.destroy(); 42 | await logWriter.destroy(); 43 | 44 | Logger.measurePerformance(perfStart); 45 | }; 46 | -------------------------------------------------------------------------------- /packages/dockest/src/run/wait-for-services/check-connection.spec.ts: -------------------------------------------------------------------------------- 1 | import { ReplaySubject } from 'rxjs'; 2 | import { AcquireConnectionFunctionType, createCheckConnection } from './check-connection'; 3 | import { createRunner } from '../../test-utils'; 4 | 5 | // mock delays to tick immediately 6 | jest.mock('rxjs/operators', () => { 7 | const operators = jest.requireActual('rxjs/operators'); 8 | operators.delay = jest.fn(() => (s: unknown) => s); // <= mock delay 9 | return operators; 10 | }); 11 | 12 | const acquireConnection: AcquireConnectionFunctionType = () => Promise.resolve(); 13 | const checkConnection = createCheckConnection({ acquireConnection }); 14 | 15 | describe('happy', () => { 16 | it('succeeds with zero port checks', async () => { 17 | const dockerEventStream$ = new ReplaySubject() as any; 18 | const runner = createRunner({ 19 | dockerEventStream$, 20 | dockerComposeFileService: { image: 'node:18-alpine', ports: [] }, 21 | }); 22 | 23 | const result = await checkConnection({ runner }); 24 | 25 | expect(result).toEqual(undefined); 26 | }); 27 | 28 | it('succeeds when the port check is successfull', async () => { 29 | const runner = createRunner({}); 30 | 31 | const result = await checkConnection({ runner }); 32 | 33 | expect(result).toEqual(undefined); 34 | }); 35 | }); 36 | 37 | describe('sad', () => { 38 | it('fails when the die event is emitted', async () => { 39 | const dockerEventStream$ = new ReplaySubject(); 40 | dockerEventStream$.next({ action: 'die' }); 41 | const runner = createRunner({ dockerEventStream$ } as any); 42 | 43 | try { 44 | await checkConnection({ runner }); 45 | expect(true).toEqual('Should throw.'); 46 | } catch (error) { 47 | expect(error).toMatchInlineSnapshot(`[DockestError: Container unexpectedly died.]`); 48 | } 49 | }); 50 | 51 | it('fails when the kill event is emitted', async () => { 52 | const dockerEventStream$ = new ReplaySubject(); 53 | dockerEventStream$.next({ action: 'kill' }); 54 | const runner = createRunner({ dockerEventStream$ } as any); 55 | 56 | try { 57 | await checkConnection({ runner }); 58 | expect(true).toEqual('Should throw.'); 59 | } catch (error) { 60 | expect(error).toMatchInlineSnapshot(`[DockestError: Container unexpectedly died.]`); 61 | } 62 | }); 63 | 64 | it('fails when acquire connection times out', async () => { 65 | const acquireConnection: AcquireConnectionFunctionType = () => Promise.reject(new Error('Timeout')); 66 | const checkConnection = createCheckConnection({ acquireConnection }); 67 | 68 | const runner = createRunner({}); 69 | 70 | try { 71 | await checkConnection({ runner }); 72 | expect(true).toEqual('Should throw.'); 73 | } catch (error) { 74 | expect(error).toMatchInlineSnapshot(`[DockestError: [Check Connection] Timed out]`); 75 | } 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /packages/dockest/src/run/wait-for-services/check-connection.ts: -------------------------------------------------------------------------------- 1 | import net from 'net'; 2 | import { from, of, race } from 'rxjs'; 3 | import { concatMap, delay, ignoreElements, map, mergeMap, retryWhen, skipWhile, takeWhile, tap } from 'rxjs/operators'; 4 | import { Runner } from '../../@types'; 5 | import { DockestError } from '../../errors'; 6 | import { selectPortMapping } from '../../utils/select-port-mapping'; 7 | 8 | export type AcquireConnectionFunctionType = ({ host, port }: { host: string; port: number }) => Promise; 9 | 10 | const LOG_PREFIX = '[Check Connection]'; 11 | const RETRY_COUNT = 10; 12 | 13 | const acquireConnection: AcquireConnectionFunctionType = ({ host, port }): Promise => { 14 | return new Promise((resolve, reject) => { 15 | let connected = false; 16 | let timeoutId: ReturnType | null = null; 17 | 18 | const netSocket = net.createConnection({ host, port }).on('connect', () => { 19 | if (timeoutId) { 20 | clearTimeout(timeoutId); 21 | } 22 | console.debug(`${host}:${port} connected ✅`); 23 | connected = true; 24 | netSocket.end(); 25 | resolve(undefined); 26 | }); 27 | 28 | timeoutId = setTimeout(() => { 29 | if (!connected) { 30 | reject(new Error('Timeout while acquiring connection')); 31 | } 32 | }, 1000); 33 | }); 34 | }; 35 | 36 | const checkPortConnection = ({ 37 | host, 38 | port, 39 | runner, 40 | acquireConnection, 41 | }: { 42 | host: string; 43 | port: number; 44 | runner: Runner; 45 | acquireConnection: AcquireConnectionFunctionType; 46 | }) => { 47 | return of({ host, port }).pipe( 48 | // run check 49 | mergeMap(({ host, port }) => { 50 | return from(acquireConnection({ host, port })); 51 | }), 52 | 53 | // retry if check errors 54 | retryWhen((errors) => { 55 | let retries = 0; 56 | 57 | return errors.pipe( 58 | tap((value) => { 59 | retries = retries + 1; 60 | runner.logger.error(`${LOG_PREFIX} Error: ${value.message}`); 61 | runner.logger.debug(`${LOG_PREFIX} Timeout after ${ 62 | RETRY_COUNT - retries 63 | } retries (Retry count set to ${RETRY_COUNT}). 64 | `); 65 | }), 66 | takeWhile(() => { 67 | if (retries < RETRY_COUNT) { 68 | return true; 69 | } 70 | 71 | throw new DockestError(`${LOG_PREFIX} Timed out`, { runner }); 72 | }), 73 | delay(1000), 74 | ); 75 | }), 76 | ); 77 | }; 78 | 79 | export const createCheckConnection = 80 | ({ acquireConnection }: { acquireConnection: AcquireConnectionFunctionType }) => 81 | async ({ 82 | runner, 83 | runner: { 84 | dockerComposeFileService: { ports }, 85 | host: runnerHost, 86 | isBridgeNetworkMode, 87 | dockerEventStream$, 88 | }, 89 | }: { 90 | runner: Runner; 91 | }) => { 92 | const host = runnerHost || 'localhost'; 93 | const portKey = isBridgeNetworkMode ? 'target' : 'published'; 94 | if (!ports || ports.length === 0) { 95 | runner.logger.debug(`${LOG_PREFIX} Skip connection check as there are no ports exposed.`); 96 | return; 97 | } 98 | 99 | return race( 100 | dockerEventStream$.pipe( 101 | skipWhile((event) => event.action !== 'die' && event.action !== 'kill'), 102 | map((event) => { 103 | throw new DockestError('Container unexpectedly died.', { event }); 104 | }), 105 | ), 106 | of(...ports.map(selectPortMapping)).pipe( 107 | // concatMap -> run checks for each port in sequence 108 | concatMap(({ [portKey]: port }) => { 109 | return checkPortConnection({ 110 | runner, 111 | host, 112 | port, 113 | acquireConnection, 114 | }); 115 | }), 116 | // we do not care about the single elements, we only want this stream to complete without errors. 117 | ignoreElements(), 118 | ), 119 | ).toPromise(); 120 | }; 121 | 122 | export const checkConnection = createCheckConnection({ acquireConnection }); 123 | -------------------------------------------------------------------------------- /packages/dockest/src/run/wait-for-services/docker-compose-up.ts: -------------------------------------------------------------------------------- 1 | import { DockestConfig } from '../../@types'; 2 | import { GENERATED_COMPOSE_FILE_PATH } from '../../constants'; 3 | import { execaWrapper } from '../../utils/execa-wrapper'; 4 | 5 | export const dockerComposeUp = async ({ 6 | composeOpts: { alwaysRecreateDeps, build, forceRecreate, noBuild, noColor, noDeps, noRecreate, quietPull }, 7 | serviceName, 8 | }: { 9 | composeOpts: DockestConfig['composeOpts']; 10 | serviceName: string; 11 | }) => { 12 | const command = `docker-compose \ 13 | -f ${`${GENERATED_COMPOSE_FILE_PATH}`} \ 14 | up \ 15 | ${alwaysRecreateDeps ? '--always-recreate-deps' : ''} \ 16 | ${build ? '--build' : ''} \ 17 | ${forceRecreate ? '--force-recreate' : ''} \ 18 | ${noBuild ? '--no-build' : ''} \ 19 | ${noColor ? '--no-color' : ''} \ 20 | ${noDeps ? '--no-deps' : ''} \ 21 | ${noRecreate ? '--no-recreate' : ''} \ 22 | ${quietPull ? '--quiet-pull' : ''} \ 23 | --detach \ 24 | ${serviceName}`; 25 | 26 | execaWrapper(command); 27 | }; 28 | -------------------------------------------------------------------------------- /packages/dockest/src/run/wait-for-services/fix-runner-host-access-on-linux.ts: -------------------------------------------------------------------------------- 1 | import { Runner } from '../../@types'; 2 | import { DEFAULT_HOST_NAME } from '../../constants'; 3 | import { execaWrapper } from '../../utils/execa-wrapper'; 4 | 5 | export const fixRunnerHostAccessOnLinux = async ({ containerId, logger }: Runner) => { 6 | const command = `docker exec ${containerId} \ 7 | /bin/sh -c "ip -4 route list match 0/0 \ 8 | | awk '{print \\$3\\" ${DEFAULT_HOST_NAME}\\"}' \ 9 | >> /etc/hosts"`; 10 | 11 | try { 12 | execaWrapper(command); 13 | } catch (err) { 14 | logger.debug( 15 | 'Fixing the host container access failed. This could be related to the container having already been stopped', 16 | ); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /packages/dockest/src/run/wait-for-services/index.ts: -------------------------------------------------------------------------------- 1 | import toposort from 'toposort'; 2 | import { checkConnection } from './check-connection'; 3 | import { dockerComposeUp } from './docker-compose-up'; 4 | import { fixRunnerHostAccessOnLinux } from './fix-runner-host-access-on-linux'; 5 | import { resolveContainerId } from './resolve-container-id'; 6 | import { runReadinessCheck } from './run-readiness-check'; 7 | import { runRunnerCommands } from './run-runner-commands'; 8 | import { DockestConfig, Runner } from '../../@types'; 9 | import { DOCKEST_HOST_ADDRESS } from '../../constants'; 10 | import { DockestError } from '../../errors'; 11 | import { bridgeNetworkExists } from '../../utils/network/bridge-network-exists'; 12 | import { createBridgeNetwork } from '../../utils/network/create-bridge-network'; 13 | import { joinBridgeNetwork } from '../../utils/network/join-bridge-network'; 14 | import { LogWriter } from '../log-writer'; 15 | 16 | const LOG_PREFIX = '[Setup]'; 17 | 18 | export const waitForServices = async ({ 19 | composeOpts, 20 | hostname, 21 | runMode, 22 | mutables, 23 | runInBand, 24 | skipCheckConnection, 25 | logWriter, 26 | }: { 27 | composeOpts: DockestConfig['composeOpts']; 28 | hostname: DockestConfig['hostname']; 29 | runMode: DockestConfig['runMode']; 30 | mutables: DockestConfig['mutables']; 31 | runInBand: DockestConfig['runInBand']; 32 | skipCheckConnection: DockestConfig['skipCheckConnection']; 33 | logWriter: LogWriter; 34 | }) => { 35 | const waitForRunner = async ({ runner, runner: { isBridgeNetworkMode, serviceName } }: { runner: Runner }) => { 36 | runner.logger.debug(`${LOG_PREFIX} Initiating...`); 37 | 38 | await dockerComposeUp({ composeOpts, serviceName }); 39 | await resolveContainerId({ runner }); 40 | 41 | logWriter.register(runner.serviceName, runner.containerId); 42 | 43 | if (isBridgeNetworkMode) { 44 | await joinBridgeNetwork({ containerId: runner.containerId, alias: serviceName }); 45 | } 46 | 47 | if (process.platform === 'linux' && !isBridgeNetworkMode) { 48 | await fixRunnerHostAccessOnLinux(runner); 49 | } 50 | 51 | if (skipCheckConnection) { 52 | runner.logger.debug(`${LOG_PREFIX} Skip connection check.`); 53 | } else { 54 | await checkConnection({ runner }); 55 | } 56 | await runReadinessCheck({ runner }); 57 | await runRunnerCommands({ runner }); 58 | 59 | runner.logger.info(`${LOG_PREFIX} Success`, { success: true, endingNewLines: 1 }); 60 | }; 61 | 62 | if (runMode === 'docker-injected-host-socket') { 63 | if (!(await bridgeNetworkExists())) { 64 | await createBridgeNetwork(); 65 | } 66 | 67 | await joinBridgeNetwork({ containerId: hostname, alias: DOCKEST_HOST_ADDRESS }); 68 | } 69 | 70 | const dependencyGraph: Array<[string, string | undefined]> = []; 71 | 72 | const walkRunner = (runner: Runner) => { 73 | if (mutables.runnerLookupMap.has(runner.serviceName)) { 74 | return; 75 | } 76 | mutables.runnerLookupMap.set(runner.serviceName, runner); 77 | 78 | if (runner.dependsOn.length === 0) { 79 | dependencyGraph.push([runner.serviceName, undefined]); 80 | } else { 81 | for (const dependencyRunner of runner.dependsOn) { 82 | dependencyGraph.push([dependencyRunner.serviceName, runner.serviceName]); 83 | walkRunner(dependencyRunner); 84 | } 85 | } 86 | }; 87 | 88 | for (const runner of Object.values(mutables.runners)) { 89 | walkRunner(runner); 90 | } 91 | 92 | if (runInBand) { 93 | const ordered: Array = toposort(dependencyGraph).filter((value) => value !== undefined); 94 | 95 | const teardownOrder = ordered.slice(0).reverse(); 96 | mutables.teardownOrder = teardownOrder; 97 | 98 | for (const serviceName of ordered) { 99 | const runner = mutables.runnerLookupMap.get(serviceName); 100 | if (!runner) { 101 | throw new DockestError('Unexpected error. Runner could not be found.'); 102 | } 103 | await waitForRunner({ runner }); 104 | } 105 | } else { 106 | await Promise.all(Array.from(mutables.runnerLookupMap.values()).map((runner) => waitForRunner({ runner }))); 107 | } 108 | }; 109 | -------------------------------------------------------------------------------- /packages/dockest/src/run/wait-for-services/resolve-container-id.ts: -------------------------------------------------------------------------------- 1 | import { interval, race } from 'rxjs'; 2 | import { first, map, skipWhile, take, tap } from 'rxjs/operators'; 3 | import { Runner } from '../../@types'; 4 | import { DockestError } from '../../errors'; 5 | 6 | const LOG_PREFIX = '[Resolve Container Id]'; 7 | const DEFAULT_TIMEOUT = 30; 8 | 9 | export const resolveContainerId = async ({ runner }: { runner: Runner }) => { 10 | return race( 11 | runner.dockerEventStream$.pipe( 12 | first((event) => event.action === 'start'), 13 | tap(({ id: containerId }) => { 14 | runner.logger.info(`${LOG_PREFIX} Success (${containerId})`, { success: true }); 15 | runner.containerId = containerId; 16 | }), 17 | ), 18 | interval(1000).pipe( 19 | tap((i) => { 20 | runner.logger.info(`Still waiting for start event... Timeout in ${DEFAULT_TIMEOUT - i}s`); 21 | }), 22 | skipWhile((i) => i < DEFAULT_TIMEOUT), 23 | map(() => { 24 | throw new DockestError('Timed out', { runner }); 25 | }), 26 | ), 27 | ) 28 | .pipe(take(1)) 29 | .toPromise(); 30 | }; 31 | -------------------------------------------------------------------------------- /packages/dockest/src/run/wait-for-services/run-readiness-check.spec.ts: -------------------------------------------------------------------------------- 1 | import { runReadinessCheck } from './run-readiness-check'; 2 | import { DockestError } from '../../errors'; 3 | import { createRunner } from '../../test-utils'; 4 | 5 | describe('happy', () => { 6 | it('succeeds in case the readinessCheck succeeds', async () => { 7 | const runner = createRunner({ readinessCheck: () => Promise.resolve() }); 8 | const result = await runReadinessCheck({ runner }); 9 | expect(result).toEqual(undefined); 10 | }); 11 | }); 12 | 13 | describe('sad', () => { 14 | it('fails in case the readiness rejects', async () => { 15 | const runner = createRunner({ readinessCheck: () => Promise.reject(new DockestError('ReadinessCheck failed.')) }); 16 | 17 | try { 18 | await runReadinessCheck({ runner }); 19 | expect(true).toBe('Should throw.'); 20 | } catch (error) { 21 | expect(error).toMatchInlineSnapshot(`[DockestError: ReadinessCheck failed.]`); 22 | } 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/dockest/src/run/wait-for-services/run-readiness-check.ts: -------------------------------------------------------------------------------- 1 | import { from, of } from 'rxjs'; 2 | import { mergeMap, tap } from 'rxjs/operators'; 3 | import { Runner } from '../../@types'; 4 | 5 | const LOG_PREFIX = '[Run ReadinessCheck]'; 6 | 7 | export const runReadinessCheck = async ({ runner }: { runner: Runner }) => { 8 | return of(runner.readinessCheck) 9 | .pipe( 10 | tap(() => runner.logger.debug(`${LOG_PREFIX} Starting`)), 11 | mergeMap((readinessCheck) => 12 | from( 13 | readinessCheck({ 14 | runner, 15 | }), 16 | ), 17 | ), 18 | tap(() => { 19 | runner.logger.info(`${LOG_PREFIX} Success`, { success: true }); 20 | }), 21 | ) 22 | .toPromise(); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/dockest/src/run/wait-for-services/run-runner-commands.ts: -------------------------------------------------------------------------------- 1 | import { Runner } from '../../@types'; 2 | import { execaWrapper } from '../../utils/execa-wrapper'; 3 | 4 | const LOG_PREFIX = '[Dockest Service Commands]'; 5 | 6 | export const runRunnerCommands = async ({ runner, runner: { commands } }: { runner: Runner }) => { 7 | for (let command of commands) { 8 | if (typeof command === 'function') { 9 | command = command(runner.containerId); 10 | } 11 | execaWrapper(command, { runner, logPrefix: LOG_PREFIX, logStdout: true }); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /packages/dockest/src/test-helper/index.ts: -------------------------------------------------------------------------------- 1 | import { DockerComposeFile, TestRunModeType } from '../@types'; 2 | import { DEFAULT_HOST_NAME, DOCKEST_ATTACH_TO_PROCESS, DOCKEST_HOST_ADDRESS } from '../constants'; 3 | import { DockestError } from '../errors'; 4 | import { getRunMode as _getRunMode } from '../utils/get-run-mode'; 5 | import { selectPortMapping } from '../utils/select-port-mapping'; 6 | 7 | let runMode: TestRunModeType | null = null; 8 | 9 | const getRunMode = (): TestRunModeType => { 10 | if (!runMode) { 11 | runMode = _getRunMode(); 12 | } 13 | return runMode; 14 | }; 15 | 16 | const dockestConfig = process.env[DOCKEST_ATTACH_TO_PROCESS]; 17 | 18 | if (!dockestConfig) { 19 | throw new DockestError('Config not attached to process: Not executed inside dockest context'); 20 | } 21 | 22 | const config: DockerComposeFile = JSON.parse(dockestConfig); 23 | 24 | export const getHostAddress = () => { 25 | if (getRunMode() !== 'docker-injected-host-socket') { 26 | return DEFAULT_HOST_NAME; 27 | } 28 | return DOCKEST_HOST_ADDRESS; 29 | }; 30 | 31 | export const resolveServiceAddress = (serviceName: string, targetPort: number | string) => { 32 | const service = config.services[serviceName]; 33 | if (!service || !service.ports) { 34 | throw new DockestError(`Service "${serviceName}" does not exist`); 35 | } 36 | 37 | const portBinding = service.ports.map(selectPortMapping).find((portBinding) => portBinding.target === targetPort); 38 | if (!portBinding) { 39 | throw new DockestError(`Service "${serviceName}" has no target port ${portBinding}`); 40 | } 41 | 42 | if (getRunMode() === 'docker-injected-host-socket') { 43 | return { host: serviceName, port: portBinding.target }; 44 | } 45 | 46 | return { host: 'localhost', port: portBinding.published }; 47 | }; 48 | 49 | export const getServiceAddress = (serviceName: string, targetPort: number | string) => { 50 | const record = resolveServiceAddress(serviceName, targetPort); 51 | return `${record.host}:${record.port}`; 52 | }; 53 | -------------------------------------------------------------------------------- /packages/dockest/src/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { ReplaySubject } from 'rxjs'; 2 | import { DockerComposeFile, DockestService, Runner } from './@types'; 3 | import { Logger } from './logger'; 4 | 5 | export const createRunner = (overrides?: Partial): Runner => ({ 6 | commands: [], 7 | containerId: '', 8 | dependsOn: [], 9 | dockerComposeFileService: { image: 'node:18-alpine', ports: [{ published: 3000, target: 3000 }] }, 10 | dockerEventStream$: new ReplaySubject(), 11 | logger: new Logger('node'), 12 | readinessCheck: () => Promise.resolve(), 13 | serviceName: 'node', 14 | ...(overrides || {}), 15 | }); 16 | 17 | export const DOCKEST_SERVICE: DockestService = { 18 | serviceName: 'redis', 19 | }; 20 | 21 | export const DOCKER_COMPOSE_FILE: DockerComposeFile = { 22 | version: '3.8', 23 | services: { 24 | [DOCKEST_SERVICE.serviceName]: { 25 | image: 'redis:5.0.3-alpine', 26 | ports: [ 27 | { 28 | published: 6379, 29 | target: 6379, 30 | }, 31 | ], 32 | }, 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/__snapshots__/format-zod-error.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`formatZodError should truncate if more than 100 issues 2`] = ` 4 | "[ZodValidationError for "Person"] 5 | String must contain at least 5 character(s) at "[0].name" 6 | Number must be greater than or equal to 33 at "[0].age" 7 | String must contain at least 5 character(s) at "[1].name" 8 | Number must be greater than or equal to 33 at "[1].age" 9 | String must contain at least 5 character(s) at "[2].name" 10 | Number must be greater than or equal to 33 at "[2].age" 11 | String must contain at least 5 character(s) at "[3].name" 12 | Number must be greater than or equal to 33 at "[3].age" 13 | String must contain at least 5 character(s) at "[4].name" 14 | Number must be greater than or equal to 33 at "[4].age" 15 | String must contain at least 5 character(s) at "[5].name" 16 | Number must be greater than or equal to 33 at "[5].age" 17 | String must contain at least 5 character(s) at "[6].name" 18 | Number must be greater than or equal to 33 at "[6].age" 19 | String must contain at least 5 character(s) at "[7].name" 20 | Number must be greater than or equal to 33 at "[7].age" 21 | String must contain at least 5 character(s) at "[8].name" 22 | Number must be greater than or equal to 33 at "[8].age" 23 | String must contain at least 5 character(s) at "[9].name" 24 | Number must be greater than or equal to 33 at "[9].age" 25 | String must contain at least 5 character(s) at "[10].name" 26 | Number must be greater than or equal to 33 at "[10].age" 27 | String must contain at least 5 character(s) at "[11].name" 28 | Number must be greater than or equal to 33 at "[11].age" 29 | String must contain at least 5 character(s) at "[12].name" 30 | Number must be greater than or equal to 33 at "[12].age" 31 | String must contain at least 5 character(s) at "[13].name" 32 | Number must be greater than or equal to 33 at "[13].age" 33 | String must contain at least 5 character(s) at "[14].name" 34 | Number must be greater than or equal to 33 at "[14].age" 35 | String must contain at least 5 character(s) at "[15].name" 36 | Number must be greater than or equal to 33 at "[15].age" 37 | String must contain at least 5 character(s) at "[16].name" 38 | Number must be greater than or equal to 33 at "[16].age" 39 | String must contain at least 5 character(s) at "[17].name" 40 | Number must be greater than or equal to 33 at "[17].age" 41 | String must contain at least 5 character(s) at "[18].name" 42 | Number must be greater than or equal to 33 at "[18].age" 43 | String must contain at least 5 character(s) at "[19].name" 44 | Number must be greater than or equal to 33 at "[19].age" 45 | String must contain at least 5 character(s) at "[20].name" 46 | Number must be greater than or equal to 33 at "[20].age" 47 | String must contain at least 5 character(s) at "[21].name" 48 | Number must be greater than or equal to 33 at "[21].age" 49 | String must contain at least 5 character(s) at "[22].name" 50 | Number must be greater than or equal to 33 at "[22].age" 51 | String must contain at least 5 character(s) at "[23].name" 52 | Number must be greater than or equal to 33 at "[23].age" 53 | String must contain at least 5 character(s) at "[24].name" 54 | Number must be greater than or equal to 33 at "[24].age" 55 | String must contain at least 5 character(s) at "[25].name" 56 | Number must be greater than or equal to 33 at "[25].age" 57 | String must contain at least 5 character(s) at "[26].name" 58 | Number must be greater than or equal to 33 at "[26].age" 59 | String must contain at least 5 character(s) at "[27].name" 60 | Number must be greater than or equal to 33 at "[27].age" 61 | String must contain at least 5 character(s) at "[28].name" 62 | Number must be greater than or equal to 33 at "[28].age" 63 | String must contain at least 5 character(s) at "[29].name" 64 | Number must be greater than or equal to 33 at "[29].age" 65 | String must contain at least 5 character(s) at "[30].name" 66 | Number must be greater than or equal to 33 at "[30].age" 67 | String must contain at least 5 character(s) at "[31].name" 68 | Number must be greater than or equal to 33 at "[31].age" 69 | String must contain at least 5 character(s) at "[32].name" 70 | Number must be greater than or equal to 33 at "[32].age" 71 | String must contain at least 5 character(s) at "[33].name" 72 | Number must be greater than or equal to 33 at "[33].age" 73 | String must contain at least 5 character(s) at "[34].name" 74 | Number must be greater than or equal to 33 at "[34].age" 75 | String must contain at least 5 character(s) at "[35].name" 76 | Number must be greater than or equal to 33 at "[35].age" 77 | String must contain at least 5 character(s) at "[36].name" 78 | Number must be greater than or equal to 33 at "[36].age" 79 | String must contain at least 5 character(s) at "[37].name" 80 | Number must be greater than or equal to 33 at "[37].age" 81 | String must contain at least 5 character(s) at "[38].name" 82 | Number must be greater than or equal to 33 at "[38].age" 83 | String must contain at least 5 character(s) at "[39].name" 84 | Number must be greater than or equal to 33 at "[39].age" 85 | String must contain at least 5 character(s) at "[40].name" 86 | Number must be greater than or equal to 33 at "[40].age" 87 | String must contain at least 5 character(s) at "[41].name" 88 | Number must be greater than or equal to 33 at "[41].age" 89 | String must contain at least 5 character(s) at "[42].name" 90 | Number must be greater than or equal to 33 at "[42].age" 91 | String must contain at least 5 character(s) at "[43].name" 92 | Number must be greater than or equal to 33 at "[43].age" 93 | String must contain at least 5 character(s) at "[44].name" 94 | Number must be greater than or equal to 33 at "[44].age" 95 | String must contain at least 5 character(s) at "[45].name" 96 | Number must be greater than or equal to 33 at "[45].age" 97 | String must contain at least 5 character(s) at "[46].name" 98 | Number must be greater than or equal to 33 at "[46].age" 99 | String must contain at least 5 character(s) at "[47].name" 100 | Number must be greater than or equal to 33 at "[47].age" 101 | String must contain at least 5 character(s) at "[48].name" 102 | Number must be greater than or equal to 33 at "[48].age" 103 | String must contain at least 5 character(s) at "[49].name" 104 | Number must be greater than or equal to 33 at "[49].age"" 105 | `; 106 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/custom-zod-error-map.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { customZodErrorMap, resetGlobalCustomZodErrorMap, setGlobalCustomZodErrorMap } from './custom-zod-error-map'; 4 | 5 | const Person = z.object({ 6 | name: z.string(), 7 | }); 8 | 9 | describe('customZodErrorMap', () => { 10 | it('should set the custom zod error map', () => { 11 | // with default error map 12 | expect(() => Person.parse({ name: 123 })).toThrowErrorMatchingInlineSnapshot(` 13 | "[ 14 | { 15 | "code": "invalid_type", 16 | "expected": "string", 17 | "received": "number", 18 | "path": [ 19 | "name" 20 | ], 21 | "message": "Expected string, received number" 22 | } 23 | ]" 24 | `); 25 | 26 | // with custom error map 27 | setGlobalCustomZodErrorMap(); 28 | expect(() => Person.parse({ name: 123 })).toThrowErrorMatchingInlineSnapshot(` 29 | "[ 30 | { 31 | "code": "invalid_type", 32 | "expected": "string", 33 | "received": "number", 34 | "path": [ 35 | "name" 36 | ], 37 | "message": "Expected string, received number [DATA]<123>" 38 | } 39 | ]" 40 | `); 41 | 42 | // reset -> back to default error map 43 | resetGlobalCustomZodErrorMap(); 44 | expect(() => Person.parse({ name: 123 })).toThrowErrorMatchingInlineSnapshot(` 45 | "[ 46 | { 47 | "code": "invalid_type", 48 | "expected": "string", 49 | "received": "number", 50 | "path": [ 51 | "name" 52 | ], 53 | "message": "Expected string, received number" 54 | } 55 | ]" 56 | `); 57 | 58 | // with custom error map (without data) 59 | setGlobalCustomZodErrorMap({ appendInputData: false }); 60 | expect(() => Person.parse({ name: 123 })).toThrowErrorMatchingInlineSnapshot(` 61 | "[ 62 | { 63 | "code": "invalid_type", 64 | "expected": "string", 65 | "received": "number", 66 | "path": [ 67 | "name" 68 | ], 69 | "message": "Expected string, received number" 70 | } 71 | ]" 72 | `); 73 | 74 | // reset -> back to default error map 75 | resetGlobalCustomZodErrorMap(); 76 | expect(() => Person.parse({ name: 123 })).toThrowErrorMatchingInlineSnapshot(` 77 | "[ 78 | { 79 | "code": "invalid_type", 80 | "expected": "string", 81 | "received": "number", 82 | "path": [ 83 | "name" 84 | ], 85 | "message": "Expected string, received number" 86 | } 87 | ]" 88 | `); 89 | 90 | // with custom error map (with data) 91 | expect(() => Person.parse({ name: 123 }, { errorMap: customZodErrorMap({ appendInputData: true }) })) 92 | .toThrowErrorMatchingInlineSnapshot(` 93 | "[ 94 | { 95 | "code": "invalid_type", 96 | "expected": "string", 97 | "received": "number", 98 | "path": [ 99 | "name" 100 | ], 101 | "message": "Expected string, received number [DATA]<123>" 102 | } 103 | ]" 104 | `); 105 | 106 | // the custom error map should not be set globally 107 | expect(() => Person.parse({ name: 123 })).toThrowErrorMatchingInlineSnapshot(` 108 | "[ 109 | { 110 | "code": "invalid_type", 111 | "expected": "string", 112 | "received": "number", 113 | "path": [ 114 | "name" 115 | ], 116 | "message": "Expected string, received number" 117 | } 118 | ]" 119 | `); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/custom-zod-error-map.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | type CustomZodErrorMapOptions = { 4 | /** 5 | * Append the input data to the default error message. 6 | */ 7 | appendInputData?: boolean; 8 | }; 9 | 10 | const DEFAULT_CUSTOM_ZOD_ERROR_MAP_OPTIONS: CustomZodErrorMapOptions = { 11 | appendInputData: true, 12 | }; 13 | 14 | /** 15 | * Create a custom ZodErrorMap with options to append input data to the default error message. 16 | * 17 | * `customZodErrorMap` can also be used in individual parsings, for example: 18 | * `Person.safeParse(person, { errorMap: customZodErrorMap() });` 19 | */ 20 | export function customZodErrorMap( 21 | customZodErrorMapOptions: CustomZodErrorMapOptions = DEFAULT_CUSTOM_ZOD_ERROR_MAP_OPTIONS, 22 | ): z.ZodErrorMap { 23 | return (_issue, ctx) => { 24 | if (!customZodErrorMapOptions.appendInputData) { 25 | return { 26 | message: ctx.defaultError, 27 | }; 28 | } 29 | 30 | return { 31 | message: `${ctx.defaultError} [DATA]<${JSON.stringify(ctx.data)}>`, 32 | }; 33 | }; 34 | } 35 | 36 | /** 37 | * There can only be one global ZodErrorMap set at any one time. 38 | * 39 | * Using this function will set the global ZodErrorMap to `customZodErrorMap`. 40 | * 41 | * The default ZodErrorMap can still be used in individual parsings, for example: 42 | * `Person.safeParse(person, { errorMap: z.defaultErrorMap });` 43 | */ 44 | export function setGlobalCustomZodErrorMap(options: CustomZodErrorMapOptions = DEFAULT_CUSTOM_ZOD_ERROR_MAP_OPTIONS) { 45 | z.setErrorMap(customZodErrorMap(options)); 46 | } 47 | 48 | /** 49 | * There can only be one global ZodErrorMap set at any one time. 50 | * 51 | * Using this function will reset the global ZodErrorMap to `z.defaultErrorMap`, 52 | * essentially overriding the previous global ZodErrorMap. 53 | */ 54 | export function resetGlobalCustomZodErrorMap() { 55 | z.setErrorMap(z.defaultErrorMap); 56 | } 57 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/execa-wrapper.ts: -------------------------------------------------------------------------------- 1 | import execa, { SyncOptions } from 'execa'; // eslint-disable-line import/default 2 | import { trim } from './trim'; 3 | import { Runner } from '../@types'; 4 | import { Logger } from '../logger'; 5 | 6 | interface Opts { 7 | runner?: Runner; 8 | logPrefix?: string; 9 | logStdout?: boolean; 10 | execaOpts?: SyncOptions; 11 | } 12 | 13 | export const execaWrapper = ( 14 | command: string, 15 | { runner, logPrefix = '[Shell]', logStdout = false, execaOpts = {} }: Opts = {}, 16 | ) => { 17 | const trimmedCommand = trim(command); 18 | const logger = runner ? runner.logger : Logger; 19 | 20 | logger.debug(`${logPrefix} <${trimmedCommand}>`); 21 | 22 | const result = execa.commandSync(trimmedCommand, { 23 | shell: true, 24 | ...execaOpts, 25 | }); 26 | 27 | logStdout && logger.debug(`${logPrefix} Success (${result.stdout})`, { success: true }); 28 | 29 | return result; 30 | }; 31 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/format-zod-error.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { formatZodError } from './format-zod-error'; 4 | 5 | describe('formatZodError', () => { 6 | const Person = z.object({ 7 | name: z.string(), 8 | age: z.number(), 9 | nested: z.object({ 10 | field: z.string(), 11 | }), 12 | }); 13 | const Options = z.union([z.literal('option-1'), z.literal('option-2')]); 14 | 15 | it('should format a ZodError for a union', () => { 16 | const invalidOptions = Options.safeParse('not-an-option'); 17 | if (invalidOptions.success) { 18 | fail("Shouldn't reach this point"); 19 | } 20 | 21 | const result = formatZodError(invalidOptions.error, 'Options'); 22 | 23 | expect(result).toMatchInlineSnapshot(` 24 | "[ZodValidationError for "Options"] 25 | Invalid literal value, expected "option-1" 26 | Invalid literal value, expected "option-2"" 27 | `); 28 | }); 29 | 30 | it('should format a ZodError with an identity', () => { 31 | const invalidPerson = Person.safeParse({ name: 1 }); 32 | if (invalidPerson.success) { 33 | fail("Shouldn't reach this point"); 34 | } 35 | 36 | const result = formatZodError(invalidPerson.error, 'Person'); 37 | 38 | expect(result).toMatchInlineSnapshot(` 39 | "[ZodValidationError for "Person"] 40 | Expected string, received number at "name" 41 | Required at "age" 42 | Required at "nested"" 43 | `); 44 | }); 45 | 46 | it('should format a ZodError without an identity', () => { 47 | const invalidPerson = Person.safeParse({ name: 1 }); 48 | if (invalidPerson.success) { 49 | fail("Shouldn't reach this point"); 50 | } 51 | 52 | const result = formatZodError(invalidPerson.error, 'Person'); 53 | 54 | expect(result).toMatchInlineSnapshot(` 55 | "[ZodValidationError for "Person"] 56 | Expected string, received number at "name" 57 | Required at "age" 58 | Required at "nested"" 59 | `); 60 | }); 61 | 62 | it('should format a ZodError with several errors', () => { 63 | const Person = z.object({ 64 | name: z.string().min(5), 65 | age: z.number().min(33), 66 | }); 67 | const invalidPerson = Person.safeParse({ 68 | name: 'Rob', 69 | age: 32, 70 | }); 71 | if (invalidPerson.success) { 72 | fail("Shouldn't reach this point"); 73 | } 74 | 75 | const result = formatZodError(invalidPerson.error, 'Person'); 76 | 77 | expect(result).toMatchInlineSnapshot(` 78 | "[ZodValidationError for "Person"] 79 | String must contain at least 5 character(s) at "name" 80 | Number must be greater than or equal to 33 at "age"" 81 | `); 82 | }); 83 | 84 | it('should truncate if more than 100 issues', () => { 85 | const Person = z.array( 86 | z.object({ 87 | name: z.string().min(5), 88 | age: z.number().min(33), 89 | }), 90 | ); 91 | const invalidPerson = Person.safeParse( 92 | Array.from({ length: 300 }).map(() => ({ 93 | name: 'Rob', 94 | age: 32, 95 | })), 96 | ); 97 | if (invalidPerson.success) { 98 | fail("Shouldn't reach this point"); 99 | } 100 | 101 | const result = formatZodError(invalidPerson.error, 'Person'); 102 | 103 | expect(result.split('\n').length).toMatchInlineSnapshot(`101`); 104 | expect(result).toMatchSnapshot(); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/format-zod-error.ts: -------------------------------------------------------------------------------- 1 | import { ZodError } from 'zod'; 2 | import { fromZodError } from 'zod-validation-error'; 3 | 4 | /** 5 | * Format a ZodError into a string with a newline per issue 6 | * 7 | * Completely missing fields in an object will be output as `Required at "path.to.field"` 8 | * 9 | * There's a hard limit of 100 issues in the message, so if you have more than 100 issues, you'll get a truncated message. 10 | */ 11 | export function formatZodError( 12 | zodError: ZodError, 13 | /** 14 | * The Zod model's name, E.g. "Person". 15 | */ 16 | modelName: string, 17 | ) { 18 | const { message, name } = fromZodError(zodError, { 19 | issueSeparator: '\n', 20 | prefix: '', 21 | prefixSeparator: '', 22 | unionSeparator: '\n', 23 | maxIssuesInMessage: 100, 24 | }); 25 | 26 | return `[${name} for "${modelName}"] 27 | ${message}`; 28 | } 29 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/get-opts.spec.ts: -------------------------------------------------------------------------------- 1 | import { getOpts } from './get-opts'; 2 | 3 | describe('getOpts', () => { 4 | it('should snapshot default opts', () => { 5 | expect(getOpts()).toMatchInlineSnapshot( 6 | { 7 | mutables: expect.any(Object), 8 | perfStart: expect.any(Number), 9 | }, 10 | ` 11 | { 12 | "composeFile": "docker-compose.yml", 13 | "composeOpts": { 14 | "alwaysRecreateDeps": false, 15 | "build": false, 16 | "forceRecreate": false, 17 | "noBuild": false, 18 | "noColor": false, 19 | "noDeps": false, 20 | "noRecreate": false, 21 | "quietPull": false, 22 | }, 23 | "containerLogs": { 24 | "logPath": "./", 25 | "modes": [ 26 | "aggregate", 27 | ], 28 | "serviceNameFilter": undefined, 29 | }, 30 | "debug": false, 31 | "dumpErrors": false, 32 | "exitHandler": [Function], 33 | "hostname": "host.docker.internal", 34 | "jestLib": { 35 | "SearchSource": [Function], 36 | "createTestScheduler": [Function], 37 | "getVersion": [Function], 38 | "run": [Function], 39 | "runCLI": [Function], 40 | }, 41 | "jestOpts": { 42 | "projects": [ 43 | ".", 44 | ], 45 | "runInBand": true, 46 | }, 47 | "logLevel": 3, 48 | "mutables": Any, 49 | "perfStart": Any, 50 | "runInBand": true, 51 | "runMode": "host", 52 | "skipCheckConnection": false, 53 | } 54 | `, 55 | ); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/get-opts.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { getRunMode } from './get-run-mode'; 3 | import { DockestConfig, DockestOpts } from '../@types'; 4 | import { DEFAULT_HOST_NAME, LOG_LEVEL } from '../constants'; 5 | import { LogWriterModeType } from '../run/log-writer'; 6 | 7 | export const getOpts = (opts: Partial = {}): DockestConfig => { 8 | const { 9 | composeFile = 'docker-compose.yml', 10 | composeOpts: { 11 | alwaysRecreateDeps = false, 12 | build = false, 13 | forceRecreate = false, 14 | noBuild = false, 15 | noColor = false, 16 | noDeps = false, 17 | noRecreate = false, 18 | quietPull = false, 19 | } = {}, 20 | debug = false || process.argv.includes('dev') || process.argv.includes('debug'), 21 | dumpErrors = false, 22 | exitHandler = async () => Promise.resolve(), 23 | jestLib = require('jest'), 24 | jestOpts, 25 | jestOpts: { projects = ['.'], runInBand: runInBandJest = true } = {}, 26 | logLevel = LOG_LEVEL.INFO, 27 | runInBand = true, 28 | containerLogs: { serviceNameFilter = undefined, modes = ['aggregate'] as LogWriterModeType[], logPath = './' } = {}, 29 | } = opts; 30 | 31 | return { 32 | composeFile, 33 | composeOpts: { 34 | alwaysRecreateDeps, 35 | build, 36 | forceRecreate, 37 | noBuild, 38 | noColor, 39 | noDeps, 40 | noRecreate, 41 | quietPull, 42 | }, 43 | debug, 44 | dumpErrors, 45 | exitHandler, 46 | mutables: { 47 | jestRanWithResult: false, 48 | runners: {}, 49 | dockerEventEmitter: new EventEmitter() as any, 50 | teardownOrder: null, 51 | runnerLookupMap: new Map(), 52 | }, 53 | hostname: process.env.HOSTNAME || DEFAULT_HOST_NAME, 54 | runMode: getRunMode(), 55 | jestLib, 56 | jestOpts: { 57 | projects, 58 | runInBand: runInBandJest, 59 | ...jestOpts, 60 | }, 61 | logLevel, 62 | perfStart: Date.now(), 63 | runInBand, 64 | skipCheckConnection: false, 65 | containerLogs: { 66 | modes, 67 | serviceNameFilter, 68 | logPath, 69 | }, 70 | }; 71 | }; 72 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/get-run-mode.ts: -------------------------------------------------------------------------------- 1 | import isDocker from 'is-docker'; // eslint-disable-line import/default 2 | import { execaWrapper } from './execa-wrapper'; 3 | import { TestRunModeType } from '../@types'; 4 | import { DockestError } from '../errors'; 5 | import { Logger } from '../logger'; 6 | 7 | export const getRunMode = (): TestRunModeType => { 8 | let mode: TestRunModeType | null = null; 9 | 10 | if (isDocker()) { 11 | const { stdout: result } = execaWrapper(` 12 | sh -c ' 13 | v=$(mount | grep "/run/docker.sock"); \\ 14 | if [ -n "$v" ]; \\ 15 | then \\ 16 | echo "injected-socket"; \\ 17 | elif [ -S /var/run/docker.sock ]; \\ 18 | then \\ 19 | echo "local-socket"; \\ 20 | else \\ 21 | echo "no-socket"; \\ 22 | fi \\ 23 | ' 24 | `); 25 | if (result === 'local-socket') { 26 | mode = 'docker-local-socket'; 27 | } else if (result === 'injected-socket') { 28 | mode = 'docker-injected-host-socket'; 29 | } else { 30 | throw new DockestError(`Resolved invalid mode: '${result}'.`); 31 | } 32 | } else { 33 | mode = 'host'; 34 | } 35 | 36 | Logger.debug(`Run dockest in '${mode}' mode.`); 37 | 38 | return mode; 39 | }; 40 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/hash-code.spec.ts: -------------------------------------------------------------------------------- 1 | import { hashCode } from './hash-code'; 2 | 3 | describe('hashCode', () => { 4 | describe('happy', () => { 5 | it('should generate hashCodes deterministically', () => { 6 | const service = 'postgres1sequelize'; 7 | const service2 = 'postgres1sequelize'; 8 | 9 | const result = hashCode(service); 10 | const result2 = hashCode(service2); 11 | 12 | expect(result).toMatchInlineSnapshot(`-2021595073`); 13 | expect(result).toEqual(result2); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/hash-code.ts: -------------------------------------------------------------------------------- 1 | export const hashCode = (str: string) => 2 | Array.from(str).reduce((hash, char) => (Math.imul(31, hash) + char.charCodeAt(0)) | 0, 0); 3 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/network/bridge-network-exists.ts: -------------------------------------------------------------------------------- 1 | import { BRIDGE_NETWORK_NAME } from '../../constants'; 2 | import { Logger } from '../../logger'; 3 | import { execaWrapper } from '../execa-wrapper'; 4 | 5 | export const bridgeNetworkExists = async () => { 6 | const command = `docker network ls \ 7 | --filter driver=bridge 8 | --filter name=${BRIDGE_NETWORK_NAME} \ 9 | --quiet`; 10 | 11 | const networkExists = !!execaWrapper(command).stdout.trim(); 12 | 13 | if (networkExists) { 14 | Logger.info(`Using existing network "${BRIDGE_NETWORK_NAME}"`); 15 | } 16 | 17 | return networkExists; 18 | }; 19 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/network/create-bridge-network.ts: -------------------------------------------------------------------------------- 1 | import { BRIDGE_NETWORK_NAME } from '../../constants'; 2 | import { execaWrapper } from '../execa-wrapper'; 3 | 4 | export const createBridgeNetwork = async () => { 5 | const command = `docker network create \ 6 | --driver bridge \ 7 | ${BRIDGE_NETWORK_NAME}`; 8 | 9 | execaWrapper(command); 10 | }; 11 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/network/join-bridge-network.ts: -------------------------------------------------------------------------------- 1 | import { BRIDGE_NETWORK_NAME } from '../../constants'; 2 | import { execaWrapper } from '../execa-wrapper'; 3 | 4 | export const joinBridgeNetwork = async ({ containerId, alias }: { containerId: string; alias: string }) => { 5 | const command = `docker network connect \ 6 | ${BRIDGE_NETWORK_NAME} \ 7 | ${`--alias ${alias}`} \ 8 | ${containerId}`; 9 | 10 | execaWrapper(command); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/network/leave-bridge-network.ts: -------------------------------------------------------------------------------- 1 | import { BRIDGE_NETWORK_NAME } from '../../constants'; 2 | import { execaWrapper } from '../execa-wrapper'; 3 | 4 | export const leaveBridgeNetwork = async ({ containerId }: { containerId: string }) => { 5 | const command = `docker network disconnect \ 6 | ${BRIDGE_NETWORK_NAME} \ 7 | ${containerId}`; 8 | 9 | execaWrapper(command); 10 | }; 11 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/network/remove-bridge-network.ts: -------------------------------------------------------------------------------- 1 | import { BRIDGE_NETWORK_NAME } from '../../constants'; 2 | import { execaWrapper } from '../execa-wrapper'; 3 | 4 | export const removeBridgeNetwork = async () => { 5 | const command = `docker network rm \ 6 | ${BRIDGE_NETWORK_NAME}`; 7 | 8 | execaWrapper(command); 9 | }; 10 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/select-port-mapping.spec.ts: -------------------------------------------------------------------------------- 1 | import { selectPortMapping } from './select-port-mapping'; 2 | 3 | describe('selectPortMapping', () => { 4 | it.each([ 5 | ['1234:1111', { target: 1111, published: 1234 }], 6 | ['2222:1111', { target: 1111, published: 2222 }], 7 | ['12:20', { target: 20, published: 12 }], 8 | ['90:1000', { target: 1000, published: 90 }], 9 | ])('can parse the string format', (input, expected) => { 10 | expect(selectPortMapping(input)).toEqual(expected); 11 | }); 12 | 13 | it.each([ 14 | [ 15 | { target: 1234, published: 1111 }, 16 | { target: 1234, published: 1111 }, 17 | ], 18 | [ 19 | { target: 1111, published: 1234 }, 20 | { target: 1111, published: 1234 }, 21 | ], 22 | [ 23 | { target: 3333, published: 3333 }, 24 | { target: 3333, published: 3333 }, 25 | ], 26 | ])('passes through the object format', (input, expected) => { 27 | expect(selectPortMapping(input)).toEqual(expected); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/select-port-mapping.ts: -------------------------------------------------------------------------------- 1 | import { DockerComposePortFormat } from '../@types'; 2 | 3 | export const selectPortMapping = (input: DockerComposePortFormat | string) => { 4 | if (typeof input !== 'string') { 5 | return input; 6 | } 7 | 8 | const [published, target] = input.split(':'); 9 | return { published: parseInt(published, 10), target: parseInt(target, 10) }; 10 | }; 11 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/sleep-with-log.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from './sleep'; 2 | import { Logger } from '../logger'; 3 | 4 | export const sleepWithLog = async (seconds = 30, reason = 'Sleeping...') => { 5 | for (let progress = 1; progress <= seconds; progress++) { 6 | Logger.replacePrevLine({ 7 | message: `${reason}: ${progress}/${seconds}`, 8 | isLast: progress === seconds, 9 | }); 10 | 11 | await sleep(1000); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/sleep.spec.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from './sleep'; 2 | 3 | const defaultSleep = 1000; 4 | const flakynessBuffer = 5; 5 | 6 | describe('sleep', () => { 7 | describe('happy', () => { 8 | it('should sleep for default time', async () => { 9 | const beforeSleep = Date.now(); 10 | 11 | await sleep(); 12 | const afterSleep = Date.now() + flakynessBuffer; 13 | const totalSleep = afterSleep - beforeSleep; 14 | 15 | expect(totalSleep).toBeGreaterThan(defaultSleep); 16 | }); 17 | 18 | it('should sleep for custom time', async () => { 19 | const customSleep = 100; 20 | const beforeSleep = Date.now(); 21 | 22 | await sleep(customSleep); 23 | const afterSleep = Date.now() + flakynessBuffer; 24 | const totalSleep = afterSleep - beforeSleep; 25 | 26 | expect(totalSleep).toBeGreaterThan(customSleep); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms = 1000) => new Promise((resolve) => setTimeout(resolve, ms)); 2 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/teardown-single.ts: -------------------------------------------------------------------------------- 1 | import { execaWrapper } from './execa-wrapper'; 2 | import { Runner } from '../@types'; 3 | import { DockestError } from '../errors'; 4 | 5 | const stopContainerById = async ({ runner, runner: { containerId } }: { runner: Runner }) => { 6 | const command = `docker stop ${containerId}`; 7 | 8 | execaWrapper(command, { runner, logPrefix: '[Stop Container]', logStdout: true }); 9 | }; 10 | 11 | const removeContainerById = async ({ runner, runner: { containerId } }: { runner: Runner }) => { 12 | const command = `docker rm ${containerId} --volumes`; 13 | 14 | execaWrapper(command, { runner, logPrefix: '[Remove Container]', logStdout: true }); 15 | }; 16 | 17 | export const teardownSingle = async ({ runner, runner: { containerId, serviceName } }: { runner: Runner }) => { 18 | if (!containerId) { 19 | throw new DockestError(`Invalid containerId (${containerId}) for service (${serviceName})`, { runner }); 20 | } 21 | 22 | await stopContainerById({ runner }); 23 | await removeContainerById({ runner }); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/dockest/src/utils/trim.ts: -------------------------------------------------------------------------------- 1 | export const trim = (str: string, seperator = ' '): string => str.replace(/\s+/g, seperator).trim(); 2 | -------------------------------------------------------------------------------- /packages/dockest/test-helper/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dockest/test-helper", 3 | "private": true, 4 | "main": "../dist/test-helper", 5 | "typings": "../dist/test-helper/index.d.ts" 6 | } 7 | -------------------------------------------------------------------------------- /packages/dockest/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "lib": ["es2019"], 5 | "target": "es2019", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "esModuleInterop": true, 10 | "noImplicitAny": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "resolveJsonModule": true, 14 | "sourceMap": true, 15 | "strict": true 16 | }, 17 | "include": ["src/**/*"] 18 | } 19 | -------------------------------------------------------------------------------- /packages/dockest/tsconfig.publish.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["**/*.spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/aws-codebuild", 3 | "version": "3.1.0", 4 | "private": true, 5 | "engines": { 6 | "node": ">=18.0.0" 7 | }, 8 | "scripts": { 9 | "build": "(cd ./src && ./codebuild_prebuild.sh)", 10 | "test:examples": "(cd ./src && ./codebuild_test.sh)" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/src/.gitignore: -------------------------------------------------------------------------------- 1 | dockest.tgz 2 | .artifacts 3 | yarn.lock -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/src/CI.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-slim 2 | 3 | LABEL author="Laurin Quast " 4 | 5 | ENV DOCKER_COMPOSE_VERSION="1.27.4" 6 | ENV DOCKER_BUILD_X_VERSION="0.4.2" 7 | 8 | RUN apt-get update \ 9 | && apt-get install -y python3 curl bash apt-transport-https ca-certificates software-properties-common gnupg2 jq \ 10 | && curl https://bootstrap.pypa.io/pip/3.5/get-pip.py -o get-pip.py \ 11 | && python3 get-pip.py \ 12 | && rm get-pip.py \ 13 | && curl -fsSL https://download.docker.com/linux/debian/gpg | apt-key add - \ 14 | && add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable" \ 15 | && apt-get update \ 16 | && apt-get install -y docker-ce docker-ce-cli containerd.io \ 17 | \ 18 | && pip install "docker-compose==$DOCKER_COMPOSE_VERSION" \ 19 | && docker-compose version \ 20 | \ 21 | && mkdir -p ~/.docker/cli-plugins \ 22 | && curl -fsSL "https://github.com/docker/buildx/releases/download/v$DOCKER_BUILD_X_VERSION/buildx-v$DOCKER_BUILD_X_VERSION.linux-amd64" --output ~/.docker/cli-plugins/docker-buildx \ 23 | && chmod a+x ~/.docker/cli-plugins/docker-buildx && \ 24 | echo "done." 25 | -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/src/README.md: -------------------------------------------------------------------------------- 1 | # aws-codebuild 2 | 3 | Example that showcases usage with AWS CodeBuild. 4 | 5 | It can be reused for any CI System that runs your build inside a docker container with an injected docker socket. 6 | 7 | ## Running the build 8 | 9 | ```bash 10 | ./codebuild_prebuild.sh 11 | ./codebuild_test.sh 12 | ``` 13 | 14 | This test should also pass when not being run inside a container. 15 | 16 | ## Differences to running dockest on the host 17 | 18 | - Dockest creates a network that connects the container that runs dockest to the other containers 19 | - Dockest uses the target ports on the containers (instead of the published on the host) 20 | - Services can be accessed via their service name as the hostname 21 | 22 | ## Development 23 | 24 | Dockest must be bundeled as a .tgz and put inside this folder, because the CodeBuild container cannot resolve the parent 25 | directories (check `codebuild_prebuild.sh`). 26 | 27 | # Exposed ports 28 | 29 | - Node 30 | - 9000 31 | -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/src/app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | COPY package.json /app/package.json 4 | RUN sh -c "cd /app && yarn install" 5 | COPY index.js /app/index.js 6 | 7 | EXPOSE 9000 8 | 9 | CMD ["node", "/app/index.js"] 10 | -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/src/app/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 3 | const bodyParser = require('body-parser'); 4 | const app = require('express')(); 5 | const fetch = require('node-fetch'); 6 | 7 | app.use(bodyParser.text()); 8 | 9 | app.post('/', (req, res) => { 10 | const url = req.body; 11 | 12 | res.status(200).send('OK.'); 13 | 14 | setTimeout(() => { 15 | fetch(url); 16 | }, 2000); 17 | }); 18 | 19 | app.listen(9000); 20 | -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/src/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/aws-codebuild--app", 3 | "version": "3.1.0", 4 | "private": true, 5 | "engines": { 6 | "node": ">=18.0.0" 7 | }, 8 | "dependencies": { 9 | "body-parser": "1.19.0", 10 | "express": "4.17.1", 11 | "node-fetch": "2.6.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/src/buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | commands: 6 | # docker in docker integration 7 | - nohup /usr/bin/dockerd --host=unix:///var/run/docker.sock --host=tcp://127.0.0.1:2375 --storage-driver=overlay2& 8 | - timeout 15 sh -c "until docker info; do echo .; sleep 1; done" 9 | - yarn install --no-lockfile 10 | 11 | build: 12 | commands: 13 | - yarn test:codebuild:buildspec 14 | -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/src/codebuild_prebuild.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd ../../../dockest 4 | yarn pack --filename ../examples/aws-codebuild/src/dockest.tgz 5 | cd ../examples/aws-codebuild/src 6 | 7 | yarn cache clean 8 | yarn install --no-lockfile -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/src/codebuild_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euxo pipefail 3 | 4 | echo "Run dockest as usual" 5 | yarn test:codebuild:buildspec 6 | 7 | echo "Build docker image" 8 | docker build --file CI.Dockerfile -t aws-codebuild-ci-node . 9 | 10 | echo "Run dockest inside the codebuild container" 11 | ./codebuild_build.sh -i aws-codebuild-ci-node -a .artifacts 12 | -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/src/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | aws_codebuild_website: 5 | build: ./app 6 | ports: 7 | - published: 9000 8 | target: 9000 9 | -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/src/dockest.ts: -------------------------------------------------------------------------------- 1 | import { Dockest, logLevel } from 'dockest'; 2 | 3 | const { run } = new Dockest({ 4 | dumpErrors: true, 5 | jestLib: require('jest'), 6 | logLevel: logLevel.DEBUG, 7 | }); 8 | 9 | run([{ serviceName: 'aws_codebuild_website' }]); 10 | -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/src/integration-test/hello-world.spec.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import { getHostAddress, getServiceAddress } from 'dockest/test-helper'; 3 | import fetch from 'node-fetch'; 4 | 5 | const TARGET_HOST = getServiceAddress('aws_codebuild_website', 9000); 6 | 7 | // hostname is either our docker container hostname or if not run inside a docker container the docker host 8 | const HOSTNAME = getHostAddress(); 9 | const PORT = 8080; 10 | 11 | let server: http.Server; 12 | 13 | afterEach(async () => { 14 | if (server) { 15 | await new Promise((resolve, reject) => { 16 | server.close((err) => { 17 | if (err) { 18 | reject(err); 19 | return; 20 | } 21 | resolve(undefined); 22 | }); 23 | }); 24 | } 25 | }); 26 | 27 | test('can send a request to the container and it can send a request to us', async (done) => { 28 | await new Promise((resolve) => { 29 | server = http 30 | .createServer((_req, res) => { 31 | res.write('Hello World!'); 32 | res.end(); 33 | done(); 34 | }) 35 | .listen(PORT, () => { 36 | console.log(`Serving on http://${HOSTNAME}:${PORT}`); // eslint-disable-line no-console 37 | resolve(undefined); 38 | }); 39 | }); 40 | 41 | const res = await fetch(`http://${TARGET_HOST}`, { 42 | method: 'post', 43 | body: `http://${HOSTNAME}:${PORT}`, 44 | }).then((res) => res.text()); 45 | 46 | expect(res).toEqual('OK.'); 47 | }); 48 | -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/src/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/integration-test'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/aws-codebuild--src", 3 | "version": "3.1.0", 4 | "private": true, 5 | "main": "index.js", 6 | "engines": { 7 | "node": ">=18.0.0" 8 | }, 9 | "scripts": { 10 | "clean": "rm -rf ./dockest.tgz ./yarn.lock", 11 | "dev:examples:link": "yarn link dockest", 12 | "dev:examples:unlink": "yarn unlink dockest", 13 | "test:codebuild:buildspec": "node_modules/.bin/ts-node dockest" 14 | }, 15 | "dependencies": { 16 | "dockest": "file:./dockest.tgz" 17 | }, 18 | "devDependencies": { 19 | "@types/jest": "^24.0.18", 20 | "@types/js-yaml": "^3.12.1", 21 | "@types/node": "^18.11.9", 22 | "@types/node-fetch": "2.5.2", 23 | "execa": "^2.0.5", 24 | "is-docker": "^2.0.0", 25 | "jest": "^24.9.0", 26 | "js-yaml": "^3.13.1", 27 | "node-fetch": "2.6.0", 28 | "ts-jest": "^24.1.0", 29 | "ts-node": "^8.4.1", 30 | "typescript": "^3.6.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "esModuleInterop": true, 5 | "lib": ["es2019"], 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "noImplicitAny": true, 9 | "sourceMap": true, 10 | "strict": true, 11 | "target": "es2019", 12 | "noUnusedParameters": true, 13 | "noUnusedLocals": true, 14 | "resolveJsonModule": true, 15 | "declaration": true 16 | }, 17 | "include": ["./integration-test/*.ts", "dockest.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /packages/examples/aws-codebuild/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/.dockerignore: -------------------------------------------------------------------------------- 1 | src/app/node_modules 2 | src/node_modules 3 | node_modules 4 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/.gitignore: -------------------------------------------------------------------------------- 1 | src/dockest.tgz -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker:19.03.8-dind 2 | 3 | ARG DOCKER_COMPOSE_VERSION="1.27.4" 4 | ARG DOCKER_BUILDX_VERSION="v0.4.2" 5 | ARG NODE_JS_VERSION="18.0.0" 6 | ARG YARN_VERSION="1.22.10" 7 | 8 | RUN apk add --no-cache curl bash 9 | 10 | # Install Buildx for fast docker builds 11 | RUN mkdir -p ~/.docker/cli-plugins \ 12 | && curl -fsSL "https://github.com/docker/buildx/releases/download/$DOCKER_BUILDX_VERSION/buildx-$DOCKER_BUILDX_VERSION.linux-amd64" --output ~/.docker/cli-plugins/docker-buildx \ 13 | && chmod a+x ~/.docker/cli-plugins/docker-buildx 14 | 15 | # install docker-compsoe 16 | # source https://github.com/wernight/docker-compose/blob/master/Dockerfile 17 | RUN set -x && \ 18 | apk add --no-cache -t .deps ca-certificates && \ 19 | # Install glibc on Alpine (required by docker-compose) from 20 | # https://github.com/sgerrand/alpine-pkg-glibc 21 | # See also https://github.com/gliderlabs/docker-alpine/issues/11 22 | wget -q -O /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub && \ 23 | wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.29-r0/glibc-2.29-r0.apk && \ 24 | apk add glibc-2.29-r0.apk && \ 25 | rm glibc-2.29-r0.apk && \ 26 | apk del --purge .deps 27 | 28 | ENV LD_LIBRARY_PATH=/lib:/usr/lib 29 | 30 | RUN set -x && \ 31 | apk add --no-cache -t .deps ca-certificates && \ 32 | # Required dependencies. 33 | apk add --no-cache zlib libgcc && \ 34 | wget -q -O /usr/local/bin/docker-compose https://github.com/docker/compose/releases/download/$DOCKER_COMPOSE_VERSION/docker-compose-Linux-x86_64 && \ 35 | chmod a+rx /usr/local/bin/docker-compose && \ 36 | \ 37 | # Clean-up 38 | apk del --purge .deps && \ 39 | \ 40 | # Basic check it works 41 | docker-compose version 42 | 43 | # source: https://github.com/nvm-sh/nvm/issues/1102#issuecomment-591560924 44 | RUN apk add --no-cache libstdc++; \ 45 | touch "$HOME/.profile"; \ 46 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.1/install.sh | bash; \ 47 | echo 'source $HOME/.profile;' >> $HOME/.zshrc; \ 48 | echo 'export NVM_NODEJS_ORG_MIRROR=https://unofficial-builds.nodejs.org/download/release;' >> $HOME/.profile; \ 49 | echo 'nvm_get_arch() { nvm_echo "x64-musl"; }' >> $HOME/.profile; \ 50 | NVM_DIR="$HOME/.nvm"; source $HOME/.nvm/nvm.sh; source $HOME/.profile; \ 51 | nvm install $NODE_JS_VERSION; \ 52 | npm install -g "yarn@$YARN_VERSION" 53 | 54 | WORKDIR /usr/app 55 | 56 | COPY src/ . 57 | 58 | CMD ["/usr/app/run-test.sh"] 59 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/README.md: -------------------------------------------------------------------------------- 1 | # Docker in Docker Example 2 | 3 | ## Motivation 4 | 5 | This example will run a docker daemon inside a docker container and run some Dockest tests on containers started with 6 | that daemon. 7 | 8 | Should you do this? Probably not. You can read more here: 9 | https://jpetazzo.github.io/2015/09/03/do-not-use-docker-in-docker-for-ci/ 10 | 11 | Some environments (such as AWS Codebuild) will however require using this method, as they do not provide a way of 12 | injecting the host docker socket into the build container. 13 | 14 | ## How can I use this example? 15 | 16 | ```bash 17 | ./prepare.sh 18 | ./run.sh 19 | ``` 20 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/docker-in-docker", 3 | "version": "3.1.0", 4 | "private": true, 5 | "engines": { 6 | "node": ">=18.0.0" 7 | }, 8 | "scripts": { 9 | "build": "./prepare.sh", 10 | "test:examples": "./run.sh" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/prepare.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euxo pipefail 3 | 4 | cd ../../dockest 5 | yarn pack --filename ../examples/docker-in-docker/src/dockest.tgz 6 | cd ../examples/docker-in-docker/src 7 | 8 | yarn cache clean 9 | yarn install --no-lockfile 10 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euxo pipefail 3 | 4 | # build a container with node, docker and docker-compose 5 | docker build . -t dockest-did-test 6 | 7 | # run our container which will run the tests 8 | # without privileged access the docker ddaemon inside the container can't start. 9 | docker run --privileged dockest-did-test 10 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/src/app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | WORKDIR /app 4 | COPY package.json . 5 | RUN yarn install 6 | COPY index.js . 7 | 8 | EXPOSE 9000 9 | 10 | CMD ["node", "/app/index.js"] 11 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/src/app/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 3 | const bodyParser = require('body-parser'); 4 | const app = require('express')(); 5 | const fetch = require('node-fetch'); 6 | 7 | app.use(bodyParser.text()); 8 | 9 | app.post('/', (req, res) => { 10 | const url = req.body; 11 | 12 | res.status(200).send('OK.'); 13 | 14 | setTimeout(() => { 15 | fetch(url); 16 | }, 2000); 17 | }); 18 | 19 | app.listen(9000); 20 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/src/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/docker-in-docker--app", 3 | "version": "3.1.0", 4 | "private": true, 5 | "engines": { 6 | "node": ">=18.0.0" 7 | }, 8 | "dependencies": { 9 | "body-parser": "1.19.0", 10 | "express": "4.17.1", 11 | "node-fetch": "2.6.1" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/src/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | docker_in_docker_website: 5 | build: ./app 6 | ports: 7 | - published: 9000 8 | target: 9000 9 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/src/dockest.ts: -------------------------------------------------------------------------------- 1 | import { Dockest, logLevel } from 'dockest'; 2 | 3 | const { run } = new Dockest({ 4 | dumpErrors: true, 5 | jestLib: require('jest'), 6 | logLevel: logLevel.DEBUG, 7 | }); 8 | 9 | run([{ serviceName: 'docker_in_docker_website' }]); 10 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/src/integration-test/hello-world.spec.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import { getHostAddress, getServiceAddress } from 'dockest/test-helper'; 3 | import fetch from 'node-fetch'; 4 | 5 | const TARGET_HOST = getServiceAddress('docker_in_docker_website', 9000); 6 | 7 | // hostname is either our docker container hostname or if not run inside a docker container the docker host 8 | const HOSTNAME = getHostAddress(); 9 | const PORT = 8080; 10 | 11 | let server: http.Server; 12 | 13 | afterEach(async () => { 14 | if (server) { 15 | await new Promise((resolve, reject) => { 16 | server.close((err) => { 17 | if (err) { 18 | reject(err); 19 | return; 20 | } 21 | resolve(undefined); 22 | }); 23 | }); 24 | } 25 | }); 26 | 27 | test('can send a request to the container and it can send a request to us', async (done) => { 28 | await new Promise((resolve) => { 29 | server = http 30 | .createServer((_req, res) => { 31 | res.write('Hello World!'); 32 | res.end(); 33 | done(); 34 | }) 35 | .listen(PORT, () => { 36 | console.log(`Serving on http://${HOSTNAME}:${PORT}`); // eslint-disable-line no-console 37 | resolve(undefined); 38 | }); 39 | }); 40 | 41 | const res = await fetch(`http://${TARGET_HOST}`, { 42 | method: 'post', 43 | body: `http://${HOSTNAME}:${PORT}`, 44 | }).then((res) => res.text()); 45 | 46 | expect(res).toEqual('OK.'); 47 | }); 48 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/src/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/integration-test'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/docker-in-docker--src", 3 | "version": "3.1.0", 4 | "private": true, 5 | "main": "index.js", 6 | "engines": { 7 | "node": ">=18.0.0" 8 | }, 9 | "scripts": { 10 | "clean": "rm -rf ./dockest.tgz ./yarn.lock", 11 | "dev:examples:link": "yarn link dockest", 12 | "dev:examples:unlink": "yarn unlink dockest", 13 | "test:docker-in-docker": "node_modules/.bin/ts-node dockest" 14 | }, 15 | "dependencies": { 16 | "dockest": "file:./dockest.tgz" 17 | }, 18 | "devDependencies": { 19 | "@types/jest": "^24.0.18", 20 | "@types/js-yaml": "^3.12.1", 21 | "@types/node": "^18.11.9", 22 | "@types/node-fetch": "2.5.2", 23 | "execa": "^2.0.5", 24 | "is-docker": "^2.0.0", 25 | "jest": "^24.9.0", 26 | "js-yaml": "^3.13.1", 27 | "node-fetch": "2.6.1", 28 | "ts-jest": "^24.1.0", 29 | "ts-node": "^8.4.1", 30 | "typescript": "^3.6.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/src/run-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euxo pipefail 3 | 4 | source "$HOME/.profile" 5 | cat $HOME/.profile 6 | nvm use default 7 | 8 | # start docker daemon 9 | nohup dockerd --host=unix:///var/run/docker.sock --host=tcp://127.0.0.1:2375& 10 | 11 | DOCKER_HOST=tcp://127.0.0.1:2375 12 | # wait until daemon is ready :) 13 | timeout 15 sh -c "until docker info; do echo .; sleep 1; done" 14 | 15 | cd /usr/app 16 | 17 | yarn install --no-lockfile 18 | 19 | docker-compose -f docker-compose.yml build 20 | yarn test:docker-in-docker 21 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "esModuleInterop": true, 5 | "lib": ["es2019"], 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "noImplicitAny": true, 9 | "sourceMap": true, 10 | "strict": true, 11 | "target": "es2019", 12 | "noUnusedParameters": true, 13 | "noUnusedLocals": true, 14 | "resolveJsonModule": true, 15 | "declaration": true 16 | }, 17 | "include": ["./integration-test/*.ts", "dockest.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /packages/examples/docker-in-docker/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | config: path.resolve('./postgres/config', 'postgresConfig.js'), 5 | 'models-path': path.resolve('./postgres', 'models'), 6 | 'seeders-path': path.resolve('./postgres', 'seeders'), 7 | 'migrations-path': path.resolve('./postgres', 'migrations'), 8 | } 9 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/README.md: -------------------------------------------------------------------------------- 1 | # compose-files-only 2 | 3 | Run Dockest without any attachedRunners. 4 | 5 | # Exposed ports 6 | 7 | - Postgres 8 | - 5433 9 | - Redis 10 | - 6380 11 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/docker-compose-postgres.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | multiple_compose_files_postgres: 5 | image: postgres:9.6-alpine 6 | ports: 7 | - published: 5433 8 | target: 5432 9 | environment: 10 | POSTGRES_DB: what 11 | POSTGRES_USER: is 12 | POSTGRES_PASSWORD: love 13 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/docker-compose-redis.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | multiple_compose_files_redis: 5 | image: redis:5.0.3-alpine 6 | ports: 7 | - published: 6380 8 | target: 6379 9 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/dockest.ts: -------------------------------------------------------------------------------- 1 | import { Dockest, logLevel } from 'dockest'; 2 | import { createPostgresReadinessCheck, createRedisReadinessCheck } from 'dockest/readiness-check'; 3 | 4 | const dockest = new Dockest({ 5 | composeFile: ['docker-compose-redis.yml', 'docker-compose-postgres.yml'], 6 | dumpErrors: true, 7 | jestLib: require('jest'), 8 | logLevel: logLevel.DEBUG, 9 | }); 10 | 11 | dockest.run([ 12 | { 13 | serviceName: 'multiple_compose_files_postgres', 14 | commands: [ 15 | 'sequelize db:migrate:undo:all', 16 | 'sequelize db:migrate', 17 | 'sequelize db:seed:undo:all', 18 | 'sequelize db:seed --seed 20190101001337-demo-user', 19 | ], 20 | readinessCheck: createPostgresReadinessCheck(), 21 | }, 22 | 23 | { 24 | serviceName: 'multiple_compose_files_redis', 25 | readinessCheck: createRedisReadinessCheck(), 26 | }, 27 | ]); 28 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | globals: { 5 | 'ts-jest': { 6 | diagnostics: false, // https://huafu.github.io/ts-jest/user/config/diagnostics 7 | }, 8 | }, 9 | testPathIgnorePatterns: ['/node_modules/'], 10 | setupFiles: ['./jest.setup.ts'], 11 | }; 12 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/jest.setup.ts: -------------------------------------------------------------------------------- 1 | jest.setTimeout(1000 * 60 * 10); 2 | 3 | process.env.NODE_ENV = 'test'; 4 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/multiple-compose-files", 3 | "version": "3.1.0", 4 | "private": true, 5 | "engines": { 6 | "node": ">=18.0.0" 7 | }, 8 | "scripts": { 9 | "dev:examples:link": "yarn link dockest", 10 | "dev:examples:unlink": "yarn unlink dockest", 11 | "test:examples": "node_modules/.bin/ts-node ./dockest" 12 | }, 13 | "dependencies": { 14 | "ioredis": "^4.14.0", 15 | "pg": "^8.7.1", 16 | "pg-hstore": "^2.3.3", 17 | "sequelize": "^5.21.3", 18 | "sequelize-cli": "^5.5.1" 19 | }, 20 | "devDependencies": { 21 | "@types/ioredis": "^4.14.4", 22 | "@types/jest": "^24.9.1", 23 | "@types/pg": "^7.14.1", 24 | "@types/sequelize": "4.28.8", 25 | "@types/validator": "12.0.1", 26 | "dockest": "file:../../dockest", 27 | "jest": "^25.1.0", 28 | "ts-jest": "^25.0.0", 29 | "ts-node": "^8.6.2", 30 | "typescript": "^3.7.5" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/postgres/app.spec.ts: -------------------------------------------------------------------------------- 1 | import { execa } from 'dockest'; 2 | import { app } from './app'; 3 | 4 | const seedUser = { 5 | firstName: 'John', 6 | lastName: 'Doe', 7 | email: 'demo@demo.com', 8 | }; 9 | 10 | beforeEach(() => { 11 | execa('sequelize db:seed:undo:all'); 12 | execa('sequelize db:seed:all'); 13 | }); 14 | 15 | describe('postgres-1-sequelize', () => { 16 | it('should get first entry', async () => { 17 | const { firstEntry } = await app(); 18 | 19 | expect(firstEntry).toEqual(expect.objectContaining(seedUser)); 20 | }); 21 | 22 | it('should be able to execute custom shell scripts', async () => { 23 | execa('sequelize db:seed:undo:all'); 24 | 25 | const { firstEntry } = await app(); 26 | 27 | expect(firstEntry).toEqual(null); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/postgres/app.ts: -------------------------------------------------------------------------------- 1 | import { db } from './models'; 2 | 3 | const seedUser = { 4 | firstName: 'John', 5 | lastName: 'Doe', 6 | email: 'demo@demo.com', 7 | }; 8 | 9 | export const app = async () => { 10 | const firstEntry = await db.UserModel.findOne({ 11 | where: { 12 | email: seedUser.email, 13 | }, 14 | returning: true, 15 | }); 16 | 17 | return { 18 | firstEntry, 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/postgres/config/postgresConfig.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | database: 'what', 3 | username: 'is', 4 | password: 'love', 5 | host: 'localhost', 6 | port: 5433, 7 | dialect: 'postgres', 8 | logging: false, 9 | }; 10 | 11 | const postgresConfig = { 12 | development: { ...config }, 13 | test: { ...config }, 14 | production: { ...config }, 15 | }; 16 | 17 | module.exports = postgresConfig; 18 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/postgres/migrations/20190101001337-create-user.js: -------------------------------------------------------------------------------- 1 | const createUserMigration = { 2 | up: (queryInterface, Sequelize) => 3 | queryInterface.createTable('Users', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER, 9 | }, 10 | firstName: { 11 | type: Sequelize.STRING, 12 | }, 13 | lastName: { 14 | type: Sequelize.STRING, 15 | }, 16 | email: { 17 | type: Sequelize.STRING, 18 | }, 19 | createdAt: { 20 | allowNull: true, 21 | type: Sequelize.DATE, 22 | }, 23 | updatedAt: { 24 | allowNull: true, 25 | type: Sequelize.DATE, 26 | }, 27 | }), 28 | down: (queryInterface) => queryInterface.dropTable('Users'), 29 | }; 30 | 31 | module.exports = createUserMigration; 32 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/postgres/models/index.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize, STRING } from 'sequelize'; 2 | 3 | const postgresConfig = require('../config/postgresConfig'); // eslint-disable-line @typescript-eslint/no-var-requires 4 | 5 | const config = postgresConfig.test; 6 | 7 | const sequelize = new Sequelize(config); 8 | 9 | const UserModel = sequelize.define( 10 | 'User', 11 | { 12 | firstName: STRING, 13 | lastName: STRING, 14 | email: STRING, 15 | }, 16 | {}, 17 | ); 18 | 19 | export const db = { 20 | sequelize, 21 | Sequelize, 22 | UserModel, 23 | }; 24 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/postgres/seeders/20190101001337-demo-user.js: -------------------------------------------------------------------------------- 1 | const seedUser = { 2 | firstName: 'John', 3 | lastName: 'Doe', 4 | email: 'demo@demo.com', 5 | }; 6 | 7 | const demoUserSeeder = { 8 | up: (queryInterface) => queryInterface.bulkInsert('Users', [seedUser], {}), 9 | down: (queryInterface) => queryInterface.bulkDelete('Users', {}, {}), 10 | }; 11 | 12 | module.exports = demoUserSeeder; 13 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/redis/app.spec.ts: -------------------------------------------------------------------------------- 1 | import { app } from './app'; 2 | 3 | const seedCake = { 4 | key: 'thecakeistein', 5 | value: 'lie', 6 | }; 7 | 8 | describe('redis', () => { 9 | it('should retrieve seeded value', async () => { 10 | const { redis } = app(); 11 | 12 | const value = await redis.get(seedCake.key); 13 | expect(value).toEqual(seedCake.value); 14 | }); 15 | 16 | it('should handle flushall', async () => { 17 | const { redis } = app(); 18 | 19 | await redis.flushall(); 20 | 21 | const flushedValue = await redis.get(seedCake.key); 22 | expect(flushedValue).toEqual(null); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/redis/app.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | 3 | const seedCake = { 4 | key: 'thecakeistein', 5 | value: 'lie', 6 | }; 7 | 8 | const redis = new Redis({ 9 | host: 'localhost', 10 | port: 6380, 11 | }); 12 | 13 | export const app = () => { 14 | redis.set(seedCake.key, seedCake.value); 15 | 16 | return { 17 | redis, 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/examples/multiple-compose-files/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "lib": ["es2019"], 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "noImplicitAny": true, 8 | "strict": true, 9 | "target": "es2019" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | config: path.resolve('./postgres-1-sequelize/config', 'postgresConfig.js'), 5 | 'models-path': path.resolve('./postgres-1-sequelize', 'models'), 6 | 'seeders-path': path.resolve('./postgres-1-sequelize', 'seeders'), 7 | 'migrations-path': path.resolve('./postgres-1-sequelize', 'migrations'), 8 | } 9 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/README.md: -------------------------------------------------------------------------------- 1 | # Larger integration test example 2 | 3 | Showcases how a runner can inherit the configuration from a compose-file based on its service name. 4 | 5 | The `postgres-service` inherit the image and ports from the `docker-compose.yml` file. 6 | 7 | # Exposed ports 8 | 9 | - Postgres sequelize 10 | - 5435 11 | - Postgres knex 12 | - 5436 13 | - Redis 14 | - 6381 15 | - ZooKeeper 16 | - 2181 17 | - Kafka 18 | - 9092 19 | - 9093 20 | - 9094 21 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | multiple_resources_postgres1sequelize: 5 | image: postgres:9.6-alpine 6 | ports: 7 | - published: 5435 8 | target: 5432 9 | environment: 10 | POSTGRES_DB: baby 11 | POSTGRES_USER: dont 12 | POSTGRES_PASSWORD: hurtme 13 | 14 | multiple_resources_kafka: 15 | image: confluentinc/cp-kafka:5.2.2 16 | ports: 17 | - published: 9092 18 | target: 9092 19 | depends_on: 20 | - multiple_resources_zookeeper 21 | environment: 22 | KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://multiple_resources_kafka:29092,PLAINTEXT_HOST://localhost:9092 23 | KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true' 24 | KAFKA_BROKER_ID: 1 25 | KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT 26 | KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 27 | KAFKA_ZOOKEEPER_CONNECT: multiple_resources_zookeeper:2181 28 | 29 | multiple_resources_postgres2knex: 30 | image: postgres:9.6-alpine 31 | ports: 32 | - published: 5436 33 | target: 5432 34 | environment: 35 | POSTGRES_DB: dont 36 | POSTGRES_USER: hurtme 37 | POSTGRES_PASSWORD: nomore 38 | 39 | multiple_resources_redis: 40 | image: redis:5.0.3-alpine 41 | ports: 42 | - published: 6381 43 | target: 6379 44 | 45 | multiple_resources_zookeeper: 46 | image: confluentinc/cp-zookeeper:5.2.2 47 | ports: 48 | - published: 2181 49 | target: 2181 50 | environment: 51 | ZOOKEEPER_CLIENT_PORT: 2181 52 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/dockest.ts: -------------------------------------------------------------------------------- 1 | import { Dockest, logLevel, sleepWithLog } from 'dockest'; 2 | import { createPostgresReadinessCheck, createRedisReadinessCheck } from 'dockest/readiness-check'; 3 | 4 | const { run } = new Dockest({ 5 | composeFile: 'docker-compose.yml', 6 | dumpErrors: true, 7 | exitHandler: (errorPayload) => 8 | // eslint-disable-next-line no-console 9 | console.log(`❌❌❌ An error occurred 10 | ${JSON.stringify(errorPayload, null, 2)} 11 | ❌❌❌`), 12 | jestLib: require('jest'), 13 | jestOpts: { 14 | updateSnapshot: true, 15 | }, 16 | logLevel: logLevel.DEBUG, 17 | }); 18 | 19 | run([ 20 | { 21 | serviceName: 'multiple_resources_postgres1sequelize', 22 | commands: [ 23 | 'sequelize db:migrate:undo:all', 24 | 'sequelize db:migrate', 25 | 'sequelize db:seed:undo:all', 26 | 'sequelize db:seed --seed 20190101001337-demo-user', 27 | (containerId) => `echo "The container id is ${containerId}"`, 28 | ], 29 | readinessCheck: createPostgresReadinessCheck(), 30 | }, 31 | 32 | { 33 | serviceName: 'multiple_resources_postgres2knex', 34 | commands: ['knex migrate:rollback', 'knex migrate:latest', 'knex seed:run'], 35 | readinessCheck: createPostgresReadinessCheck(), 36 | }, 37 | 38 | { 39 | serviceName: 'multiple_resources_redis', 40 | readinessCheck: createRedisReadinessCheck(), 41 | }, 42 | 43 | { 44 | // https://github.com/wurstmeister/kafka-docker/issues/167 45 | serviceName: 'multiple_resources_kafka', 46 | dependsOn: [ 47 | { 48 | serviceName: 'multiple_resources_zookeeper', 49 | }, 50 | ], 51 | readinessCheck: () => sleepWithLog(20, `Sleeping a bit for Kafka's sake`), 52 | }, 53 | ]); 54 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | globals: { 5 | 'ts-jest': { 6 | diagnostics: false, // https://huafu.github.io/ts-jest/user/config/diagnostics 7 | }, 8 | }, 9 | testPathIgnorePatterns: ['/node_modules/'], 10 | setupFiles: ['./jest.setup.ts'], 11 | }; 12 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/jest.setup.ts: -------------------------------------------------------------------------------- 1 | jest.setTimeout(1000 * 60 * 10); 2 | 3 | process.env.NODE_ENV = 'test'; 4 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/kafka-1-kafkajs/app.spec.ts: -------------------------------------------------------------------------------- 1 | import { app } from './app'; 2 | 3 | const sleep = (ms = 1000) => new Promise((resolve) => setTimeout(resolve, ms)); 4 | 5 | jest.setTimeout(1000 * 60); 6 | 7 | const waitForEventConsumption = async ( 8 | targetCount: number, 9 | endBatchProcessListener: (args: { counter: number }) => void, 10 | startConsuming: () => Promise, 11 | emit: () => Promise, 12 | timeout = 15, 13 | ) => { 14 | const opts = { counter: 0 }; 15 | endBatchProcessListener(opts); 16 | await startConsuming(); 17 | 18 | await sleep(100); // FIXME: Investigate why the consumer doesn't consume messages without this sleep 19 | await emit(); 20 | 21 | const recurse = async () => { 22 | timeout--; 23 | 24 | // eslint-disable-next-line no-console 25 | console.log( 26 | `Waiting for published events to be consumed (Progress: ${opts.counter}/${targetCount}) (Timeout in ${timeout}s)`, 27 | ); 28 | if (timeout <= 0) { 29 | throw new Error('Waiting for event consumption timed out'); 30 | } 31 | 32 | if (opts.counter === targetCount) { 33 | console.log(`✅ Successfully consumed ${opts.counter}/${targetCount} messages`); // eslint-disable-line no-console 34 | return; 35 | } 36 | 37 | await sleep(1000); 38 | await recurse(); 39 | }; 40 | 41 | await recurse(); 42 | }; 43 | 44 | const mockProductionCallback = jest.fn(); 45 | const mockConsumptionCallback = jest.fn(); 46 | const messages = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; 47 | const key = 'arbitrary key 🌮'; 48 | 49 | beforeEach(() => { 50 | mockProductionCallback.mockClear(); 51 | mockConsumptionCallback.mockClear(); 52 | }); 53 | 54 | describe('kafka-1-kafkajs', () => { 55 | it('should be able to produce and consume kafka events', async () => { 56 | const { consumer, emit, startConsuming, stopConsuming } = app( 57 | key, 58 | messages, 59 | mockConsumptionCallback, 60 | mockProductionCallback, 61 | ); 62 | 63 | await waitForEventConsumption( 64 | messages.length, 65 | (opts) => { 66 | consumer.on( 67 | consumer.events.END_BATCH_PROCESS, 68 | ({ payload: { batchSize } }: { payload: { batchSize: number } }) => { 69 | opts.counter += batchSize; 70 | }, 71 | ); 72 | }, 73 | startConsuming, 74 | emit, 75 | ); 76 | await stopConsuming(); 77 | 78 | expect(mockProductionCallback).toHaveBeenCalledWith({ 79 | acks: 1, 80 | messages: messages.map((message) => ({ key, value: message })), 81 | topic: 'dockesttopic', 82 | }); 83 | messages.forEach((message) => { 84 | expect(mockConsumptionCallback).toHaveBeenCalledWith({ 85 | messageHeaders: {}, 86 | messageKey: key, 87 | messageValue: message, 88 | partition: 0, 89 | topic: 'dockesttopic', 90 | }); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/kafka-1-kafkajs/app.ts: -------------------------------------------------------------------------------- 1 | const { Kafka, logLevel } = require('kafkajs'); // eslint-disable-line @typescript-eslint/no-var-requires 2 | 3 | type JestFn = (_: any) => void; 4 | 5 | const kafka = new Kafka({ 6 | brokers: ['localhost:9092'], 7 | clientId: 'dockest_example', 8 | logLevel: logLevel.NOTHING, 9 | retry: { 10 | initialRetryTime: 2500, 11 | retries: 10, 12 | }, 13 | }); 14 | 15 | const createConsumer = ( 16 | mockConsumptionCallback: JestFn, 17 | ): { consumer: any; startConsuming: () => Promise; stopConsuming: () => Promise } => { 18 | const consumer = kafka.consumer({ groupId: 'dockest_group_1' }); 19 | 20 | const startConsuming = async () => { 21 | await consumer.connect(); 22 | await consumer.subscribe({ topic: 'dockesttopic' }); 23 | await consumer.run({ 24 | eachMessage: async ({ 25 | topic, 26 | partition, 27 | message, 28 | }: { 29 | topic: string; 30 | partition: number; 31 | message: { headers: any; key: any; value: any }; 32 | }) => { 33 | mockConsumptionCallback({ 34 | messageHeaders: message.headers, 35 | messageKey: message.key.toString(), 36 | messageValue: message.value.toString(), 37 | partition, 38 | topic, 39 | }); 40 | }, 41 | }); 42 | }; 43 | 44 | const stopConsuming = () => consumer.stop(); 45 | 46 | return { 47 | consumer, 48 | startConsuming, 49 | stopConsuming, 50 | }; 51 | }; 52 | 53 | const produceMessage = ( 54 | key: string, 55 | messages: string[], 56 | mockProductionCallback: JestFn, 57 | ): { emit: () => Promise } => { 58 | const producer = kafka.producer(); 59 | const payload = { 60 | acks: process.env.NODE_ENV === 'test' ? 1 : -1, // https://kafka.js.org/docs/producing#options 61 | topic: 'dockesttopic', 62 | messages: messages.map((message: string) => ({ key, value: message })), 63 | }; 64 | 65 | const emit = async () => { 66 | await producer.connect(); 67 | await producer.send(payload); 68 | await producer.disconnect(); 69 | mockProductionCallback(payload); 70 | }; 71 | 72 | return { 73 | emit, 74 | }; 75 | }; 76 | 77 | export const app = ( 78 | key: string, 79 | messages: string[], 80 | mockConsumptionCallback: JestFn, 81 | mockProductionCallback: JestFn, 82 | ) => ({ 83 | ...createConsumer(mockConsumptionCallback), 84 | ...produceMessage(key, messages, mockProductionCallback), 85 | }); 86 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/knexfile.js: -------------------------------------------------------------------------------- 1 | const dbConfig = { 2 | client: 'postgresql', 3 | connection: { 4 | database: 'dont', 5 | user: 'hurtme', 6 | password: 'nomore', 7 | host: 'localhost', 8 | port: 5436, 9 | }, 10 | migrations: { 11 | directory: './postgres-2-knex/migrations', 12 | tableName: 'knex_migrations', 13 | }, 14 | seeds: { 15 | directory: './postgres-2-knex/seeders', 16 | }, 17 | }; 18 | 19 | module.exports = dbConfig; 20 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/multiple-resources", 3 | "version": "3.1.0", 4 | "private": true, 5 | "engines": { 6 | "node": ">=18.0.0" 7 | }, 8 | "scripts": { 9 | "dev:examples:link": "yarn link dockest", 10 | "dev:examples:unlink": "yarn unlink dockest", 11 | "test:examples": "node_modules/.bin/ts-node ./dockest" 12 | }, 13 | "dependencies": { 14 | "ioredis": "4.14.0", 15 | "kafkajs": "1.10.0", 16 | "knex": "0.20.8", 17 | "pg": "8.7.1", 18 | "pg-hstore": "2.3.2", 19 | "sequelize": "5.21.3", 20 | "sequelize-cli": "5.4.0" 21 | }, 22 | "devDependencies": { 23 | "@types/ioredis": "^4.14.4", 24 | "@types/jest": "^24.9.1", 25 | "@types/kafkajs": "^1.8.2", 26 | "@types/pg": "^7.14.1", 27 | "@types/sequelize": "4.28.8", 28 | "@types/validator": "12.0.1", 29 | "dockest": "file:../../dockest", 30 | "jest": "^25.1.0", 31 | "ts-jest": "^25.0.0", 32 | "ts-node": "^8.6.2", 33 | "typescript": "^3.7.5" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/postgres-1-sequelize/app.spec.ts: -------------------------------------------------------------------------------- 1 | import { execa } from 'dockest'; 2 | import { app } from './app'; 3 | 4 | const { seedUser } = require('./data.json'); // eslint-disable-line @typescript-eslint/no-var-requires 5 | 6 | beforeEach(() => { 7 | execa('sequelize db:seed:undo:all'); 8 | execa('sequelize db:seed:all'); 9 | }); 10 | 11 | describe('postgres-1-sequelize', () => { 12 | it('should get first entry', async () => { 13 | const { firstEntry } = await app(); 14 | 15 | expect(firstEntry).toEqual(expect.objectContaining(seedUser)); 16 | }); 17 | 18 | it('should be able to execute custom shell scripts', async () => { 19 | execa('sequelize db:seed:undo:all'); 20 | 21 | const { firstEntry } = await app(); 22 | 23 | expect(firstEntry).toEqual(null); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/postgres-1-sequelize/app.ts: -------------------------------------------------------------------------------- 1 | import { db } from './models'; 2 | 3 | const { seedUser } = require('./data.json'); // eslint-disable-line @typescript-eslint/no-var-requires 4 | 5 | const getFirstEntry = async () => { 6 | // FUTURE TODO: Handle type error 7 | return await (db as any).UserModel.findOne({ 8 | where: { 9 | email: seedUser.email, 10 | }, 11 | returning: true, 12 | }); 13 | }; 14 | 15 | export const app = async () => { 16 | const firstEntry = await getFirstEntry(); 17 | 18 | return { 19 | firstEntry, 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/postgres-1-sequelize/config/postgresConfig.js: -------------------------------------------------------------------------------- 1 | const postgresConfig = { 2 | development: { 3 | database: 'baby', 4 | username: 'dont', 5 | password: 'hurtme', 6 | host: 'localhost', 7 | port: 5435, 8 | dialect: 'postgres', 9 | logging: false, 10 | }, 11 | test: { 12 | database: 'baby', 13 | username: 'dont', 14 | password: 'hurtme', 15 | host: 'localhost', 16 | port: 5435, 17 | dialect: 'postgres', 18 | logging: false, 19 | }, 20 | production: { 21 | database: 'baby', 22 | username: 'dont', 23 | password: 'hurtme', 24 | host: 'localhost', 25 | port: 5435, 26 | dialect: 'postgres', 27 | logging: false, 28 | }, 29 | }; 30 | 31 | module.exports = postgresConfig; 32 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/postgres-1-sequelize/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "seedUser": { 3 | "firstName": "John", 4 | "lastName": "Doe", 5 | "email": "demo@demo.com" 6 | }, 7 | "seedUser2": { 8 | "firstName": "Johnny", 9 | "lastName": "Boi", 10 | "email": "spec@spec.com" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/postgres-1-sequelize/migrations/20190101001337-create-user.js: -------------------------------------------------------------------------------- 1 | const createUserMigration = { 2 | up: (queryInterface, Sequelize) => 3 | queryInterface.createTable('Users', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER, 9 | }, 10 | firstName: { 11 | type: Sequelize.STRING, 12 | }, 13 | lastName: { 14 | type: Sequelize.STRING, 15 | }, 16 | email: { 17 | type: Sequelize.STRING, 18 | }, 19 | createdAt: { 20 | allowNull: true, 21 | type: Sequelize.DATE, 22 | }, 23 | updatedAt: { 24 | allowNull: true, 25 | type: Sequelize.DATE, 26 | }, 27 | }), 28 | down: (queryInterface, Sequelize) => queryInterface.dropTable('Users'), // eslint-disable-line @typescript-eslint/no-unused-vars 29 | }; 30 | 31 | module.exports = createUserMigration; 32 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/postgres-1-sequelize/models/index.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize, STRING } from 'sequelize'; 2 | 3 | const postgresConfig = require('../config/postgresConfig.js'); // eslint-disable-line @typescript-eslint/no-var-requires 4 | 5 | const config = postgresConfig.test; 6 | 7 | const sequelize = new Sequelize(config); 8 | 9 | const UserModel = sequelize.define( 10 | 'User', 11 | { 12 | firstName: STRING, 13 | lastName: STRING, 14 | email: STRING, 15 | }, 16 | {}, 17 | ); 18 | 19 | export const db = { 20 | sequelize, 21 | Sequelize, 22 | UserModel, 23 | }; 24 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/postgres-1-sequelize/seeders/20190101001337-demo-user.js: -------------------------------------------------------------------------------- 1 | const { seedUser } = require('../data.json'); // eslint-disable-line @typescript-eslint/no-var-requires 2 | 3 | const demoUserSeeder = { 4 | up: (queryInterface, Sequelize) => queryInterface.bulkInsert('Users', [seedUser], {}), // eslint-disable-line @typescript-eslint/no-unused-vars 5 | down: (queryInterface, Sequelize) => queryInterface.bulkDelete('Users', {}, {}), // eslint-disable-line @typescript-eslint/no-unused-vars 6 | }; 7 | 8 | module.exports = demoUserSeeder; 9 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/postgres-2-knex/app.spec.ts: -------------------------------------------------------------------------------- 1 | import { app } from './app'; 2 | 3 | const { seedBanana } = require('./data.json'); // eslint-disable-line @typescript-eslint/no-var-requires 4 | 5 | describe('postgres-2-knex', () => { 6 | it('should get first entry', async () => { 7 | const { firstEntry } = await app(); 8 | 9 | expect(firstEntry).toEqual(expect.objectContaining(seedBanana)); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/postgres-2-knex/app.ts: -------------------------------------------------------------------------------- 1 | import { knex } from './models'; 2 | 3 | const { seedBanana } = require('./data.json'); // eslint-disable-line @typescript-eslint/no-var-requires 4 | 5 | const getFirstEntry = async () => knex('bananas').where({ size: seedBanana.size }).first(); 6 | 7 | export const app = async () => { 8 | const firstEntry = await getFirstEntry(); 9 | 10 | return { 11 | firstEntry, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/postgres-2-knex/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "seedBanana": { 3 | "size": "medium", 4 | "maturity": "quite bueno" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/postgres-2-knex/migrations/20190101001337-create-banana.js: -------------------------------------------------------------------------------- 1 | const up = (knex) => 2 | knex.schema.createTable('bananas', (table) => { 3 | table.increments('id').primary(); 4 | table.string('size'); 5 | table.string('maturity'); 6 | }); 7 | 8 | const down = (knex) => knex.schema.dropTableIfExists('bananas'); 9 | 10 | module.exports = { 11 | up, 12 | down, 13 | }; 14 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/postgres-2-knex/models/index.ts: -------------------------------------------------------------------------------- 1 | import Knex from 'knex'; 2 | 3 | const knexConfig = require('../../knexfile.js'); // eslint-disable-line @typescript-eslint/no-var-requires 4 | 5 | export const knex = Knex({ 6 | client: knexConfig.client, 7 | connection: { 8 | host: knexConfig.connection.host, 9 | user: knexConfig.connection.user, 10 | password: knexConfig.connection.password, 11 | database: knexConfig.connection.database, 12 | port: knexConfig.connection.port, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/postgres-2-knex/seeders/20180101001337-kill-bananas.js: -------------------------------------------------------------------------------- 1 | const seed = (knex) => knex('bananas').del(); 2 | 3 | module.exports = { 4 | seed, 5 | }; 6 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/postgres-2-knex/seeders/20190101001337-demo-banana.js: -------------------------------------------------------------------------------- 1 | const { seedBanana } = require('../data.json'); // eslint-disable-line @typescript-eslint/no-var-requires 2 | 3 | const seed = (knex) => 4 | knex('bananas').insert([ 5 | { 6 | id: 1, 7 | ...seedBanana, 8 | }, 9 | ]); 10 | 11 | module.exports = { 12 | seed, 13 | }; 14 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/redis-1-ioredis/__snapshots__/app.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`redis-1-ioredis should retrieve seeded value 1`] = `"lie"`; 4 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/redis-1-ioredis/app.spec.ts: -------------------------------------------------------------------------------- 1 | import { app } from './app'; 2 | 3 | const { seedCake } = require('./data.json'); // eslint-disable-line @typescript-eslint/no-var-requires 4 | 5 | describe('redis-1-ioredis', () => { 6 | it('should retrieve seeded value', async () => { 7 | const { redis } = app(); 8 | 9 | const value = await redis.get(seedCake.key); 10 | expect(value).toMatchSnapshot(); 11 | }); 12 | 13 | it('should handle flushall', async () => { 14 | const { redis } = app(); 15 | 16 | await redis.flushall(); 17 | 18 | const flushedValue = await redis.get(seedCake.key); 19 | expect(flushedValue).toEqual(null); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/redis-1-ioredis/app.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | 3 | const { seedCake } = require('./data.json'); // eslint-disable-line @typescript-eslint/no-var-requires 4 | 5 | const redis = new Redis({ 6 | host: 'localhost', 7 | port: 6381, 8 | }); 9 | 10 | export const app = () => { 11 | redis.set(seedCake.key, seedCake.value); 12 | 13 | return { 14 | redis, 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/redis-1-ioredis/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "seedCake": { 3 | "key": "thecakeistein", 4 | "value": "lie" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/examples/multiple-resources/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "lib": ["es2019"], 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "noImplicitAny": true, 8 | "strict": true, 9 | "target": "es2019" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/examples/node-to-node/README.md: -------------------------------------------------------------------------------- 1 | # Node to Node 2 | 3 | Showcases how to run Dockest with two Node services 4 | 5 | # Exposed ports 6 | 7 | - Node (users) 8 | - 1337 9 | - Node (orders) 10 | - 1338 11 | -------------------------------------------------------------------------------- /packages/examples/node-to-node/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | node_to_node_orders: 5 | ports: 6 | - published: 1338 7 | target: 1338 8 | build: 9 | context: ./orders 10 | networks: 11 | bueno: null 12 | 13 | node_to_node_users: 14 | ports: 15 | - published: 1337 16 | target: 1337 17 | build: 18 | context: ./users 19 | networks: 20 | bueno: null 21 | 22 | networks: 23 | bueno: {} 24 | -------------------------------------------------------------------------------- /packages/examples/node-to-node/dockest.ts: -------------------------------------------------------------------------------- 1 | import { Dockest, logLevel, sleepWithLog } from 'dockest'; 2 | 3 | const { run } = new Dockest({ 4 | composeFile: 'docker-compose.yml', 5 | dumpErrors: true, 6 | jestLib: require('jest'), 7 | logLevel: logLevel.DEBUG, 8 | }); 9 | 10 | run([ 11 | { 12 | serviceName: 'node_to_node_orders', 13 | commands: ['echo "Hello from orders (dependency - should run first) 👋🏽"'], 14 | dependsOn: [ 15 | { 16 | serviceName: 'node_to_node_users', 17 | commands: ['echo "Hello from users (dependent - should run right after orders) 👋🏽"'], 18 | readinessCheck: () => sleepWithLog(2, 'Sleepidy sleep'), 19 | }, 20 | ], 21 | }, 22 | ]); 23 | -------------------------------------------------------------------------------- /packages/examples/node-to-node/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { getUserNameById, getOrdersByUserId, notFound } from './index'; 2 | 3 | describe('index.spec.js', () => { 4 | describe('getUserNameById', () => { 5 | describe('happy', () => { 6 | it(`get user by id`, async () => { 7 | const userResponse = await getUserNameById('1'); 8 | 9 | expect(userResponse.status).toEqual(200); 10 | expect(userResponse.data).toEqual({ id: '1', name: 'John Doe' }); 11 | }); 12 | }); 13 | 14 | describe('sad', () => { 15 | it(`should throw 404 when querying user that doesn't exist`, async () => { 16 | try { 17 | await getUserNameById('4'); 18 | throw new Error(`Guarantees failure`); 19 | } catch (error) { 20 | expect((error as any).response.status).toEqual(404); 21 | expect((error as any).response.data).toEqual('Could not find user'); 22 | } 23 | }); 24 | }); 25 | }); 26 | 27 | describe('getOrdersByUserId', () => { 28 | describe('happy', () => { 29 | it(`get user's orders`, async () => { 30 | const ordersResponse = await getOrdersByUserId('1'); 31 | 32 | expect(ordersResponse.status).toEqual(200); 33 | expect(ordersResponse.data).toEqual({ orders: [{ id: '1', name: 'An awesome product', userId: '1' }] }); 34 | }); 35 | }); 36 | }); 37 | 38 | describe('misc', () => { 39 | it(`should throw 404 when querying undeclared endpoint`, async () => { 40 | try { 41 | await notFound(); 42 | throw new Error(`Guarantees failure`); 43 | } catch (error) { 44 | expect((error as any).response.status).toEqual(404); 45 | } 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /packages/examples/node-to-node/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const USER_SERVICE_BASE_URL = `http://localhost:1337`; 4 | 5 | export const getUserNameById = async (userId: string) => { 6 | const userResponse = await axios({ 7 | baseURL: USER_SERVICE_BASE_URL, 8 | url: `/users/${userId}`, 9 | }); 10 | 11 | return userResponse; 12 | }; 13 | 14 | export const getOrdersByUserId = async (userId: string) => { 15 | const ordersResponse = await axios({ 16 | baseURL: USER_SERVICE_BASE_URL, 17 | url: `/orders/${userId}`, 18 | }); 19 | 20 | return ordersResponse; 21 | }; 22 | 23 | export const notFound = async () => { 24 | const notFoundResponse = await axios({ 25 | baseURL: USER_SERVICE_BASE_URL, 26 | url: `/not-found`, 27 | }); 28 | 29 | return notFoundResponse; 30 | }; 31 | -------------------------------------------------------------------------------- /packages/examples/node-to-node/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testPathIgnorePatterns: ['/node_modules/'], 5 | }; 6 | -------------------------------------------------------------------------------- /packages/examples/node-to-node/orders/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /packages/examples/node-to-node/orders/Dockerfile: -------------------------------------------------------------------------------- 1 | # https://nodejs.org/de/docs/guides/nodejs-docker-webapp/ 2 | FROM node:18-alpine 3 | 4 | # Create app directory 5 | WORKDIR /usr/src/app 6 | 7 | # Install app dependencies 8 | COPY package.json . 9 | 10 | RUN npm install 11 | 12 | COPY . . 13 | 14 | CMD [ "node", "./index" ] 15 | -------------------------------------------------------------------------------- /packages/examples/node-to-node/orders/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 3 | const express = require('express'); 4 | 5 | const PORT = 1338; 6 | const SERVICE_NAME = 'node_to_node_orders'; 7 | 8 | const ORDERS = [{ userId: '1', id: '1', name: 'An awesome product' }]; 9 | 10 | const app = express(); 11 | 12 | app.get('/orders/:userId', (req, res) => { 13 | const userId = req.params.userId; 14 | const orders = ORDERS.filter((order) => order.userId === userId); 15 | 16 | if (orders.length === 0) { 17 | return res.status(404).send(`Could not find orders`); 18 | } 19 | 20 | res.status(200).json({ 21 | orders, 22 | }); 23 | }); 24 | 25 | const server = app.listen(PORT, () => { 26 | console.log(`${SERVICE_NAME} listening on port ${PORT}`); // eslint-disable-line no-console 27 | }); 28 | 29 | process.on('SIGTERM', () => { 30 | server.close((err) => { 31 | console.log('Shutting down.'); // eslint-disable-line no-console 32 | if (err) { 33 | console.error(err); // eslint-disable-line no-console 34 | process.exitCode = 1; 35 | } 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /packages/examples/node-to-node/orders/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/node-to-node--orders", 3 | "version": "3.1.0", 4 | "private": true, 5 | "engines": { 6 | "node": ">=18.0.0" 7 | }, 8 | "scripts": { 9 | "start": "node ./index", 10 | "docker:build": "docker build --no-cache -t orders .", 11 | "docker:run": "docker container run --rm -p 1338:1338 -t orders" 12 | }, 13 | "dependencies": { 14 | "express": "^4.17.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/examples/node-to-node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/node-to-node", 3 | "version": "3.1.0", 4 | "private": true, 5 | "engines": { 6 | "node": ">=18.0.0" 7 | }, 8 | "scripts": { 9 | "dev:examples:link": "yarn link dockest", 10 | "dev:examples:unlink": "yarn unlink dockest", 11 | "test:examples": "node_modules/.bin/ts-node ./dockest" 12 | }, 13 | "dependencies": { 14 | "axios": "^0.21.1" 15 | }, 16 | "devDependencies": { 17 | "@types/jest": "^24.9.1", 18 | "dockest": "file:../../dockest", 19 | "jest": "^25.1.0", 20 | "ts-jest": "^25.0.0", 21 | "ts-node": "^8.6.2", 22 | "typescript": "^3.7.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/examples/node-to-node/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "lib": ["es2019"], 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "noImplicitAny": true, 8 | "strict": true, 9 | "target": "es2019" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/examples/node-to-node/users/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /packages/examples/node-to-node/users/Dockerfile: -------------------------------------------------------------------------------- 1 | # https://nodejs.org/de/docs/guides/nodejs-docker-webapp/ 2 | FROM node:18-alpine 3 | 4 | # Create app directory 5 | WORKDIR /usr/src/app 6 | 7 | # Install app dependencies 8 | COPY package.json . 9 | 10 | RUN npm install 11 | 12 | COPY . . 13 | 14 | CMD [ "node", "./index" ] 15 | -------------------------------------------------------------------------------- /packages/examples/node-to-node/users/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 3 | const express = require('express'); 4 | const axios = require('axios'); 5 | 6 | const PORT = 1337; 7 | const SERVICE_NAME = 'node_to_node_users'; 8 | 9 | const USERS = [ 10 | { 11 | id: '1', 12 | name: 'John Doe', 13 | }, 14 | ]; 15 | 16 | const app = express(); 17 | 18 | app.get('/users/:userId', (req, res) => { 19 | const user = USERS.find((user) => user.id === req.params.userId); 20 | 21 | if (!user) { 22 | return res.status(404).send('Could not find user'); 23 | } 24 | 25 | res.status(200).json(user); 26 | }); 27 | 28 | app.get('/orders/:userId', async (req, res) => { 29 | const userId = req.params.userId; 30 | const user = USERS.find((user) => user.id === userId); 31 | 32 | if (!user) { 33 | return res.status(404).send('Could not find user'); 34 | } 35 | 36 | let response; 37 | try { 38 | response = await axios({ 39 | baseURL: `http://node_to_node_orders:1338/`, 40 | url: `/orders/${userId}`, 41 | }); 42 | } catch (error) { 43 | return res.status(error.response.status).send(error.response.data); 44 | } 45 | 46 | return res.status(200).json(response.data); 47 | }); 48 | 49 | const server = app.listen(PORT, () => { 50 | console.log(`${SERVICE_NAME} listening on port ${PORT}`); // eslint-disable-line no-console 51 | }); 52 | 53 | process.on('SIGTERM', () => { 54 | server.close((err) => { 55 | console.log('Shutting down.'); // eslint-disable-line no-console 56 | if (err) { 57 | console.error(err); // eslint-disable-line no-console 58 | process.exitCode = 1; 59 | } 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /packages/examples/node-to-node/users/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/node-to-node--users", 3 | "version": "3.1.0", 4 | "private": true, 5 | "engines": { 6 | "node": ">=18.0.0" 7 | }, 8 | "scripts": { 9 | "start": "node ./index", 10 | "docker:build": "docker build --no-cache -t users .", 11 | "docker:run": "docker container run --rm -p 1337:1337 -t users" 12 | }, 13 | "dependencies": { 14 | "axios": "^0.19.2", 15 | "express": "^4.17.1" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pipeline/updateNextVersion.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path') 2 | const { writeFileSync } = require('fs') 3 | const { execSync } = require('child_process') 4 | 5 | const pathToPackageJson = resolve(process.cwd(), 'packages/dockest/package.json') 6 | const packageJson = require(pathToPackageJson) 7 | 8 | const updateNextVersion = () => { 9 | console.log('> updateNextVersion') 10 | 11 | const currentVersion = packageJson.version 12 | const commitSha = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim() 13 | const commitShaShort = commitSha.substr(0, 7) 14 | const nextVersion = `${currentVersion}-${commitShaShort}` 15 | 16 | const dockestMeta = { 17 | commitShaShort, 18 | commitSha, 19 | nextVersion, 20 | } 21 | 22 | packageJson.version = nextVersion 23 | packageJson.dockest = dockestMeta 24 | 25 | writeFileSync(pathToPackageJson, JSON.stringify(packageJson, null, 2)) 26 | 27 | console.log('>> updateNextVersion', dockestMeta) 28 | } 29 | 30 | updateNextVersion() 31 | -------------------------------------------------------------------------------- /resources/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikengervall/dockest/c97592d61744bb5c01da081e3ffb3db1cd6a86fd/resources/img/favicon.png -------------------------------------------------------------------------------- /resources/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikengervall/dockest/c97592d61744bb5c01da081e3ffb3db1cd6a86fd/resources/img/logo.png -------------------------------------------------------------------------------- /resources/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | dockest 15 | 16 | 17 | 18 | 19 | --------------------------------------------------------------------------------