├── .commitlintrc.yml ├── .dockerignore ├── .env.sample ├── .eslintignore ├── .eslintrc.yml ├── .github └── workflows │ ├── docker-github-packages.yml │ └── tests.yml ├── .gitignore ├── .prettierrc.yml ├── ENV.md ├── LICENSE ├── MAINTAINERS ├── Makefile ├── README.md ├── __mocks__ └── puppeteer.js ├── data ├── checks │ ├── .common.yml │ └── checks.yml ├── parameters.yml ├── schedules.yml └── suites │ └── suites.yml ├── docker-compose.yaml ├── docker └── Dockerfile ├── jest.config.js ├── jsconfig.json ├── package-lock.json ├── package.json └── src ├── __mocks__ ├── checks │ └── checks.yml ├── config.js ├── parameters.yml ├── schedules.yml └── suites │ └── suites.yml ├── __tests__ ├── config.test.js ├── utils.test.js └── validators.test.js ├── actions ├── common │ └── index.js └── context.js ├── api ├── Server.js ├── favicon-32x32.png └── views │ ├── Checks.js │ ├── Metrics.js │ ├── Reports.js │ └── Suites.js ├── browser ├── browser.js └── page.js ├── check ├── __mocks__ │ └── runner.js ├── __tests__ │ ├── parser.test.js │ └── runner.test.js ├── check.js ├── parser.js └── runner.js ├── cli.js ├── cli ├── __tests__ │ ├── check.test.js │ ├── cli-check.test.js │ ├── cli-suite.test.js │ ├── cli-worker.test.js │ ├── cli.test.js │ └── suite.test.js ├── check.js ├── cli-check.js ├── cli-queue.js ├── cli-schedule.js ├── cli-server.js ├── cli-suite.js ├── cli-worker.js ├── cli.js └── suite.js ├── config.js ├── config ├── __tests__ │ ├── configuration.test.js │ └── env.test.js ├── configuration.js └── env.js ├── logger.js ├── metrics └── metrics.js ├── parameters └── ParamParser.js ├── queue ├── BaseQueue.js ├── RedisQueue.js ├── RedisQueueWorker.js ├── SimpleQueue.js ├── __mocks__ │ └── RedisQueueWorker.js └── __tests__ │ ├── BaseQueue.test.js │ ├── RedisQueue.test.js │ ├── RedisQueueWorker.test.js │ └── SimpleQueue.test.js ├── report ├── CheckReportCustomData.js ├── __tests__ │ ├── check.test.js │ └── suite.test.js ├── action.js ├── check.js ├── pathGenerator.js ├── paths.js ├── suite.js └── urlReplacer.js ├── schedule ├── __tests__ │ └── parser.test.js ├── parser.js └── runner.js ├── suite ├── __tests__ │ ├── parser.test.js │ └── runner.test.js ├── parser.js └── runner.js ├── utils.js └── validators.js /.commitlintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - '@commitlint/config-conventional' 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !__mocks__ 3 | !data 4 | !docker 5 | !src 6 | !.eslintignore 7 | !.eslintrc.yml 8 | !jest.config.js 9 | !jsconfig.json 10 | !package.json 11 | !package-lock.json 12 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | PURR_PARAM_USER_EMAIL=example@example.com 2 | PURR_PARAM_USER_PASSWORD=secret 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Eslint doesn't work well with symlinks 2 | /src/cli.js 3 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - airbnb-base 3 | - plugin:prettier/recommended 4 | 5 | overrides: 6 | - files: 7 | - '*.test.js' 8 | rules: 9 | global-require: 0 10 | no-new: 0 11 | - files: 12 | - '**/__mocks__/*.js' 13 | rules: 14 | class-methods-use-this: 0 15 | no-empty-function: 0 16 | 17 | env: 18 | browser: true 19 | node: true 20 | jest: true 21 | -------------------------------------------------------------------------------- /.github/workflows/docker-github-packages.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish a Docker image 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | tags: 8 | - 'v*' 9 | 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | jobs: 15 | build-and-push-image: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | packages: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v3 24 | 25 | - name: Set up QEMU 26 | uses: docker/setup-qemu-action@v2 27 | 28 | - name: Set up Docker Buildx 29 | id: buildx 30 | uses: docker/setup-buildx-action@v2 31 | 32 | - name: Log in to the Container registry 33 | uses: docker/login-action@v2 34 | with: 35 | registry: ${{ env.REGISTRY }} 36 | username: ${{ github.actor }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: Extract metadata (tags, labels) for Docker 40 | id: meta 41 | uses: docker/metadata-action@v4 42 | with: 43 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 44 | 45 | - name: Build and push Docker image 46 | uses: docker/build-push-action@v4 47 | with: 48 | context: . 49 | file: ./docker/Dockerfile 50 | push: true 51 | tags: ${{ steps.meta.outputs.tags }} 52 | labels: ${{ steps.meta.outputs.labels }} 53 | platforms: linux/amd64,linux/aarch64 54 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | tests: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | 15 | - name: Setup node 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 21.7 19 | 20 | - name: npm install 21 | run: npm install 22 | 23 | - name: test 24 | run: npm run test 25 | 26 | - name: test check should succeed 27 | run: | 28 | set +e 29 | ./src/cli.js check example-com > check_report 30 | code=$? 31 | set -e 32 | 33 | cat check_report 34 | 35 | [ $code -eq 1 ] && echo 'Error: Check should succeed' && exit 1 36 | 37 | grep 'success: true' check_report 38 | 39 | - name: test check should fail 40 | run: | 41 | set +e 42 | ./src/cli.js check example-com-fail > check_report 43 | code=$? 44 | set -e 45 | 46 | cat check_report 47 | 48 | [ $code -eq 0 ] && echo 'Error: Check should fail' && exit 1 49 | 50 | grep 'success: false' check_report 51 | grep "Element 'body' does not contain 'No Way'" check_report 52 | 53 | - name: test check lighthouse 54 | run: | 55 | set +e 56 | PURR_CONFIG_CONCURRENCY=1 \ 57 | PURR_CONFIG_TRACES=false \ 58 | PURR_CONFIG_CHROMIUM_REMOTE_DEBUGGING=true \ 59 | PURR_CONFIG_BROWSER_HEADLESS=false \ 60 | ./src/cli.js check --no-shorten \ 61 | example-com-lighthouse > check_report 62 | code=$? 63 | set -e 64 | cat check_report 65 | [ $code -eq 1 ] && echo 'Error: Check should succeed' && exit 1 66 | grep 'success: true' check_report 67 | grep "labels: { name: 'first-contentful-paint', id: 'lh-one' }" \ 68 | check_report 69 | 70 | - name: 'lint configs' 71 | run: npm run prettier 72 | 73 | - name: lint 74 | run: npm run lint 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /node_modules/ 3 | /coverage/ 4 | /storage/ 5 | /storage_tmp/ 6 | .env 7 | *.tgz 8 | docker-compose.override.yaml 9 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | trailingComma: es5 3 | arrowParens: always 4 | endOfLine: lf 5 | overrides: 6 | - files: 7 | - '**/*.yml' 8 | - '**/*.yaml' 9 | options: 10 | # Workaround to fix nunjucks templating 11 | bracketSpacing: false 12 | -------------------------------------------------------------------------------- /ENV.md: -------------------------------------------------------------------------------- 1 | # PURR Configuration 2 | 3 | ## Configuration using environmental variables 4 | 5 | Application can be configured using ENV variables. Variables can be passed either by giving 6 | to the `docker-compose` command 7 | or by adding to `.env` file (see [.env.sample](./.env.sample) for details). 8 | 9 | User should tell `PURR_PARAM_*` variables from `PURR_CONFIG_*`. The first helps to configure tests behaviour, 10 | while the second configures application itself. 11 | 12 | List of available parameters, their default values and description are available bellow: 13 | 14 | | Environment | Description | Default value | 15 | |:---------------------------------------|---------------------------------------------------------------------------------------------------------------------:|:------------------------| 16 | | PURR_CONFIG_DATA_DIR | Location of checks, suites, schedules and parameters for tests. | `./data` | 17 | | PURR_CONFIG_ARTIFACTS_DIR | Location, where to store results of runs | `./storage` | 18 | | PURR_CONFIG_ARTIFACTS_TEMP_DIR | Location of temporary storage for results | `./storage_tmp` | 19 | | PURR_CONFIG_CONCURRENCY | Amount of concurrent processes when performing checks | `4` | 20 | | PURR_CONFIG_PARAMETERS_INFO_FILE_PATH | Name of file with parameters, absolute | `./data/parameters.yml` | 21 | | PURR_CONFIG_SCHEDULES_FILE_PATH | Name of file with scheduled checks | `./data/schedules.yml` | 22 | | PURR_CONFIG_ARTIFACTS_KEEP_SUCCESSFUL | Whether to keep artifacts for successful checks | `true` | 23 | | PURR_CONFIG_REPORTS | Whether to generate test runs reports | `true` | 24 | | PURR_CONFIG_REPORTS_DIR | Where to store tests reports. | `./storage/reports` | 25 | | PURR_CONFIG_LATEST_FAILED_REPORTS | Whether to generate test runs reports for failed checks | `true` | 26 | | PURR_CONFIG_SCREENSHOTS | Whether to save screenshots for tests runs | `true` | 27 | | PURR_CONFIG_SCREENSHOTS_DIR | Where to save screenshots | `./storage/screenshots` | 28 | | PURR_CONFIG_TRACES | Whether to store run traces | `true` | 29 | | PURR_CONFIG_TRACES_DIR | Where to store traces | `./storage/traces` | 30 | | PURR_CONFIG_HARS | Whether to store HAR files for checks | `false` | 31 | | PURR_CONFIG_HARS_DIR | Where to store HAR files | `./storage/hars` | 32 | | PURR_CONFIG_CONSOLE_LOG | Whether to store console logs from checks | `true` | 33 | | PURR_CONFIG_CONSOLE_LOG_DIR | Where to store console log files | `./storage/console_log` | 34 | | PURR_CONFIG_ENV_VAR_PARAM_PREFIX | Prefix for PARAMS when configuring them from env variables. | `PURR_PARAM_` | 35 | | PURR_CONFIG_WINDOW_WIDTH | Default width of window for tests | `1920` | 36 | | PURR_CONFIG_WINDOW_HEIGHT | Default height of window for tests | `1080` | 37 | | PURR_CONFIG_NAVIGATION_TIMEOUT | Default timeout for navigation in milliseconds | `30000` | 38 | | PURR_CONFIG_USER_AGENT | Default user agent for requests from Puppeteer | `uptime-agent` | 39 | | PURR_CONFIG_LOG_LEVEL | | `info` | 40 | | PURR_CONFIG_BLOCKED_RESOURCE_DOMAINS | List of domains to block in Puppeteer. One should use comma-separated string. For example: `google.com,facebook.com` | | 41 | | PURR_CONFIG_COOKIE_TRACKING | Whether to add cookies from checks to action reports. | `false` | 42 | | PURR_CONFIG_COOKIE_TRACKING_HIDE_VALUE | Whether to hide cookies values from checks to action reports. Available only if COOKIE_TRACKING is true. | `true` | 43 | 44 | For details please take a look at [src/config/env.js](./src/config/env.js) and [src/config.js](./src/config.js). 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © (2019), SEMRush CY Ltd. (written by Vyacheslav Artemiev, Ruslan Burkhanov, Alexei Kochetov, Daniil Mikhailov, Konstantin Sergievskiy and Aleksey Shirokih) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the 4 | Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the 5 | Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 10 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 11 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | 13 | See the MIT License for more details. -------------------------------------------------------------------------------- /MAINTAINERS: -------------------------------------------------------------------------------- 1 | Lev Galaktionov 2 | Nikita Popov 3 | Pavel Ageev 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | override APPLICATION_NAME=purr 2 | override NODE_VERSION=21.7 3 | 4 | DOCKER_IMAGE?=ghcr.io/semrush/purr 5 | DOCKER_TAG?=latest 6 | CHECK_NAME:=example-com 7 | SUITE_NAME:=example-com-suite 8 | 9 | .PHONY: npm-install 10 | npm-install: 11 | rm -r ${CURDIR}/node_modules || true 12 | docker run --rm \ 13 | -v ${CURDIR}:/app \ 14 | -w /app \ 15 | node:${NODE_VERSION} \ 16 | npm ci 17 | 18 | .PHONY: npm-patch-version 19 | npm-patch-version: 20 | rm -r ${CURDIR}/node_modules || true 21 | docker run --rm \ 22 | -v ${CURDIR}:/app \ 23 | -w /app \ 24 | node:${NODE_VERSION} \ 25 | npm version patch --no-git-tag-version 26 | 27 | .PHONY: vendor 28 | vendor: npm-install 29 | 30 | .PHONY: npm-lint 31 | npm-lint: 32 | docker run --rm \ 33 | -v ${CURDIR}:/app \ 34 | -w /app \ 35 | node:${NODE_VERSION} \ 36 | npm run lint 37 | 38 | .PHONY: lint 39 | lint: npm-lint 40 | 41 | .PHONY: npm-test 42 | npm-test: 43 | docker run --rm \ 44 | -v ${CURDIR}:/app \ 45 | -w /app \ 46 | node:${NODE_VERSION} \ 47 | npm run test 48 | 49 | .PHONY: test 50 | test: npm-test 51 | 52 | .PHONY: docker-build 53 | docker-build: 54 | docker rmi --force ${DOCKER_IMAGE}:${DOCKER_TAG} || true 55 | docker build -f ${CURDIR}/docker/Dockerfile -t ${DOCKER_IMAGE}:${DOCKER_TAG} --progress plain --no-cache . 56 | 57 | .PHONY: build 58 | build: docker-build 59 | 60 | .PHONY: docker-compose-up 61 | docker-compose-up: 62 | DOCKER_IMAGE=${DOCKER_IMAGE} DOCKER_TAG=${DOCKER_TAG} docker compose -p ${APPLICATION_NAME} up -d 63 | 64 | .PHONY: docker-compose-down 65 | docker-compose-down: 66 | docker compose -p ${APPLICATION_NAME} down --remove-orphans --volumes --rmi local 67 | 68 | .PHONY: run-check 69 | run-check: docker-build 70 | rm -r ${CURDIR}/storage/* || true 71 | docker run --rm \ 72 | --env-file ${CURDIR}/.env \ 73 | ${DOCKER_IMAGE}:${DOCKER_TAG} \ 74 | check $(CHECK_NAME) 75 | 76 | .PHONY: run-suite 77 | run-suite: docker-build 78 | rm -r ${CURDIR}/storage/* || true 79 | docker run --rm \ 80 | -v ${CURDIR}:/app \ 81 | --env-file ${CURDIR}/.env \ 82 | ${DOCKER_IMAGE}:${DOCKER_TAG} \ 83 | suite $(SUITE_NAME) 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PURR 2 | 3 | 4 | 5 | 6 | - [PURR](#purr) 7 | - [Intro](#intro) 8 | - [Configuration](#configuration) 9 | - [CLI](#cli) 10 | - [Scheduled jobs](#scheduled-jobs) 11 | - [REST API](#rest-api) 12 | - [Writing checks](#writing-checks) 13 | - [Development](#development) 14 | 15 | 16 | 17 | ## Intro 18 | 19 | PURR (PUppeteer RunneR) is a devops-friendly tool for browser testing and monitoring. 20 | 21 | The goal of this project is to have single set of browser checks, that could be used as tests, canaries in CI/CD pipelines and scenarios for production monitoring. 22 | 23 | The tool uses puppeteer () to run standalone browsers (Chrome and Firefox are supported currently). 24 | 25 | Checks results are stored as JSON reports, screenshots, traces and HAR files. 26 | 27 | PURR has three modes: 28 | 29 | - [CLI](README.md#cli) (mainly used in CI/CD pipelines) 30 | - [Queue worker](README.md#scheduled-jobs) (scheduled monitoring checks) 31 | - [REST service](README.md#rest-api) (show results and expose internal metrics for prometheus) 32 | 33 | ## Configuration 34 | 35 | ### data/checks dir 36 | 37 | Stores descriptions of every single check 38 | 39 | ### data/suites dir 40 | 41 | Organizes checks into suites 42 | 43 | ### data/parameters.yml 44 | 45 | Specifies check parameters, i.e. target host or cookie values 46 | 47 | ### data/schedules.yml 48 | 49 | Define your schedules here 50 | 51 | ### priority of parameters 52 | 53 | - Defaults from parameters.yml 54 | - Defaults from check/suite 55 | - Params from env 56 | - Explicitly specified params 57 | 58 | ### PURR configuration 59 | 60 | You can configure PURR behaviour using environmental variables. Please see the [ENV.md](./ENV.md) for details. 61 | 62 | ## CLI 63 | 64 | ### Requirements 65 | 66 | - docker 67 | - [docker compose](https://docs.docker.com/compose/install) 68 | - make 69 | 70 | Before first run of whole application or custom checks, you need to provide `.env` file. Sample you can find in 71 | `.env.sample` file and full list of supported ENV variables in [ENV.md](./ENV.md). 72 | 73 | ### Build 74 | 75 | Native docker build 76 | ```shell 77 | docker build -f $(pwd)/docker/Dockerfile -t ghcr.io/semrush/purr:latest . 78 | ``` 79 | 80 | Or use predefined make directive 81 | ```shell 82 | make docker-build 83 | ``` 84 | 85 | ### Run single check 86 | 87 | Native docker run 88 | ```shell 89 | docker build -f $(pwd)/docker/Dockerfile -t ghcr.io/semrush/purr:latest . 90 | docker run --rm -v $(pwd):/app --env-file $(pwd)/.env ghcr.io/semrush/purr:latest check example-com 91 | ``` 92 | 93 | Or use predefined make directive 94 | ```shell 95 | make run-check CHECK_NAME=example-com 96 | ``` 97 | 98 | ### Run suite 99 | 100 | Native docker run 101 | ```shell 102 | docker build -f $(pwd)/docker/Dockerfile -t ghcr.io/semrush/purr:latest . 103 | docker run --rm -v $(pwd):/app --env-file $(pwd)/.env ghcr.io/semrush/purr:latest suite example-com-suite 104 | ``` 105 | 106 | Or use predefined make directive 107 | ```shell 108 | make run-suite SUITE_NAME=example-com-suite 109 | ``` 110 | 111 | ### Results 112 | 113 | ```shell 114 | $ tree storage 115 | storage 116 | ├── console_log 117 | │   ├── console_semrush-com_0cedaca3-1153-47df-a616-55e21bf54635.log 118 | │   └── console_semrush-com_ded5990f-7638-48e6-9d0e-77f8dba376fd.log 119 | ├── screenshots 120 | │   ├── screenshot_semrush-com_0cedaca3-1153-47df-a616-55e21bf54635.png 121 | │   └── screenshot_semrush-com_ded5990f-7638-48e6-9d0e-77f8dba376fd.png 122 | └── traces 123 | ├── trace_semrush-com_0cedaca3-1153-47df-a616-55e21bf54635.json 124 | └── trace_semrush-com_ded5990f-7638-48e6-9d0e-77f8dba376fd.json 125 | 126 | ``` 127 | 128 | ### Traces and HARs 129 | 130 | PURR have a feature to save Chromium traces and [HARs](). 131 | 132 | You can open traces in Chromium Devtools Network Inspector or [Chrome DevTools Timeline Viewer](https://chromedevtools.github.io/timeline-viewer/). 133 | For HAR you can use [GSuite Toolbox HAR Analyze](https://toolbox.googleapps.com/apps/har_analyzer/). 134 | 135 | ## Scheduled jobs 136 | 137 | ### Run application 138 | 139 | ```shell 140 | docker compose up -d 141 | ``` 142 | 143 | ### Apply schedules 144 | 145 | ```shell 146 | docker compose exec worker /app/src/cli.js schedule clean 147 | docker compose exec worker /app/src/cli.js schedule apply 148 | ``` 149 | 150 | ### Stop schedules 151 | 152 | ```shell 153 | docker compose exec worker /app/src/cli.js schedule clean 154 | ``` 155 | 156 | ## REST API 157 | 158 | To enable access to API server, just create file `docker-compose.override.yaml` and place replacement of `server` 159 | service like in example: 160 | 161 | ```yaml 162 | version: '3.9' 163 | 164 | services: 165 | server: 166 | ports: 167 | - '8080:8080' 168 | ``` 169 | 170 | After that, all commands called via `docker compose` will apply configuration and provide access to server with address 171 | `http://localhost:8080` 172 | 173 | ### Endpoints 174 | 175 | #### `GET /metrics` 176 | 177 | Prometheus metrics 178 | 179 | #### `GET /api/v1/checks` 180 | 181 | List of existing checks 182 | 183 | ##### query strings 184 | 185 | #### `POST /api/v1/checks/:name` 186 | 187 | Add check to queue 188 | 189 | ##### Response 190 | 191 | **200**: Returns check report 192 | **202**: Returns id of created check job 193 | 194 | ##### Payload 195 | 196 | - **name**: string 197 | Check name to run 198 | - **params**: array 199 | Any check parameter 200 | 201 | ##### Query strings 202 | 203 | - **wait**: bool 204 | **default**: false 205 | Just return link for report when false 206 | - **view**: string 207 | **default**: json 208 | **options**: json, pretty 209 | Output format 210 | 211 | ##### Example: 212 | 213 | ```shell 214 | curl -X POST \ 215 | -d 'params[TARGET_SCHEMA]=http' \ 216 | -d 'params[TARGET_DOMAIN]=rc.example.com' \ 217 | http://localhost:8080/api/v1/checks/main-page?wait=true&view=pretty 218 | ``` 219 | 220 | #### `GET /api/v1/reports/:id` 221 | 222 | Get report 223 | 224 | ##### Payload 225 | 226 | - **id**: string 227 | Check report id 228 | 229 | ##### Query strings 230 | 231 | - **view**: string 232 | **default**: json 233 | **options**: json, pretty 234 | Output format 235 | 236 | #### `GET /api/v1/reports/:name/latest/failed` 237 | 238 | Get report 239 | 240 | ##### Payload 241 | 242 | - **name**: string 243 | Check report name 244 | 245 | ##### Query strings 246 | 247 | - **schedule**: string 248 | **default**: '' 249 | Schedule name 250 | 251 | - **view**: string 252 | **default**: json 253 | **options**: json, pretty 254 | Output format 255 | 256 | ## Writing checks 257 | 258 | PURR translates scenario steps described in ./data/checks into methods of puppeteer.Page object. 259 | You can check [puppeteer reference documentation](https://github.com/GoogleChrome/puppeteer/blob/v1.19.0/docs/api.md#class-page) for up-to-date capabilities. 260 | 261 | ### Methods 262 | 263 | List of methods which were tested by the PURR dev team 264 | 265 | ```yaml 266 | - goto: 267 | - '{{ TARGET_SCHEMA }}://{{ TARGET_DOMAIN }}/{{ TARGET_PAGE }}/' 268 | 269 | - goto: 270 | - '{{ TARGET_SCHEMA }}://{{ TARGET_DOMAIN }}/{{ TARGET_PAGE }}/' 271 | - waitUntil: networkidle2 272 | 273 | - waitForNavigation: 274 | - waitUntil: domcontentloaded 275 | 276 | - click: 277 | - '{{ CSS_OR_DOM_SELECTOR }}' 278 | 279 | - type: 280 | - '{{ CSS_OR_DOM_SELECTOR }}' 281 | - '{{ STRING_TO_TYPE }}' 282 | 283 | - waitForSelector: 284 | - '{{ CSS_OR_DOM_SELECTOR }}' 285 | 286 | - setCookie: 287 | - name: '{{ COOKIE_NAME }}' 288 | value: '{{ COOKIE_VALUE }}' 289 | domain: .{{ TARGET_DOMAIN.split('.').slice(-2).join('.') }} 290 | ``` 291 | 292 | ## Testing checks 293 | 294 | to launch your check run 295 | ``` 296 | make check name=main-page 297 | ``` 298 | 299 | ### Custom Methods 300 | 301 | Custom steps methods are described in [src/actions](./src/actions/common/index.js) dir and can be executed in checks. 302 | 303 | ```yaml 304 | - actions.common.selectorContains: 305 | - '[data-test="user-profile"]' 306 | - 'User Name:' 307 | ``` 308 | 309 | ### Includes 310 | 311 | Feel free to use YAML anchors in your scenarios 312 | 313 | ```yaml 314 | .login_via_popup: &login_via_popup 315 | - click: 316 | - '[data-test="login"]' 317 | - waitForSelector: 318 | - '[data-test="email"]' 319 | - type: 320 | - '[data-test="email"]' 321 | - '{{ USER_EMAIL }}' 322 | - type: 323 | - '[data-test="password"]' 324 | - '{{ USER_PASSWORD }}' 325 | - click: 326 | - '[data-test="login-submit"]' 327 | 328 | 329 | logged-user-dashboard: 330 | parameters: 331 | USER_PASSWORD: secret 332 | steps: 333 | - goto: 334 | - '{{ TARGET_URL }}' 335 | - waitUntil: networkidle2 336 | <<: *login_via_popup 337 | parameters: 338 | USER_EMAIL: root@localhost 339 | - waitForSelector: 340 | - '[data-test="user-profile"]' 341 | - actions.common.selectorContains: 342 | - '[data-test="user-profile"]' 343 | - 'User Name:' 344 | ``` 345 | 346 | ### Variables 347 | 348 | You can specify parameters in checks and suites yaml files under 'parameters' key 349 | 350 | ```yaml 351 | parameters: 352 | TARGET_HOST: localhost 353 | 354 | valid-password: 355 | <<: *login_via_popup 356 | parameters: 357 | USER_EMAIL: root@localhost 358 | USER_PASSOWRD: secret 359 | 360 | invalid-password: 361 | <<: *login_via_popup 362 | parameters: 363 | USER_PASSOWRD: invalid 364 | ``` 365 | 366 | ### Proxy 367 | 368 | To run a check, suite or schedule throw proxy use 'proxy' key 369 | 370 | ```yaml 371 | check-page-from-india: 372 | proxy: 'socks5h://user:password@proxy.service:8080' 373 | steps: 374 | - goto: 375 | - '{{ TARGET_URL }}' 376 | - waitForSelector: 377 | - body 378 | - actions.common.selectorContains: 379 | - body 380 | - 'Your location: India' 381 | ``` 382 | 383 | ## Development 384 | 385 | Main entrypoint for project is `src/cli.js`. 386 | 387 | There are two options for development avalaible. 388 | 389 | * cli command development require only call from cli. [docker-compose.single.yml](docker-compose.single.yml) placed for your convinience 390 | * client-server model. That mode described in [docker-compose.server.yml](docker-compose.server.yml). There we have two services avalaible 391 | * sever - provides api endpoint and other stuff related to daemon itself 392 | * worker - queue worker. 393 | 394 | 395 | ```shell 396 | make start-dev 397 | make attach-dev 398 | ``` 399 | 400 | ### Tests 401 | 402 | Run tests: 403 | 404 | ```shell 405 | yarn run test 406 | ``` 407 | 408 | #### Mocks 409 | 410 | We are using Jest testing framework. 411 | 412 | You can mock module like that: 413 | 414 | ```javascript 415 | // If `manual` mock exist in dir `__mocks__` along module file, will be used 416 | // automatically. 417 | // 418 | // Mocked module methods return `undefined`, fields return actual value. 419 | jest.mock('../../config'); 420 | ``` 421 | 422 | ```javascript 423 | // Now `config` for all scripts will be `{ concurrency: 9 }` 424 | jest.mock('../../config', () => ({ concurrency: 9 })); 425 | ``` 426 | 427 | Or like that: 428 | 429 | ```javascript 430 | const config = require('../../config'); 431 | 432 | config.concurrency = 1; 433 | config.getWorkingPath = jest.fn().mockImplementation(() => { 434 | return '/working/path'; 435 | }); 436 | ``` 437 | 438 | ##### Be careful 439 | 440 | Methods `mock`\\`unmock` must be executed before module imports and in the 441 | same scope. 442 | Mocks state restoring after each test, but only when you did not used 443 | `jest.mock()` 444 | -------------------------------------------------------------------------------- /__mocks__/puppeteer.js: -------------------------------------------------------------------------------- 1 | const puppeteer = jest.genMockFromModule('puppeteer'); 2 | 3 | class Page { 4 | // async click(selector, options = {}) { 5 | // console.log(`Clicked '${selector}' with options`, options); 6 | // return Promise.resolve( 7 | // `Clicked '${selector}' with options '${JSON.stringify(options)}'` 8 | // ); 9 | // } 10 | } 11 | 12 | class Browser { 13 | async newPage() { 14 | return new Page(); 15 | } 16 | 17 | async close() { 18 | // console.log('Browser closed'); 19 | } 20 | } 21 | 22 | puppeteer.launch.mockResolvedValue(new Browser()); 23 | 24 | module.exports = puppeteer; 25 | -------------------------------------------------------------------------------- /data/checks/.common.yml: -------------------------------------------------------------------------------- 1 | .set_cookie: &set_cookie 2 | - setCookie: 3 | - name: purr_test_cookie 4 | value: 'true' 5 | domain: .{{ TARGET_DOMAIN.split('.').slice(-2).join('.') }} 6 | -------------------------------------------------------------------------------- /data/checks/checks.yml: -------------------------------------------------------------------------------- 1 | semrush-com: 2 | steps: 3 | - goto: 4 | - '{{ TARGET_SCHEMA }}://{{ TARGET_DOMAIN }}/' 5 | - waitForSelector: 6 | - '[data-test]' 7 | 8 | example-com: 9 | steps: 10 | - *set_cookie 11 | - goto: 12 | - '{{ TARGET_SCHEMA }}://example.com/' 13 | - waitUntil: networkidle2 14 | - actions.common.logPerformanceMetrics: 15 | - 'perf-one' 16 | - waitForSelector: 17 | - 'body' 18 | - actions.common.selectorContains: 19 | - 'body' 20 | - 'Example Domain' 21 | 22 | example-com-fail: 23 | steps: 24 | - *set_cookie 25 | - goto: 26 | - '{{ TARGET_SCHEMA }}://example.com/' 27 | - waitUntil: networkidle2 28 | - actions.common.logPerformanceMetrics: 29 | - 'perf-one' 30 | - waitForSelector: 31 | - 'body' 32 | - actions.common.selectorContains: 33 | - 'body' 34 | - 'No Way' 35 | 36 | example-com-lighthouse: 37 | steps: 38 | - actions.common.runLighthouse: 39 | - 'lh-one' 40 | - '{{ TARGET_SCHEMA }}://example.com/' 41 | -------------------------------------------------------------------------------- /data/parameters.yml: -------------------------------------------------------------------------------- 1 | # Format: 2 | # PARAM_NAME: 3 | # desc: Some description 4 | # default: Default value 5 | 6 | TARGET_SCHEMA: 7 | desc: Target domain schema(http or https) 8 | default: https 9 | 10 | TARGET_DOMAIN: 11 | desc: Target domain 12 | default: semrush.com 13 | -------------------------------------------------------------------------------- /data/schedules.yml: -------------------------------------------------------------------------------- 1 | schedules: 2 | semrush-hourly: 3 | interval: 60m 4 | labels: 5 | team: semrush 6 | priority: p4 7 | parameters: 8 | TARGET_DOMAIN: de.semrush.com 9 | checks: 10 | - semrush-com 11 | -------------------------------------------------------------------------------- /data/suites/suites.yml: -------------------------------------------------------------------------------- 1 | semrush-suite: 2 | steps: 3 | - semrush-com 4 | 5 | example-com-suite: 6 | steps: 7 | - example-com 8 | - example-com-fail 9 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | server: 5 | image: ${DOCKER_IMAGE:-ghcr.io/semrush/purr}:${DOCKER_TAG:-latest} 6 | build: 7 | dockerfile: docker/Dockerfile 8 | context: . 9 | command: 10 | - server 11 | - start 12 | ports: 13 | - '8080:8080' 14 | env_file: 15 | - ./.env 16 | volumes: 17 | - ./:/app 18 | depends_on: 19 | - redis 20 | 21 | worker: 22 | image: ${DOCKER_IMAGE:-ghcr.io/semrush/purr}:${DOCKER_TAG:-latest} 23 | build: 24 | dockerfile: docker/Dockerfile 25 | context: . 26 | command: 27 | - worker 28 | - check 29 | env_file: 30 | - ./.env 31 | volumes: 32 | - ./:/app 33 | depends_on: 34 | - server 35 | 36 | apply: 37 | image: ${DOCKER_IMAGE:-ghcr.io/semrush/purr}:${DOCKER_TAG:-latest} 38 | build: 39 | dockerfile: docker/Dockerfile 40 | context: . 41 | command: 42 | - schedule 43 | - apply 44 | env_file: 45 | - ./.env 46 | volumes: 47 | - ./:/app 48 | depends_on: 49 | - worker 50 | 51 | redis: 52 | image: redis 53 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NODE_VERSION=21.7 2 | 3 | FROM node:${NODE_VERSION} AS vendor 4 | 5 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true 6 | 7 | WORKDIR /app 8 | 9 | COPY package.json /app 10 | COPY package-lock.json /app 11 | 12 | RUN npm ci --omit dev 13 | 14 | FROM node:${NODE_VERSION} 15 | 16 | ARG CHROMIUM_VERSION=131.0.* 17 | ENV CHROMIUM_VERSION=${CHROMIUM_VERSION} 18 | ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium 19 | 20 | RUN apt update && \ 21 | apt install -y --no-install-recommends \ 22 | chromium-common=${CHROMIUM_VERSION} \ 23 | chromium=${CHROMIUM_VERSION} \ 24 | chromium-sandbox=${CHROMIUM_VERSION} && \ 25 | apt clean && \ 26 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true 27 | 28 | WORKDIR /app 29 | 30 | COPY jsconfig.json /app 31 | COPY package.json /app 32 | COPY package-lock.json /app 33 | COPY src /app/src 34 | COPY data /app/data 35 | RUN chown -R node:node /app 36 | 37 | COPY --from=vendor --chown=node:node /app/node_modules /app/node_modules 38 | 39 | USER node 40 | 41 | ENTRYPOINT ["/app/src/cli.js"] 42 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: true, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/tmp/jest_rs", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | collectCoverage: true, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | collectCoverageFrom: ['src/**/*.{js,jsx}'], 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: 'coverage', 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | coverageThreshold: { 44 | global: { 45 | branches: 35, 46 | functions: 45, 47 | lines: 45, 48 | statements: 45, 49 | }, 50 | // TODO: get this: 51 | // global: { 52 | // branches: 98, 53 | // functions: 98, 54 | // lines: 98, 55 | // statements: 98, 56 | // }, 57 | }, 58 | 59 | // A path to a custom dependency extractor 60 | // dependencyExtractor: null, 61 | 62 | // Make calling deprecated APIs throw helpful error messages 63 | // errorOnDeprecated: false, 64 | 65 | // Force coverage collection from ignored files using an array of glob patterns 66 | // forceCoverageMatch: [], 67 | 68 | // A path to a module which exports an async function that is triggered once before all test suites 69 | // globalSetup: null, 70 | 71 | // A path to a module which exports an async function that is triggered once after all test suites 72 | // globalTeardown: null, 73 | 74 | // A set of global variables that need to be available in all test environments 75 | // globals: {}, 76 | 77 | // An array of directory names to be searched recursively up from the requiring module's location 78 | // moduleDirectories: [ 79 | // "node_modules" 80 | // ], 81 | 82 | // An array of file extensions your modules use 83 | // moduleFileExtensions: [ 84 | // "js", 85 | // "json", 86 | // "jsx", 87 | // "ts", 88 | // "tsx", 89 | // "node" 90 | // ], 91 | 92 | // A map from regular expressions to module names that allow to stub out resources with a single module 93 | // moduleNameMapper: {}, 94 | 95 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 96 | // modulePathIgnorePatterns: [], 97 | 98 | // Activates notifications for test results 99 | // notify: false, 100 | 101 | // An enum that specifies notification mode. Requires { notify: true } 102 | // notifyMode: "failure-change", 103 | 104 | // A preset that is used as a base for Jest's configuration 105 | // preset: null, 106 | 107 | // Run tests from one or more projects 108 | // projects: null, 109 | 110 | // Use this configuration option to add custom reporters to Jest 111 | // reporters: undefined, 112 | 113 | // Automatically reset mock state between every test 114 | resetMocks: true, 115 | 116 | // Reset the module registry before running each individual test 117 | resetModules: true, 118 | 119 | // A path to a custom resolver 120 | // resolver: null, 121 | 122 | // Automatically restore mock state between every test 123 | restoreMocks: true, 124 | 125 | // The root directory that Jest should scan for tests and modules within 126 | // rootDir: null, 127 | 128 | // A list of paths to directories that Jest should use to search for files in 129 | // roots: [ 130 | // "" 131 | // ], 132 | 133 | // Allows you to use a custom runner instead of Jest's default test runner 134 | // runner: "jest-runner", 135 | 136 | // The paths to modules that run some code to configure or set up the testing environment before each test 137 | // setupFiles: [], 138 | 139 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 140 | // setupFilesAfterEnv: [], 141 | 142 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 143 | // snapshotSerializers: [], 144 | 145 | // The test environment that will be used for testing 146 | testEnvironment: 'node', 147 | 148 | // Options that will be passed to the testEnvironment 149 | // testEnvironmentOptions: {}, 150 | 151 | // Adds a location field to test results 152 | // testLocationInResults: false, 153 | 154 | // The glob patterns Jest uses to detect test files 155 | // testMatch: [ 156 | // "**/__tests__/**/*.[jt]s?(x)", 157 | // "**/?(*.)+(spec|test).[tj]s?(x)" 158 | // ], 159 | 160 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 161 | // testPathIgnorePatterns: [ 162 | // "/node_modules/" 163 | // ], 164 | 165 | // The regexp pattern or array of patterns that Jest uses to detect test files 166 | // testRegex: [], 167 | 168 | // This option allows the use of a custom results processor 169 | // testResultsProcessor: null, 170 | 171 | // This option allows use of a custom test runner 172 | // testRunner: "jasmine2", 173 | 174 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 175 | // testURL: "http://localhost", 176 | 177 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 178 | // timers: "real", 179 | 180 | // A map from regular expressions to paths to transformers 181 | // transform: null, 182 | 183 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 184 | // transformIgnorePatterns: [ 185 | // "/node_modules/" 186 | // ], 187 | 188 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 189 | // unmockedModulePathPatterns: undefined, 190 | 191 | // Indicates whether each individual test should be reported during the run 192 | verbose: true, 193 | 194 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 195 | watchPathIgnorePatterns: ['storage/*', 'storage_tmp/*'], 196 | 197 | // Whether to use watchman for file crawling 198 | // watchman: true, 199 | }; 200 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "checkJs": true 6 | }, 7 | "exclude": ["node_modules", "**/node_modules/*"], 8 | "include": ["./src"] 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@semrush/purr", 3 | "version": "3.15.10", 4 | "description": "", 5 | "main": "src/cli.cjs", 6 | "scripts": { 7 | "prettier": "prettier -c 'data/*'", 8 | "test": "jest --coverage", 9 | "test:watch": "jest --detectOpenHandles --watch --coverage=false", 10 | "test:watch-all": "jest --detectOpenHandles --watchAll", 11 | "commitlint": "commitlint --from=HEAD~1", 12 | "lint": "npm run lint:js && npm run commitlint", 13 | "lint:js": "eslint --max-warnings 0 --ext .js,.jsx src", 14 | "lint:fix": "npm run lint:js --fix", 15 | "pre-commit": "npm run lint && npm run test -- --silent --verbose false" 16 | }, 17 | "husky": { 18 | "hooks": { 19 | "commit-msg": "commitlint --env HUSKY_GIT_PARAMS" 20 | } 21 | }, 22 | "keywords": [], 23 | "author": "", 24 | "license": "MIT", 25 | "engines": { 26 | "node": ">=21.7.0" 27 | }, 28 | "devDependencies": { 29 | "@commitlint/cli": "^18.4.4", 30 | "@commitlint/config-conventional": "^18.4.4", 31 | "@types/glob": "^8.1.0", 32 | "@types/jest": "^29.5.11", 33 | "@types/morgan": "^1.9.9", 34 | "@types/node": "^20.11.5", 35 | "@types/nunjucks": "^3.2.6", 36 | "@types/serve-favicon": "^2.5.7", 37 | "@types/uuid": "^9.0.7", 38 | "eslint": "^8.56.0", 39 | "eslint-config-airbnb-base": "^15.0.0", 40 | "eslint-config-prettier": "^9.1.0", 41 | "eslint-plugin-import": "^2.29.1", 42 | "eslint-plugin-prettier": "^5.1.3", 43 | "husky": "^8.0.3", 44 | "jest": "^29.7.0", 45 | "prettier": "^3.2.4", 46 | "typescript": "^5.3.3" 47 | }, 48 | "dependencies": { 49 | "@sentry/node": "^7.93.0", 50 | "bull": "^4.12.1", 51 | "commander": "^11.1.0", 52 | "express": "^4.18.2", 53 | "glob": "^10.3.10", 54 | "ioredis": "^5.3.2", 55 | "js-yaml": "^4.1.0", 56 | "lighthouse": "^12.0.0", 57 | "lodash.filter": "^4.6.0", 58 | "lodash.has": "^4.5.2", 59 | "lodash.isempty": "^4.4.0", 60 | "lodash.isundefined": "^3.0.1", 61 | "morgan": "^1.10.0", 62 | "node-fetch": "^2.7.0", 63 | "nodemon": "^3.0.3", 64 | "nunjucks": "^3.2.4", 65 | "prom-client": "^15.1.0", 66 | "puppeteer": "^22.10.0", 67 | "puppeteer-har": "^1.1.2", 68 | "serve-favicon": "^2.5.0", 69 | "uuid": "^9.0.1", 70 | "winston": "^3.11.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/__mocks__/checks/checks.yml: -------------------------------------------------------------------------------- 1 | .action_template: &action_template 2 | - someAction: 3 | - {{SOME_PARAMETER}} 4 | 5 | .another_action_template: &another_action_template 6 | - anotherAction: 7 | - {{SOME_PARAMETER}} 8 | - anotherArgument: .{{ SOME_COMPLEX_PARAMETER.split('.').slice(-2).join('.') }} 9 | 10 | .nested_action_template: &nested_action_template 11 | - *action_template 12 | - *another_action_template 13 | 14 | extra-action-check: 15 | steps: 16 | - someAction: 17 | - 3600 18 | extraAction: 19 | - 3600 20 | 21 | mocked-check: 22 | steps: 23 | - someAction: 24 | - 3600 25 | - anotherAction: 26 | - someArgument 27 | - anotherArgument: anotherValue 28 | 29 | mocked-check-with-params: 30 | parameters: 31 | PARAMETRIZED_VALUE: https 32 | TARGET_DOMAIN: en.example.cn 33 | steps: 34 | - someAction: 35 | - {{SOME_PARAMETER}} 36 | - anotherAction: 37 | - {{SOME_PARAMETER}} 38 | - anotherArgument: .{{ SOME_COMPLEX_PARAMETER.split('.').slice(-2).join('.') }} 39 | 40 | failing-fake-check: 41 | steps: 42 | - someAction: 43 | - 3600 44 | 45 | check-with-exception: 46 | steps: 47 | - errorAction: 48 | - 3600 49 | 50 | check-with-template: 51 | steps: 52 | - *action_template 53 | - someAction: 54 | - 3600 55 | - *action_template 56 | 57 | check-with-template-expected: 58 | steps: 59 | - someAction: 60 | - {{SOME_PARAMETER}} 61 | 62 | - someAction: 63 | - 3600 64 | 65 | - someAction: 66 | - {{SOME_PARAMETER}} 67 | 68 | check-with-nested-template: 69 | steps: 70 | - *nested_action_template 71 | - someAction: 72 | - 3600 73 | - *nested_action_template 74 | 75 | check-with-nested-template-expected: 76 | steps: 77 | - someAction: 78 | - {{SOME_PARAMETER}} 79 | - anotherAction: 80 | - {{SOME_PARAMETER}} 81 | - anotherArgument: .{{ SOME_COMPLEX_PARAMETER.split('.').slice(-2).join('.') }} 82 | 83 | - someAction: 84 | - 3600 85 | 86 | - someAction: 87 | - {{SOME_PARAMETER}} 88 | - anotherAction: 89 | - {{SOME_PARAMETER}} 90 | - anotherArgument: .{{ SOME_COMPLEX_PARAMETER.split('.').slice(-2).join('.') }} 91 | 92 | check-with-invalid-step: 93 | steps: 94 | - 'someAction' 95 | -------------------------------------------------------------------------------- /src/__mocks__/config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const config = jest.genMockFromModule('../config'); 4 | 5 | const configMock = { 6 | concurrency: 3, 7 | 8 | checksDir: path.resolve(__dirname, 'checks'), 9 | suitesDir: path.resolve(__dirname, 'suites'), 10 | parametersInfoFilePath: path.resolve(__dirname, './parameters.yml'), 11 | schedulesFilePath: path.resolve(__dirname, './schedules.yml'), 12 | reports: false, 13 | traces: false, 14 | screenshots: false, 15 | consoleLog: false, 16 | }; 17 | 18 | module.exports = Object.assign(config, configMock); 19 | -------------------------------------------------------------------------------- /src/__mocks__/parameters.yml: -------------------------------------------------------------------------------- 1 | # Format: 2 | # PARAM_NAME: 3 | # desc: Some description 4 | # validate(optional): Name of validator function defined at validators.js 5 | # default: Default value 6 | # protected(false by default): Show value in logs 7 | 8 | SOME_PARAMETER: 9 | desc: Some parametrized value 10 | # validate: isSchemaAllowed 11 | default: SomeValue 12 | 13 | SOME_COMPLEX_PARAMETER: 14 | desc: Some complex parametrized value 15 | # validate: isSchemaAllowed 16 | default: some.complex.value.to.make.some.actions.with.it 17 | -------------------------------------------------------------------------------- /src/__mocks__/schedules.yml: -------------------------------------------------------------------------------- 1 | schedules: 2 | schedule-full: 3 | interval: 60s 4 | labels: 5 | team: some-team 6 | product: some-product 7 | priority: p1 8 | appName: some-app 9 | appLink: app-link 10 | slackChannel: app-slack 11 | allowedCookies: 12 | - /^regex_test{1,30}$/ 13 | checks: 14 | - mocked-check 15 | 16 | schedule-without-labels: 17 | interval: 60s 18 | allowedCookies: 19 | - /^regex_test{1,30}$/ 20 | checks: 21 | - mocked-check 22 | 23 | empty-schedule: {} 24 | 25 | schedule-with-incorrect-labels-type: 26 | interval: 60s 27 | labels: 'NOPE' 28 | 29 | schedule-with-incorrect-label-priority: 30 | interval: 60s 31 | labels: 32 | priority: p10 33 | 34 | schedule-with-incorrect-labels: 35 | interval: 60s 36 | labels: 37 | all-in-fire: true 38 | -------------------------------------------------------------------------------- /src/__mocks__/suites/suites.yml: -------------------------------------------------------------------------------- 1 | mocked-suite: 2 | proxy: some_url 3 | steps: 4 | - mocked-check 5 | - mocked-check-with-param 6 | 7 | failing-mocked-suite: 8 | steps: 9 | - mocked-check 10 | - failing-fake-check 11 | 12 | mocked-suite-with-exception: 13 | steps: 14 | - mocked-check 15 | - check-with-exception 16 | 17 | empty-suite: 18 | 19 | empty-steps-suite: 20 | steps: 21 | -------------------------------------------------------------------------------- /src/__tests__/config.test.js: -------------------------------------------------------------------------------- 1 | const config = jest.requireActual('../config'); 2 | 3 | test('config', () => { 4 | expect(config).toBeInstanceOf(Object); 5 | expect(typeof config.userAgent).toBe('string'); 6 | }); 7 | -------------------------------------------------------------------------------- /src/__tests__/utils.test.js: -------------------------------------------------------------------------------- 1 | const utils = require('../utils'); 2 | 3 | describe('mandatory', () => { 4 | test('fail when name argument is not specified', () => { 5 | expect(() => { 6 | utils.mandatory(); 7 | }).toThrow('Mandatory parameter name is not specified'); 8 | }); 9 | 10 | test.each([[''], [null], [1]])( 11 | 'fail when name argument is invalid ("%s")', 12 | (name) => { 13 | expect(() => { 14 | utils.mandatory(name); 15 | }).toThrow('Mandatory parameter name must be non-empty string'); 16 | } 17 | ); 18 | 19 | function someFunc(someParam = utils.mandatory('someParam')) { 20 | return someParam; 21 | } 22 | 23 | test('fail when it was used as default value', () => { 24 | expect(someFunc).toThrow("Mandatory parameter 'someParam' is missing"); 25 | }); 26 | 27 | test('skip if param value specified', () => { 28 | expect(someFunc('someValue')).toEqual('someValue'); 29 | }); 30 | }); 31 | 32 | describe('enrichError', () => { 33 | test('fail when error argument is not specified', () => { 34 | expect(() => { 35 | utils.enrichError(); 36 | }).toThrow("Mandatory parameter 'error' is missing"); 37 | }); 38 | 39 | test('fail when message argument is not specified', () => { 40 | expect(() => { 41 | utils.enrichError('test'); 42 | }).toThrow("Mandatory parameter 'message' is missing"); 43 | }); 44 | 45 | test('skip if param value specified', () => { 46 | const originErrorMessage = 'Origin Error Message'; 47 | const newErrorMessage = 'New Error Message'; 48 | const originError = new Error(originErrorMessage); 49 | const newError = utils.enrichError(originError, newErrorMessage); 50 | expect(newError.message).toEqual(newErrorMessage); 51 | expect(newError.stack).toEqual( 52 | expect.stringContaining(`Error: ${originErrorMessage}`) 53 | ); 54 | }); 55 | }); 56 | 57 | describe('sleep', () => { 58 | test('sleep is running, at least', () => { 59 | return expect(utils.sleep(100)).resolves.toBe(undefined); 60 | }); 61 | 62 | test('fail when ms argument is not specified', () => { 63 | expect(() => { 64 | utils.sleep(); 65 | }).toThrow("Mandatory parameter 'ms' is missing"); 66 | }); 67 | }); 68 | 69 | describe('stringToRegExp', () => { 70 | test("fail when 'str' argument is not specified", () => { 71 | expect(() => { 72 | utils.stringToRegExp(); 73 | }).toThrow("Mandatory parameter 'str' is missing"); 74 | }); 75 | 76 | test('skip if param value specified', () => { 77 | expect(utils.stringToRegExp(String.raw`/^test_[a-z-]{1,3}$/`)).toEqual( 78 | /^test_[a-z-]{1,3}$/ 79 | ); 80 | expect(utils.stringToRegExp(String.raw`/^test_\/[a-z]{1,3}$/`)).toEqual( 81 | /^test_\/[a-z]{1,3}$/ 82 | ); 83 | expect(utils.stringToRegExp(String.raw`/^test_[a-z-]{1,3}$/gi`)).toEqual( 84 | /^test_[a-z-]{1,3}$/gi 85 | ); 86 | expect(utils.stringToRegExp(String.raw`/^test_\/[a-z]{1,3}$/gi`)).toEqual( 87 | /^test_\/[a-z]{1,3}$/gi 88 | ); 89 | }); 90 | }); 91 | 92 | test('splitArray', () => { 93 | const originArray = ['a', 'b', 'c', 'd', 'e']; 94 | 95 | expect(utils.splitArray(originArray.slice(), 1)).toEqual([originArray]); 96 | expect(utils.splitArray(originArray.slice(), 2)).toEqual([ 97 | ['a', 'b', 'c'], 98 | ['d', 'e'], 99 | ]); 100 | expect(utils.splitArray(originArray.slice(), 3)).toEqual([ 101 | ['a', 'b'], 102 | ['c', 'd'], 103 | ['e'], 104 | ]); 105 | expect(utils.splitArray(originArray.slice(), 4)).toEqual([ 106 | ['a', 'b'], 107 | ['c'], 108 | ['d'], 109 | ['e'], 110 | ]); 111 | expect(utils.splitArray(originArray.slice(), 5)).toEqual([ 112 | ['a'], 113 | ['b'], 114 | ['c'], 115 | ['d'], 116 | ['e'], 117 | ]); 118 | expect(utils.splitArray(originArray.slice(), 6)).toEqual([ 119 | ['a'], 120 | ['b'], 121 | ['c'], 122 | ['d'], 123 | ['e'], 124 | [], 125 | ]); 126 | }); 127 | -------------------------------------------------------------------------------- /src/__tests__/validators.test.js: -------------------------------------------------------------------------------- 1 | const validators = require('../validators'); 2 | 3 | test.each(['', null, '@', '_', 'azAZ19-_@a'])( 4 | 'return true for allowed server(%s)', 5 | (value) => { 6 | expect(validators.isServerAllowed(value)).toEqual(true); 7 | } 8 | ); 9 | 10 | test.each(['$', '%', '"', "'"])( 11 | 'return false for not-allowed server(%s)', 12 | (value) => { 13 | expect(validators.isServerAllowed(value)).toEqual(false); 14 | } 15 | ); 16 | 17 | test.each(['http', 'https'])('return true for allowed schema(%s)', (value) => { 18 | expect(validators.isSchemaAllowed(value)).toEqual(true); 19 | }); 20 | 21 | test.each(['', null, 'ftp'])( 22 | 'return false for not-allowed schema(%s)', 23 | (value) => { 24 | expect(validators.isSchemaAllowed(value)).toEqual(false); 25 | } 26 | ); 27 | 28 | test.each(['www.example.com', 'example.com'])( 29 | 'return true for allowed domain(%s)', 30 | (value) => { 31 | expect(validators.isDomainAllowed(value)).toEqual(true); 32 | } 33 | ); 34 | 35 | test.each(['www.google.com', '', null])( 36 | 'return false for not-allowed domain(%s)', 37 | (value) => { 38 | expect(validators.isDomainAllowed(value)).toEqual(false); 39 | } 40 | ); 41 | -------------------------------------------------------------------------------- /src/actions/common/index.js: -------------------------------------------------------------------------------- 1 | const lighthouse = require('lighthouse/core/index.cjs'); 2 | 3 | const config = require('../../config'); 4 | const utils = require('../../utils'); 5 | const CheckReportCustomData = require('../../report/CheckReportCustomData'); 6 | 7 | /** 8 | * @param {HTMLElement} el 9 | */ 10 | function getInnerText(el) { 11 | return el.innerText; 12 | } 13 | 14 | /** 15 | * Checks that selector's innerText contains the target text 16 | * 17 | * @param {import('../context').ActionContext} context 18 | * @param {string} selector 19 | * @param {string} targetText 20 | */ 21 | exports.selectorContains = async (context, selector, targetText) => { 22 | await context.page.$eval(selector, getInnerText).then((content) => { 23 | if (content.includes(targetText)) { 24 | return; 25 | } 26 | throw new Error(`Element '${selector}' does not contain '${targetText}'`); 27 | }); 28 | }; 29 | 30 | /** 31 | * Checks that selector's innerText doesn't contain target text 32 | * 33 | * @param {import('../context').ActionContext} context 34 | * @param {string} selector 35 | * @param {string} targetText 36 | */ 37 | exports.selectorNotContains = async (context, selector, targetText) => { 38 | await context.page.$eval(selector, getInnerText).then((content) => { 39 | if (!content.includes(targetText)) { 40 | return; 41 | } 42 | throw new Error(`Element '${selector}' should not contain '${targetText}'`); 43 | }); 44 | }; 45 | 46 | /** 47 | * Returns iframe with specified selector 48 | * 49 | * @param {import('../context').ActionContext} context 50 | * @param {string} frameSelector 51 | * @param {string} selector 52 | */ 53 | exports.getFrame = async (context, frameSelector) => { 54 | let frame; 55 | try { 56 | const frameElement = await context.page.waitForSelector(frameSelector); 57 | frame = await frameElement.contentFrame(); 58 | 59 | if (!frame) { 60 | throw new Error(`Selector '${frameSelector}' is not IFrame`); 61 | } 62 | } catch (err) { 63 | throw utils.enrichError( 64 | err, 65 | `IFrame '${frameSelector}' not found: ${err.message}` 66 | ); 67 | } 68 | 69 | return frame; 70 | }; 71 | 72 | /** 73 | * Checks that iframe contains specified selector 74 | * 75 | * @param {import('../context').ActionContext} context 76 | * @param {string} frameSelector 77 | * @param {string} selector 78 | */ 79 | exports.frameWaitForSelector = async (context, frameSelector, selector) => { 80 | try { 81 | const frame = await exports.getFrame(context, frameSelector, selector); 82 | await frame.waitForSelector(selector); 83 | } catch (err) { 84 | throw utils.enrichError( 85 | err, 86 | `Selector '${selector}' not found in IFrame '${frameSelector}': ` + 87 | `${err.message}` 88 | ); 89 | } 90 | }; 91 | 92 | /** 93 | * Checks that iframe selector's innerText contains the target text 94 | * 95 | * @param {import('../context').ActionContext} context 96 | * @param {string} frameSelector 97 | * @param {string} selector 98 | * @param {string} targetText 99 | */ 100 | exports.frameSelectorContains = async ( 101 | context, 102 | frameSelector, 103 | selector, 104 | targetText 105 | ) => { 106 | try { 107 | const frame = await exports.getFrame(context, frameSelector, selector); 108 | 109 | await frame.$eval(selector, getInnerText).then((content) => { 110 | if (content.includes(targetText)) { 111 | return; 112 | } 113 | throw new Error(`Element '${selector}' does not contain '${targetText}'`); 114 | }); 115 | } catch (err) { 116 | throw utils.enrichError( 117 | err, 118 | `IFrame '${frameSelector}' selector '${selector}' does not contain ` + 119 | `'${targetText}': ${err.message}` 120 | ); 121 | } 122 | }; 123 | 124 | /** 125 | * Checks that iframe selector's innerText doesn't contain target text 126 | * 127 | * @param {import('../context').ActionContext} context 128 | * @param {string} frameSelector 129 | * @param {string} selector 130 | * @param {string} targetText 131 | */ 132 | exports.frameSelectorNotContains = async ( 133 | context, 134 | frameSelector, 135 | selector, 136 | targetText 137 | ) => { 138 | try { 139 | const frame = await exports.getFrame(context, frameSelector, selector); 140 | 141 | await frame.$eval(selector, getInnerText).then((content) => { 142 | if (!content.includes(targetText)) { 143 | return; 144 | } 145 | throw new Error( 146 | `Element '${selector}' should not contain '${targetText}'` 147 | ); 148 | }); 149 | } catch (err) { 150 | throw utils.enrichError( 151 | err, 152 | `IFrame '${frameSelector}' selector '${selector}' should not contain ` + 153 | `'${targetText}': ${err.message}` 154 | ); 155 | } 156 | }; 157 | 158 | /** 159 | * Logs lighthouse report performance metrics 160 | * 161 | * @param {import('../context').ActionContext} context 162 | * @param {string} id Id label for reported metrics 163 | * @param {string} url URL to measure 164 | * 165 | * @returns {Promise} 166 | */ 167 | exports.runLighthouse = async (context, id, url) => { 168 | if (config.traces) { 169 | throw new Error('traces config option must be false to run Lighthouse'); 170 | } 171 | 172 | if (!config.chromiumRemoteDebugging) { 173 | throw new Error( 174 | 'chromiumRemoteDebugging config option must be true to run Lighthouse' 175 | ); 176 | } 177 | 178 | const result = await lighthouse(url, { 179 | port: config.chromiumRemoteDebuggingPort, 180 | onlyCategories: ['performance'], 181 | }); 182 | const { lhr } = result; 183 | 184 | const customReport = new CheckReportCustomData(); 185 | customReport.metrics = [ 186 | { 187 | value: lhr.audits['first-contentful-paint'].numericValue, 188 | labels: { name: 'first-contentful-paint', id }, 189 | }, 190 | { 191 | value: lhr.audits['largest-contentful-paint'].numericValue, 192 | labels: { name: 'largest-contentful-paint', id }, 193 | }, 194 | { 195 | value: lhr.audits['first-meaningful-paint'].numericValue, 196 | labels: { name: 'first-meaningful-paint', id }, 197 | }, 198 | { 199 | value: lhr.audits['speed-index'].numericValue, 200 | labels: { name: 'speed-index', id }, 201 | }, 202 | { 203 | value: lhr.audits['server-response-time'].numericValue, 204 | labels: { name: 'server-response-time', id }, 205 | }, 206 | { 207 | value: lhr.audits.interactive.numericValue, 208 | labels: { name: 'interactive', id }, 209 | }, 210 | { 211 | value: lhr.audits['total-blocking-time'].numericValue, 212 | labels: { name: 'total-blocking-time', id }, 213 | }, 214 | { 215 | value: lhr.audits['cumulative-layout-shift'].numericValue, 216 | labels: { name: 'cumulative-layout-shift', id }, 217 | }, 218 | ]; 219 | return customReport; 220 | }; 221 | 222 | /** 223 | * Logs browser performance metrics 224 | * 225 | * @param {import('../context').ActionContext} context 226 | * @param {string} id Id label for reported metrics 227 | * 228 | * @returns {Promise} 229 | */ 230 | exports.logPerformanceMetrics = async (context, id) => { 231 | const firstPaint = JSON.parse( 232 | await context.page.evaluate(() => 233 | JSON.stringify(window.performance.getEntriesByName('first-paint')) 234 | ) 235 | ); 236 | 237 | const firstContentfulPaint = JSON.parse( 238 | await context.page.evaluate(() => 239 | JSON.stringify( 240 | window.performance.getEntriesByName('first-contentful-paint') 241 | ) 242 | ) 243 | ); 244 | 245 | const windowPerformance = JSON.parse( 246 | await context.page.evaluate(() => 247 | JSON.stringify(window.performance.toJSON()) 248 | ) 249 | ); 250 | 251 | const customReport = new CheckReportCustomData(); 252 | customReport.metrics = [ 253 | { 254 | value: firstPaint[0].startTime, 255 | labels: { name: 'first-paint', id }, 256 | }, 257 | { 258 | value: firstContentfulPaint[0].startTime, 259 | labels: { name: 'first-contentful-paint', id }, 260 | }, 261 | { 262 | value: 263 | windowPerformance.timing.domainLookupEnd - 264 | windowPerformance.timing.domainLookupStart, 265 | labels: { name: 'domain-lookup', id }, 266 | }, 267 | { 268 | value: 269 | windowPerformance.timing.connectEnd - 270 | windowPerformance.timing.connectStart, 271 | labels: { name: 'connect', id }, 272 | }, 273 | { 274 | value: 275 | windowPerformance.timing.responseStart - 276 | windowPerformance.timing.requestStart, 277 | labels: { name: 'server-response-time', id }, 278 | }, 279 | ]; 280 | return customReport; 281 | }; 282 | 283 | module.exports = exports; 284 | -------------------------------------------------------------------------------- /src/actions/context.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Extension to add custom actions property. 3 | * @typedef ActionContext 4 | * @property {import('puppeteer').Browser} browser 5 | * @property {import('../browser/page').PageExtended} page 6 | */ 7 | 8 | module.exports = exports; 9 | -------------------------------------------------------------------------------- /src/api/Server.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const express = require('express'); 3 | const Sentry = require('@sentry/node'); 4 | const morgan = require('morgan'); 5 | const serveFavicon = require('serve-favicon'); 6 | 7 | const log = require('../logger'); 8 | const utils = require('../utils'); 9 | const config = require('../config'); 10 | const Metrics = require('./views/Metrics'); 11 | const Checks = require('./views/Checks'); 12 | const Reports = require('./views/Reports'); 13 | 14 | Sentry.init({ 15 | dsn: config.sentryDSN, 16 | environment: config.sentryEnvironment, 17 | release: config.sentryRelease, 18 | debug: config.sentryDebug, 19 | attachStacktrace: config.sentryAttachStacktrace, 20 | }); 21 | utils.logUnhandledRejections(); 22 | 23 | const port = 8080; 24 | 25 | const app = express(); 26 | 27 | app.use(Sentry.Handlers.requestHandler()); 28 | 29 | app.use(morgan('combined')); 30 | app.use(serveFavicon(path.join(__dirname, 'favicon-32x32.png'))); 31 | app.use(express.urlencoded({ extended: true })); 32 | 33 | app.get('/metrics', Metrics.get); 34 | 35 | const apiRouter = express.Router(); 36 | app.use(config.apiUrlPrefix, apiRouter); 37 | 38 | apiRouter.get('/checks', Checks.list); 39 | apiRouter.post('/checks/:name', Checks.exec); 40 | apiRouter.get('/reports/:id', Reports.get); 41 | apiRouter.get('/reports/:name/latest/failed', Reports.failed); 42 | 43 | app.use(Sentry.Handlers.errorHandler()); 44 | 45 | class Server { 46 | static start() { 47 | app.listen(port, () => log.info('App listening on port', { port })); 48 | } 49 | } 50 | 51 | module.exports = Server; 52 | -------------------------------------------------------------------------------- /src/api/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semrush/purr/6ad458ff741fccffc0d4068bd1ae1b0d8d9b190d/src/api/favicon-32x32.png -------------------------------------------------------------------------------- /src/api/views/Checks.js: -------------------------------------------------------------------------------- 1 | const { v4: uuidv4 } = require('uuid'); 2 | 3 | const log = require('../../logger'); 4 | const config = require('../../config'); 5 | const { CheckParser } = require('../../check/parser'); 6 | const CheckRunner = require('../../check/runner'); 7 | const RedisQueue = require('../../queue/RedisQueue'); 8 | 9 | class Checks { 10 | static list(req, res) { 11 | res.send(new CheckParser().getList()); 12 | } 13 | 14 | static exec(req, res, next) { 15 | const queue = new RedisQueue(config.checksQueueName); 16 | const checkRunner = new CheckRunner(queue); 17 | const checkId = uuidv4(); 18 | const { query, params, body } = req; 19 | 20 | // TODO: body.params is not secure? 21 | const checkJob = checkRunner.run(params.name, checkId, body.params); 22 | let isRequestComplete = false; 23 | 24 | if (query.wait === 'true') { 25 | const responseTimeout = setTimeout(() => { 26 | if (isRequestComplete) { 27 | return; 28 | } 29 | 30 | isRequestComplete = true; 31 | 32 | const errMessage = `Check waiting timeout exceeded`; 33 | 34 | log.info(errMessage, { checkId }); 35 | 36 | res.status(408).json({ 37 | status: 'error', 38 | error: errMessage, 39 | checkId, 40 | }); 41 | }, config.apiWaitTimeout); 42 | 43 | checkJob 44 | .then(async (report) => { 45 | if (isRequestComplete) { 46 | return; 47 | } 48 | 49 | isRequestComplete = true; 50 | 51 | const result = { report }; 52 | 53 | if (result.report.tracePath) { 54 | result.report.tracePath = result.report.tracePath.replace( 55 | config.artifactsDir, 56 | `${req.protocol}://${req.headers.host}/storage` 57 | ); 58 | } 59 | if (result.report.screenshotPath) { 60 | result.report.screenshotPath = result.report.screenshotPath.replace( 61 | config.artifactsDir, 62 | `${req.protocol}://${req.headers.host}/storage` 63 | ); 64 | } 65 | if (result.report.consoleLogPath) { 66 | result.report.consoleLogPath = result.report.consoleLogPath.replace( 67 | config.artifactsDir, 68 | `${req.protocol}://${req.headers.host}/storage` 69 | ); 70 | } 71 | 72 | res 73 | .set({ 'Content-Type': 'application/json; charset=utf-8' }) 74 | .send( 75 | query.view === 'pretty' ? JSON.stringify(result, null, 2) : result 76 | ); 77 | }) 78 | .catch(next) 79 | .finally(() => { 80 | clearTimeout(responseTimeout); 81 | queue.close(); 82 | }); 83 | } else { 84 | queue.close(); 85 | res 86 | .status(202) 87 | .location(`${config.apiUrlPrefix}/reports/${checkId}`) 88 | .send({ id: checkId }); 89 | } 90 | } 91 | } 92 | 93 | module.exports = Checks; 94 | -------------------------------------------------------------------------------- /src/api/views/Metrics.js: -------------------------------------------------------------------------------- 1 | const prom = require('prom-client'); 2 | const Redis = require('ioredis'); 3 | 4 | const log = require('../../logger'); 5 | const config = require('../../config'); 6 | const RedisQueue = require('../../queue/RedisQueue'); 7 | const metrics = require('../../metrics/metrics'); 8 | 9 | prom.collectDefaultMetrics({ timeout: 5000, prefix: metrics.prefix }); 10 | 11 | const checksSuccessfulTotal = new prom.Gauge({ 12 | name: `${metrics.prefix}${metrics.names.checksSuccessfulTotal}`, 13 | help: 'Count of successful checks', 14 | }); 15 | 16 | const checksFailedTotal = new prom.Gauge({ 17 | name: `${metrics.prefix}${metrics.names.checksFailedTotal}`, 18 | help: 'Count of failed checks', 19 | }); 20 | 21 | const queueJobCountGauge = new prom.Gauge({ 22 | name: `${metrics.prefix}queue_job_count`, 23 | help: 'Count of jobs in queue', 24 | labelNames: ['queue', 'state'], 25 | }); 26 | 27 | const checksScheduled = new prom.Gauge({ 28 | name: `${metrics.prefix}${metrics.names.checksScheduled}`, 29 | help: 'Count of scheduled checks', 30 | }); 31 | 32 | const labelNames = [ 33 | 'name', 34 | 'schedule', 35 | 'team', 36 | 'product', 37 | 'priority', 38 | 'appName', 39 | 'appLink', 40 | 'slackChannel', 41 | ]; 42 | 43 | const checkIntervalSeconds = new prom.Gauge({ 44 | name: `${metrics.prefix}${metrics.names.checkIntervalSeconds}`, 45 | help: 'Check schedule interval', 46 | labelNames, 47 | }); 48 | 49 | const checkWaitTimeSeconds = new prom.Gauge({ 50 | name: `${metrics.prefix}${metrics.names.checkWaitTimeSeconds}`, 51 | help: 'Time from last check completion', 52 | labelNames, 53 | }); 54 | 55 | const checkDurationSeconds = new prom.Gauge({ 56 | name: `${metrics.prefix}${metrics.names.checkDurationSeconds}`, 57 | help: 'Last check duration', 58 | labelNames, 59 | }); 60 | 61 | const reportCheckSuccess = new prom.Gauge({ 62 | name: `${metrics.prefix}${metrics.names.reportCheckSuccess}`, 63 | help: 'Status of last check execution', 64 | labelNames, 65 | }); 66 | 67 | const reportCheckForbiddenCookies = new prom.Gauge({ 68 | name: `${metrics.prefix}${metrics.names.reportCheckForbiddenCookies}`, 69 | help: 'Count of forbidden cookies found', 70 | labelNames, 71 | }); 72 | 73 | const reportCheckStart = new prom.Gauge({ 74 | name: `${metrics.prefix}${metrics.names.reportCheckStart}`, 75 | help: 'Start time', 76 | labelNames, 77 | }); 78 | 79 | const reportCheckEnd = new prom.Gauge({ 80 | name: `${metrics.prefix}${metrics.names.reportCheckEnd}`, 81 | help: 'End time', 82 | labelNames, 83 | }); 84 | 85 | const reportCheckLastStep = new prom.Gauge({ 86 | name: `${metrics.prefix}${metrics.names.reportCheckLastStep}`, 87 | help: 'Number of last executed step(from 0)', 88 | labelNames, 89 | }); 90 | 91 | const reportCheckCustomMetric = new prom.Gauge({ 92 | name: `${metrics.prefix}${metrics.names.reportCheckCustomMetric}`, 93 | help: 'Custom report metrics', 94 | labelNames: ['name', 'schedule', 'metric_name', 'metric_id'], 95 | }); 96 | 97 | class Metrics { 98 | static async get(req, res) { 99 | // Queues status 100 | const redisQueues = [new RedisQueue(config.checksQueueName)]; 101 | 102 | redisQueues.forEach(async (queue) => { 103 | Object.entries(await queue.getJobCounts()).forEach(([k, v]) => { 104 | queueJobCountGauge.set({ queue: queue.name, state: k }, v); 105 | }); 106 | 107 | queue.close(); 108 | }); 109 | 110 | // Reports 111 | const redis = new Redis({ 112 | port: config.redisPort, 113 | host: config.redisHost, 114 | password: config.redisPassword, 115 | }); 116 | 117 | const schedules = {}; 118 | 119 | await redis 120 | .keys('purr:schedules:*') 121 | .then((keys) => { 122 | return Promise.all( 123 | keys.map(async (key) => { 124 | return redis 125 | .get(key) 126 | .then((result) => { 127 | schedules[key.replace('purr:schedules:', '')] = 128 | JSON.parse(result); 129 | }) 130 | .catch((err) => { 131 | log.error('Can not get schedule from redis: ', err); 132 | }); 133 | }) 134 | ); 135 | }) 136 | .catch((err) => { 137 | log.error('Can not get schedule list from redis: ', err); 138 | }); 139 | 140 | await Promise.all( 141 | Object.entries(schedules).map(async ([scheduleName, checks]) => { 142 | return Promise.all( 143 | checks.map((checkName) => { 144 | const checkIdentifier = `${scheduleName}:${checkName}`; 145 | const reportKey = `purr:reports:checks:${checkIdentifier}`; 146 | 147 | return redis 148 | .multi() 149 | .get(reportKey) 150 | .get( 151 | [metrics.redisKeyPrefix, metrics.names.checksScheduled].join( 152 | ':' 153 | ) 154 | ) 155 | .get( 156 | [ 157 | metrics.redisKeyPrefix, 158 | metrics.names.checksSuccessfulTotal, 159 | ].join(':') 160 | ) 161 | .get( 162 | [metrics.redisKeyPrefix, metrics.names.checksFailedTotal].join( 163 | ':' 164 | ) 165 | ) 166 | .get( 167 | [ 168 | metrics.redisKeyPrefix, 169 | metrics.names.checkDurationSeconds, 170 | checkIdentifier, 171 | ].join(':') 172 | ) 173 | .get( 174 | [ 175 | metrics.redisKeyPrefix, 176 | metrics.names.checkWaitTimeSeconds, 177 | checkIdentifier, 178 | ].join(':') 179 | ) 180 | .get( 181 | [ 182 | metrics.redisKeyPrefix, 183 | metrics.names.checkIntervalSeconds, 184 | scheduleName, 185 | ].join(':') 186 | ) 187 | .exec() 188 | .then((result) => { 189 | /** 190 | * @type {import('../../report/check').CheckReport | null} 191 | */ 192 | const report = JSON.parse(result[0][1]); 193 | if (!report) { 194 | log.warn( 195 | 'Can not fill report metrics because the report is empty', 196 | { reportKey, report } 197 | ); 198 | return; 199 | } 200 | 201 | if (!Object.prototype.hasOwnProperty.call(report, 'success')) { 202 | log.warn( 203 | 'Can not fill report metrics because the report is in ' + 204 | 'wrong format.', 205 | { reportKey, report } 206 | ); 207 | return; 208 | } 209 | 210 | const team = 211 | report && report.labels.team 212 | ? report.labels.team 213 | : config.defaultTeamLabel; 214 | 215 | const product = 216 | report && report.labels.product 217 | ? report.labels.product 218 | : config.defaultProductLabel; 219 | 220 | const priority = 221 | report && report.labels.priority 222 | ? report.labels.priority 223 | : config.defaultPriorityLabel; 224 | 225 | const appName = 226 | report && report.labels.appName 227 | ? report.labels.appName 228 | : config.defaultAppNameLabel; 229 | 230 | const appLink = 231 | report && report.labels.appLink 232 | ? report.labels.appLink 233 | : config.defaultAppLinkLabel; 234 | 235 | const slackChannel = 236 | report && report.labels.slackChannel 237 | ? report.labels.slackChannel 238 | : config.defaultSlackChannelLabel; 239 | 240 | const labels = { 241 | name: checkName, 242 | schedule: scheduleName, 243 | team, 244 | product, 245 | priority, 246 | appName, 247 | appLink, 248 | slackChannel, 249 | }; 250 | 251 | try { 252 | const scheduled = JSON.parse(result[1][1]); 253 | if (scheduled) { 254 | checksScheduled.set(scheduled); 255 | } 256 | 257 | const successful = JSON.parse(result[2][1]); 258 | if (successful) { 259 | checksSuccessfulTotal.set(successful); 260 | } 261 | 262 | const failed = JSON.parse(result[3][1]); 263 | if (failed) { 264 | checksFailedTotal.set(failed); 265 | } 266 | 267 | checkDurationSeconds.set(labels, JSON.parse(result[4][1])); 268 | checkWaitTimeSeconds.set(labels, JSON.parse(result[5][1])); 269 | 270 | const interval = JSON.parse(result[6][1]); 271 | if (interval) { 272 | checkIntervalSeconds.set(labels, interval); 273 | } 274 | 275 | reportCheckSuccess.set( 276 | labels, 277 | report && report.success ? 1 : 0 278 | ); 279 | 280 | reportCheckForbiddenCookies.set( 281 | labels, 282 | report ? report.forbiddenCookiesCount : 0 283 | ); 284 | 285 | reportCheckStart.set( 286 | labels, 287 | report ? Date.parse(report.startDateTime) : 0 288 | ); 289 | reportCheckEnd.set( 290 | labels, 291 | report ? Date.parse(report.endDateTime) : 0 292 | ); 293 | reportCheckLastStep.set( 294 | labels, 295 | report ? report.actions.length : 0 296 | ); 297 | 298 | if (report) { 299 | report.metrics.forEach((metric) => { 300 | reportCheckCustomMetric.set( 301 | { 302 | name: checkName, 303 | schedule: scheduleName, 304 | metric_name: metric.labels.name, 305 | metric_id: metric.labels.id, 306 | }, 307 | metric.value 308 | ); 309 | }); 310 | } 311 | } catch (err) { 312 | log.error('Can not fill report metrics: ', err, { 313 | reportKey, 314 | }); 315 | } 316 | }) 317 | .catch((err) => { 318 | log.error('Can not get report from redis: ', err, { 319 | reportKey, 320 | }); 321 | }); 322 | }) 323 | ); 324 | }) 325 | ); 326 | 327 | redis.quit(); 328 | 329 | // Other 330 | res.set('Content-Type', prom.register.contentType); 331 | res.end(await prom.register.metrics()); 332 | 333 | prom.register.resetMetrics(); 334 | } 335 | } 336 | 337 | module.exports = Metrics; 338 | -------------------------------------------------------------------------------- /src/api/views/Reports.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const isEmpty = require('lodash.isempty'); 3 | const config = require('../../config'); 4 | const ReportPathsGenerator = require('../../report/pathGenerator'); 5 | const ReportURLReplacer = require('../../report/urlReplacer'); 6 | const { CheckData } = require('../../check/check'); 7 | 8 | class Reports { 9 | static get(req, res) { 10 | const generator = new ReportPathsGenerator(config); 11 | 12 | let name = req.params.id; 13 | if (config.artifactsGroupByCheckName) { 14 | if (isEmpty(req.query.name)) { 15 | res 16 | .status(400) 17 | .set({ 'Content-Type': 'application/json; charset=utf-8' }) 18 | .send({ 19 | code: 400, 20 | message: 21 | 'Artifact group by name is enabled but check name in request query is empty.', 22 | }); 23 | return; 24 | } 25 | name = req.query.name; 26 | } 27 | 28 | const paths = generator.get( 29 | new CheckData(name, req.params.id, null, null, []) 30 | ); 31 | 32 | let report; 33 | try { 34 | report = JSON.parse(fs.readFileSync(paths.getReportPath(), 'utf8')); 35 | } catch (err) { 36 | res 37 | .status(404) 38 | .set({ 'Content-Type': 'application/json; charset=utf-8' }) 39 | .send({ 40 | code: 404, 41 | message: `Failed to parse file: ${err.message}`, 42 | }); 43 | return; 44 | } 45 | 46 | const replacer = new ReportURLReplacer(config); 47 | report = replacer.replacePaths(report, req); 48 | res 49 | .set({ 'Content-Type': 'application/json; charset=utf-8' }) 50 | .send( 51 | req.query.view === 'pretty' ? JSON.stringify(report, null, 2) : report 52 | ); 53 | } 54 | 55 | static failed(req, res) { 56 | const generator = new ReportPathsGenerator(config); 57 | const paths = generator.get( 58 | new CheckData( 59 | req.params.name, 60 | req.params.name, 61 | null, 62 | req.query.schedule, 63 | [] 64 | ) 65 | ); 66 | 67 | let report; 68 | try { 69 | report = JSON.parse( 70 | fs.readFileSync(paths.getLatestFailedReportPath(), 'utf8') 71 | ); 72 | } catch (err) { 73 | res 74 | .status(404) 75 | .set({ 'Content-Type': 'application/json; charset=utf-8' }) 76 | .send({ 77 | code: 404, 78 | message: `Failed to parse file: ${err.message}`, 79 | }); 80 | return; 81 | } 82 | 83 | const replacer = new ReportURLReplacer(config); 84 | report = replacer.replacePaths(report, req); 85 | res 86 | .set({ 'Content-Type': 'application/json; charset=utf-8' }) 87 | .send( 88 | req.query.view === 'pretty' ? JSON.stringify(report, null, 2) : report 89 | ); 90 | } 91 | } 92 | 93 | module.exports = Reports; 94 | -------------------------------------------------------------------------------- /src/api/views/Suites.js: -------------------------------------------------------------------------------- 1 | const config = require('../../config'); 2 | const { SuiteParser } = require('../../suite/parser'); 3 | const SuiteRunner = require('../../suite/runner'); 4 | const RedisQueue = require('../../queue/RedisQueue'); 5 | 6 | class Suites { 7 | static list(req, res) { 8 | res.send(new SuiteParser(config.suitesDir).getList()); 9 | } 10 | 11 | static exec(req, res, next) { 12 | const queue = new RedisQueue(config.checksQueueName); 13 | const suiteRunner = new SuiteRunner(queue); 14 | 15 | suiteRunner 16 | .run(req.params.name) 17 | .then((report) => { 18 | res.send(report); 19 | }) 20 | .catch(next) 21 | .finally(() => { 22 | queue.close(); 23 | }); 24 | } 25 | } 26 | 27 | module.exports = Suites; 28 | -------------------------------------------------------------------------------- /src/browser/browser.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | 3 | const config = require('../config'); 4 | const log = require('../logger'); 5 | 6 | /** 7 | * Returns Puppeteer Browser instance 8 | * 9 | * @param {string} userAgent Browser User Agent 10 | * @param {string[]} customArgs Additional arguments to pass to the browser instance 11 | * @returns {Promise} 12 | */ 13 | exports.getBrowser = async (userAgent = config.userAgent, customArgs = []) => { 14 | const args = [ 15 | `--window-size=${config.windowWidth},${config.windowHeight}`, 16 | `--user-agent=${userAgent}`, 17 | '--no-sandbox', 18 | '--disk-cache-size=0', 19 | '--disable-dev-shm-usage', 20 | '--disable-gpu', 21 | '--disable-audio-output', 22 | '--disable-site-isolation-trials', 23 | '--no-zygote', 24 | ...customArgs, 25 | ]; 26 | 27 | if (config.chromiumRemoteDebugging) { 28 | if (config.concurrency > 1) { 29 | throw new Error( 30 | 'chromiumRemoteDebugging config option is not compatible with concurrency > 1' 31 | ); 32 | } 33 | 34 | args.push( 35 | `--remote-debugging-address=${config.chromiumRemoteDebuggingAddress}`, 36 | `--remote-debugging-port=${config.chromiumRemoteDebuggingPort}` 37 | ); 38 | } 39 | 40 | log.debug('Lauching browser with args', { args }); 41 | return puppeteer.launch({ 42 | // executablePath: 'google-chrome', 43 | // executablePath: 'google-chrome-unstable', 44 | // pipe: true, 45 | 46 | args, 47 | 48 | defaultViewport: { 49 | width: config.windowWidth, 50 | height: config.windowHeight, 51 | }, 52 | 53 | // We want to handle it manually in bull workers 54 | handleSIGTERM: false, 55 | handleSIGINT: false, 56 | handleSIGHUP: false, 57 | 58 | dumpio: config.browserDumpIO, 59 | headless: config.browserHeadless ? 'new' : 'headful', 60 | protocolTimeout: config.browserProtocolTimeout, 61 | }); 62 | }; 63 | 64 | module.exports = exports; 65 | -------------------------------------------------------------------------------- /src/browser/page.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const glob = require('glob'); 3 | 4 | const config = require('../config'); 5 | const log = require('../logger'); 6 | 7 | /** 8 | * Extension to add custom actions property. 9 | * @typedef PageCustomActions 10 | * @property {Record} [actions] 11 | */ 12 | 13 | /** 14 | * Extends the puppeteer `Page` class to add custom properties. 15 | * @typedef {import('puppeteer').Page & PageCustomActions} PageExtended 16 | */ 17 | 18 | /** 19 | * Enables blocking of requests to specified domains 20 | * 21 | * @param {PageExtended} page 22 | * @param {String[]} domains List of domains to block 23 | */ 24 | async function BlockRequests(page, domains) { 25 | await page.setRequestInterception(true); 26 | 27 | page.on('request', (request) => { 28 | const url = new URL(request.url()); 29 | 30 | if (domains.includes(url.host.toLowerCase())) { 31 | log.debug('Request blocked', { url: request.url() }); 32 | request.abort(); 33 | } else { 34 | request.continue(request.continueRequestOverrides(), 0).catch((err) => { 35 | log.error( 36 | `Could not continue request(${request.method()} ${request.url()}): `, 37 | err 38 | ); 39 | }); 40 | } 41 | }); 42 | } 43 | 44 | /** 45 | * Get Puppeteer Page instance 46 | * 47 | * @param {import('puppeteer').Browser} browser Puppeteer Browser instance 48 | * @returns {Promise} 49 | */ 50 | exports.getPage = async (browser) => { 51 | /** @type {PageExtended} */ 52 | const page = await browser.newPage(); 53 | page.actions = {}; 54 | 55 | /** @type {import('../actions/context').ActionContext} */ 56 | const context = { browser, page }; 57 | 58 | glob.sync('../actions/**/*.js', { cwd: __dirname }).forEach((file) => { 59 | log.debug(`Actions file found: ${file}`); 60 | 61 | const objectPath = file.split(path.sep).slice(1, -1); 62 | 63 | // eslint-disable-next-line import/no-dynamic-require,global-require 64 | const actionsModule = require(file); 65 | 66 | page.actions[objectPath[1]] = {}; 67 | 68 | Object.keys(actionsModule).forEach((actionFn) => { 69 | page.actions[objectPath[1]][actionFn] = (...args) => 70 | actionsModule[actionFn](context, ...args); 71 | 72 | log.debug(`Action added: ${objectPath[1]}.${actionFn}`); 73 | }); 74 | }); 75 | 76 | if (config.blockedResourceDomains.length > 0) { 77 | await BlockRequests(page, config.blockedResourceDomains); 78 | } 79 | 80 | await page.setDefaultNavigationTimeout(config.navigationTimeout); 81 | 82 | return page; 83 | }; 84 | 85 | module.exports = exports; 86 | -------------------------------------------------------------------------------- /src/check/__mocks__/runner.js: -------------------------------------------------------------------------------- 1 | const { v4: uuidv4 } = require('uuid'); 2 | 3 | const utils = require('../../utils'); 4 | const { CheckReport } = require('../../report/check'); 5 | 6 | class CheckRunner { 7 | constructor(queue = utils.mandatory('queue')) { 8 | this.queue = queue; 9 | } 10 | 11 | async run(name, checkId, scheduleName = null) { 12 | return this.doCheck(name, checkId, scheduleName); 13 | } 14 | 15 | async doCheck( 16 | name = utils.mandatory('name'), 17 | checkId = uuidv4(), 18 | scheduleName = null, 19 | scheduleInterval = 0 20 | ) { 21 | const checkReport = new CheckReport(name, checkId); 22 | checkReport.scheduleName = scheduleName; 23 | checkReport.scheduleInterval = scheduleInterval; 24 | 25 | let result = Promise.resolve().then(() => { 26 | checkReport.startDateTime = new Date().toISOString(); 27 | }); 28 | 29 | result = result.then(async () => { 30 | await utils.sleep(100); 31 | 32 | if (name === 'failing-fake-check') { 33 | checkReport.success = false; 34 | checkReport.shortMessage = 'Mocked failing check is failed'; 35 | checkReport.fullMessage = 'Mocked failing check is failed'; 36 | return Promise.reject(checkReport); 37 | } 38 | 39 | if (name === 'check-with-exception') { 40 | throw new Error('check-with-exception-error'); 41 | } 42 | 43 | checkReport.success = true; 44 | checkReport.shortMessage = 'Moked check is successful'; 45 | checkReport.fullMessage = 'Moked check is successful'; 46 | return Promise.resolve(checkReport); 47 | }); 48 | 49 | result = result.finally(async () => { 50 | checkReport.endDateTime = new Date().toISOString(); 51 | }); 52 | 53 | return result; 54 | } 55 | } 56 | 57 | module.exports = CheckRunner; 58 | -------------------------------------------------------------------------------- /src/check/__tests__/parser.test.js: -------------------------------------------------------------------------------- 1 | const { CheckParser } = require('../parser'); 2 | const config = require('../../config'); 3 | 4 | jest.mock('../../config'); 5 | 6 | let parser; 7 | 8 | beforeEach(async () => { 9 | parser = new CheckParser(); 10 | }); 11 | 12 | afterEach(async () => { 13 | // await browser.close(); 14 | }); 15 | 16 | describe('CheckParser', () => { 17 | test('fail when name not passed', () => { 18 | expect(parser.getScenario).toThrow("Mandatory parameter 'name' is missing"); 19 | }); 20 | 21 | test('fail when check does not exists', () => { 22 | expect(() => { 23 | parser.getScenario('non_existing_check'); 24 | }).toThrow("Check with name 'non_existing_check' does not exist"); 25 | }); 26 | 27 | test('fail when check is not an object', () => { 28 | expect(() => { 29 | parser.getScenario('check-with-invalid-step'); 30 | }).toThrow("Step with index 0 should be 'object', not 'string'."); 31 | }); 32 | 33 | test('get check scenario', () => { 34 | // TODO: Use more smart assert 35 | expect(parser.getScenario('mocked-check')).toBeTruthy(); 36 | }); 37 | 38 | test('get check scenario with params', () => { 39 | // TODO: Use more smart assert 40 | // TODO: Add different usage of parameters 41 | const scenario = parser.getScenario('mocked-check-with-params', { 42 | test: 1, 43 | TARGET_SCHEMA: 'ht', 44 | }); 45 | expect(scenario).toBeTruthy(); 46 | }); 47 | 48 | test('fail when name argument not passed', () => { 49 | expect(parser.getScenario).toThrow("Mandatory parameter 'name' is missing"); 50 | }); 51 | 52 | test('get scenario', () => { 53 | // TODO: use mock for files from vars int test instead __/mocks__/check.yml 54 | // const data = { 55 | // parameters: {}, // FIXME: if parameters not used? 56 | // steps: [ 57 | // { 58 | // waitFor: [1], 59 | // }, 60 | // { 61 | // waitFor: [2, 3], 62 | // }, 63 | // ], 64 | // }; 65 | const dataExpected = [ 66 | ['someAction', [3600]], 67 | ['anotherAction', ['someArgument', { anotherArgument: 'anotherValue' }]], 68 | ]; 69 | 70 | expect(parser.getScenario('mocked-check')).toEqual(dataExpected); 71 | }); 72 | 73 | test('get scenario with default parameters', () => { 74 | const dataExpected = [ 75 | ['someAction', ['SomeValue']], 76 | ['anotherAction', ['SomeValue', { anotherArgument: '.with.it' }]], 77 | ]; 78 | 79 | expect(parser.getScenario('mocked-check-with-params')).toEqual( 80 | dataExpected 81 | ); 82 | }); 83 | 84 | test('get scenario with default parameters and action template', () => { 85 | const dataExpected = parser.getScenario('check-with-template-expected'); 86 | 87 | expect(parser.getScenario('check-with-template')).toEqual(dataExpected); 88 | }); 89 | 90 | test('get scenario with default parameters and nested action template', () => { 91 | const dataExpected = parser.getScenario( 92 | 'check-with-nested-template-expected' 93 | ); 94 | 95 | expect(parser.getScenario('check-with-nested-template')).toEqual( 96 | dataExpected 97 | ); 98 | }); 99 | 100 | test('get scenario with custom parameters', () => { 101 | const customParameters = { 102 | SOME_PARAMETER: 'OverriddenValue', 103 | SOME_COMPLEX_PARAMETER: 'Overridden.Complex.Value', 104 | }; 105 | const dataExpected = [ 106 | ['someAction', [customParameters.SOME_PARAMETER]], 107 | [ 108 | 'anotherAction', 109 | [ 110 | customParameters.SOME_PARAMETER, 111 | { 112 | anotherArgument: `.${customParameters.SOME_COMPLEX_PARAMETER.split( 113 | '.' 114 | ) 115 | .slice(-2) 116 | .join('.')}`, 117 | }, 118 | ], 119 | ], 120 | ]; 121 | 122 | expect( 123 | parser.getScenario('mocked-check-with-params', customParameters) 124 | ).toEqual(dataExpected); 125 | }); 126 | 127 | test('get scenario with custom parameters from env', () => { 128 | const originEnv = { ...process.env }; 129 | 130 | const prefix = config.envVarParamPrefix; 131 | const customParameters = { 132 | SOME_PARAMETER: 'OverriddenValue', 133 | SOME_COMPLEX_PARAMETER: 'Overridden.Complex.Value', 134 | }; 135 | const envParameters = { 136 | [`${prefix}SOME_PARAMETER`]: customParameters.SOME_PARAMETER, 137 | [`${prefix}SOME_COMPLEX_PARAMETER`]: 138 | customParameters.SOME_COMPLEX_PARAMETER, 139 | }; 140 | const dataExpected = [ 141 | ['someAction', [customParameters.SOME_PARAMETER]], 142 | [ 143 | 'anotherAction', 144 | [ 145 | customParameters.SOME_PARAMETER, 146 | { 147 | anotherArgument: `.${customParameters.SOME_COMPLEX_PARAMETER.split( 148 | '.' 149 | ) 150 | .slice(-2) 151 | .join('.')}`, 152 | }, 153 | ], 154 | ], 155 | ]; 156 | 157 | Object.assign(process.env, envParameters); 158 | 159 | expect(parser.getScenario('mocked-check-with-params')).toEqual( 160 | dataExpected 161 | ); 162 | 163 | process.env = originEnv; 164 | }); 165 | }); 166 | 167 | // test('handle scenario failes', async () => { 168 | // const scenario = [ 169 | // ['waitFor', [1]], 170 | // ['waitFor', [2, 3]], 171 | // ['waitFor', [{ a: 2 }, 3]], 172 | // ]; 173 | 174 | // const page = await check.preparePage(browser); 175 | // page.waitFor = jest 176 | // .fn(async () => { 177 | // return Promise.reject(new Error('fake fail')); 178 | // }) 179 | // .mockImplementationOnce(async () => { 180 | // return true; 181 | // }) 182 | // .mockName('waitFor'); 183 | 184 | // await expect(check.execPageScenario(page, scenario)).rejects.toThrow( 185 | // 'fake fail' 186 | // ); 187 | // expect(page.waitFor).toBeCalledTimes(2); 188 | // }); 189 | 190 | // test('execute scenario', async () => { 191 | // const data = [ 192 | // { 193 | // waitFor: [1], 194 | // }, 195 | // { 196 | // waitFor: [2, 3], 197 | // }, 198 | // { 199 | // waitFor: [{ a: 2 }, 3], 200 | // }, 201 | // ]; 202 | // const scenario = [ 203 | // ['waitFor', [1]], 204 | // ['waitFor', [2, 3]], 205 | // ['waitFor', [{ a: 2 }, 3]], 206 | // ]; 207 | 208 | // const page = await check.preparePage(browser); 209 | // page.waitFor = jest.fn(async () => true).mockName('waitFor'); 210 | 211 | // await check.execPageScenario(page, scenario); 212 | 213 | // expect(page.waitFor).toBeCalledTimes(3); 214 | // expect(page.waitFor.mock.calls[0]).toEqual(scenario[0][1]); 215 | // expect(page.waitFor.mock.calls[1]).toEqual(scenario[1][1]); 216 | // expect(page.waitFor.mock.calls[2]).toEqual(scenario[2][1]); 217 | 218 | // // TODO: add test that initial scenario was not changed 219 | // expect(check.CheckParser.getScenario(data)).toEqual(scenario); 220 | // }); 221 | -------------------------------------------------------------------------------- /src/check/__tests__/runner.test.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const { v4: uuidv4 } = require('uuid'); 3 | 4 | jest.mock('puppeteer'); 5 | jest.mock('../../config'); 6 | 7 | const CheckRunner = require('../runner'); 8 | const SimpleQueue = require('../../queue/SimpleQueue'); 9 | const { CheckReport } = require('../../report/check'); 10 | 11 | const browser = { 12 | newPage: async () => { 13 | return { 14 | setDefaultNavigationTimeout: async () => {}, 15 | on: async () => {}, 16 | someAction: async () => { 17 | return 'someActionReturn'; 18 | }, 19 | anotherAction: async () => { 20 | return 'anotherActionReturn'; 21 | }, 22 | errorAction: async () => { 23 | return Promise.reject(new Error('This action must fail')); 24 | }, 25 | tracing: { 26 | start: async () => {}, 27 | stop: async () => {}, 28 | }, 29 | }; 30 | }, 31 | close: async () => {}, 32 | }; 33 | const queue = new SimpleQueue(); 34 | const runner = new CheckRunner(queue); 35 | 36 | test('CheckRunner arguments', () => { 37 | expect(() => { 38 | return new CheckRunner(); 39 | }).toThrowError("Mandatory parameter 'queue' is missing"); 40 | }); 41 | 42 | test('doCheck', () => { 43 | puppeteer.launch.mockResolvedValue(browser); 44 | 45 | return runner.doCheck('mocked-check', uuidv4()).then((data) => { 46 | expect(data).toBeInstanceOf(CheckReport); 47 | expect(data.name).toEqual('mocked-check'); 48 | expect(data.success).toEqual(true); 49 | }); 50 | }); 51 | 52 | test.each([0.9, 90, Number])( 53 | 'fail when doCheck checkId arg is not a string', 54 | async (checkId) => { 55 | puppeteer.launch.mockResolvedValue(browser); 56 | 57 | await expect(runner.doCheck('mocked-check', checkId)).rejects.toThrowError( 58 | 'Param checkId should be string, not ' 59 | ); 60 | } 61 | ); 62 | 63 | test('run', () => { 64 | puppeteer.launch.mockResolvedValue(browser); 65 | 66 | return runner.run('mocked-check').then((data) => { 67 | expect(data).toBeInstanceOf(CheckReport); 68 | expect(data.name).toEqual('mocked-check'); 69 | expect(data.success).toEqual(true); 70 | }); 71 | }); 72 | 73 | test('check-with-exception', () => { 74 | puppeteer.launch.mockResolvedValue(browser); 75 | 76 | expect.assertions(2); 77 | 78 | return runner.doCheck('check-with-exception', uuidv4()).catch((report) => { 79 | expect(report).toBeInstanceOf(CheckReport); 80 | expect(report).toEqual( 81 | expect.objectContaining({ 82 | shortMessage: 83 | "Action 'errorAction' failed after 1 attempts: This action must fail", 84 | success: false, 85 | }) 86 | ); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /src/check/check.js: -------------------------------------------------------------------------------- 1 | const utils = require('../utils'); 2 | 3 | class CheckData { 4 | constructor( 5 | name = utils.mandatory('name'), 6 | id = utils.mandatory('id'), 7 | params = {}, 8 | scheduleName = null, 9 | labels = [] 10 | ) { 11 | this.name = name; 12 | this.id = id; 13 | this.params = params; 14 | this.scheduleName = scheduleName; 15 | this.labels = labels; 16 | } 17 | } 18 | 19 | module.exports = { 20 | CheckData, 21 | }; 22 | -------------------------------------------------------------------------------- /src/check/parser.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const yaml = require('js-yaml'); 4 | const nunjucks = require('nunjucks'); 5 | 6 | const config = require('../config'); 7 | const utils = require('../utils'); 8 | const ParamParser = require('../parameters/ParamParser'); 9 | 10 | class CheckParser { 11 | constructor() { 12 | const rawContent = []; 13 | 14 | const dir = fs.opendirSync(config.checksDir); 15 | let dirent = dir.readSync(); 16 | 17 | while (dirent !== null) { 18 | if (dirent.isFile()) { 19 | const file = fs.readFileSync( 20 | path.resolve(config.checksDir, dirent.name) 21 | ); 22 | 23 | if (dirent.name.startsWith('.common.')) { 24 | rawContent.unshift(file); 25 | } else { 26 | rawContent.push(file); 27 | } 28 | } 29 | dirent = dir.readSync(); 30 | } 31 | 32 | dir.close(); 33 | 34 | this.rawContent = rawContent.join('\n'); 35 | this.rawDoc = yaml.load(this.rawContent); 36 | this.paramParser = new ParamParser(); 37 | this.preparedDoc = null; 38 | } 39 | 40 | getList() { 41 | return Object.keys(this.rawDoc); 42 | } 43 | 44 | getParsedCheck(name = utils.mandatory('name')) { 45 | if (typeof this.preparedDoc[name] === 'undefined') { 46 | throw new Error(`Check with name '${name}' was not parsed`); 47 | } 48 | return this.preparedDoc[name]; 49 | } 50 | 51 | getScenario(name = utils.mandatory('name'), params = {}) { 52 | if (typeof this.rawDoc[name] === 'undefined') { 53 | throw new Error(`Check with name '${name}' does not exist`); 54 | } 55 | 56 | const scenario = []; 57 | 58 | const mergedParams = this.paramParser.mergeParams( 59 | this.rawDoc[name].parameters, 60 | params 61 | ); 62 | 63 | this.preparedDoc = yaml.load( 64 | nunjucks.renderString(this.rawContent, mergedParams) 65 | ); 66 | const check = this.preparedDoc[name]; 67 | 68 | const flattenedSteps = utils.flattenArray(check.steps, true); 69 | 70 | flattenedSteps.forEach((x, i) => { 71 | if (typeof x !== 'object') { 72 | throw new Error( 73 | `Step with index ${i} should be 'object', not '${typeof x}'.` 74 | ); 75 | } 76 | 77 | Object.entries(x).forEach((action) => { 78 | scenario.push(action); 79 | }); 80 | }); 81 | 82 | return scenario; 83 | } 84 | } 85 | 86 | module.exports = { 87 | CheckParser, 88 | ParamParser, 89 | }; 90 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | ./cli/cli.js -------------------------------------------------------------------------------- /src/cli/__tests__/check.test.js: -------------------------------------------------------------------------------- 1 | const originArgv = process.argv.slice(); 2 | 3 | let logger; 4 | let processExit; 5 | 6 | const exitErrorText = 'process.exit prevented in tests. Code:'; 7 | 8 | const checkName = 'some-check-name'; 9 | 10 | beforeEach(() => { 11 | jest.resetModules(); 12 | jest.restoreAllMocks(); 13 | 14 | jest.doMock('../../logger'); 15 | logger = require('../../logger'); 16 | 17 | processExit = jest 18 | .spyOn(process, 'exit') 19 | .mockName('processExit') 20 | .mockImplementation((code) => { 21 | throw Error(`${exitErrorText} ${code}`); 22 | }); 23 | }); 24 | 25 | afterEach(() => { 26 | process.argv = originArgv; 27 | }); 28 | 29 | test('return full report if check is complete successful', async () => { 30 | const expectedResult = { 31 | shortMessage: 'Fake success', 32 | success: true, 33 | actions: ['fake action'], 34 | }; 35 | 36 | const run = jest.fn().mockResolvedValue(expectedResult); 37 | 38 | jest.doMock('../../check/runner', () => { 39 | return jest.fn().mockImplementation(() => { 40 | return { run }; 41 | }); 42 | }); 43 | 44 | const a = require('../check'); 45 | 46 | await expect(a.run(checkName, {})).resolves.toBeUndefined(); 47 | 48 | expect(run).toBeCalledTimes(1); 49 | expect(run).toBeCalledWith(checkName); 50 | 51 | expect(logger.info).toBeCalled(); 52 | expect(logger.info).toBeCalledWith('Check success', { 53 | report: expectedResult, 54 | }); 55 | }); 56 | 57 | test('shorten report if check is complete successful', async () => { 58 | const expectedResult = { 59 | success: true, 60 | }; 61 | const report = { 62 | shortMessage: 'Fake success', 63 | actions: ['fake action'], 64 | ...expectedResult, 65 | }; 66 | 67 | const run = jest.fn().mockResolvedValue(report); 68 | 69 | jest.doMock('../../check/runner', () => { 70 | return jest.fn().mockImplementation(() => { 71 | return { run }; 72 | }); 73 | }); 74 | 75 | const a = require('../check'); 76 | 77 | await expect(a.run(checkName, { shorten: true })).resolves.toBeUndefined(); 78 | 79 | expect(run).toBeCalledTimes(1); 80 | expect(run).toBeCalledWith(checkName); 81 | 82 | expect(logger.info).toBeCalled(); 83 | expect(logger.info).toBeCalledWith('Check success', { 84 | report: expectedResult, 85 | }); 86 | }); 87 | 88 | test('not shorten report if check is complete but not successful', async () => { 89 | const expectedResult = { 90 | shortMessage: 'Fake fail', 91 | success: false, 92 | actions: ['fake action'], 93 | }; 94 | 95 | const run = jest.fn().mockResolvedValue(expectedResult); 96 | 97 | jest.doMock('../../check/runner', () => { 98 | return jest.fn().mockImplementation(() => { 99 | return { run }; 100 | }); 101 | }); 102 | 103 | const a = require('../check'); 104 | 105 | await expect(a.run(checkName, { shorten: true })).rejects.toThrow( 106 | exitErrorText 107 | ); 108 | 109 | expect(run).toBeCalledTimes(1); 110 | expect(run).toBeCalledWith(checkName); 111 | 112 | expect(logger.error).toBeCalled(); 113 | expect(logger.error).toBeCalledWith('Check failed', { 114 | report: expectedResult, 115 | }); 116 | }); 117 | 118 | test('exit with code 0 if check is complete successful', async () => { 119 | const expectedResult = { 120 | shortMessage: 'Fake success', 121 | success: true, 122 | }; 123 | 124 | const run = jest.fn().mockResolvedValue(expectedResult); 125 | 126 | jest.doMock('../../check/runner', () => { 127 | return jest.fn().mockImplementation(() => { 128 | return { run }; 129 | }); 130 | }); 131 | 132 | const a = require('../check'); 133 | 134 | await expect(a.run(checkName, {})).resolves.toBeUndefined(); 135 | 136 | expect(run).toBeCalledTimes(1); 137 | expect(run).toBeCalledWith(checkName); 138 | 139 | expect(logger.info).toBeCalled(); 140 | expect(logger.info).toBeCalledWith('Check success', { 141 | report: expectedResult, 142 | }); 143 | }); 144 | 145 | test('exit with code 1 if check is complete but not successful', async () => { 146 | const expectedResult = { 147 | shortMessage: 'Fake fail', 148 | success: false, 149 | }; 150 | 151 | const run = jest.fn().mockResolvedValue(expectedResult); 152 | 153 | jest.doMock('../../check/runner', () => { 154 | return jest.fn().mockImplementation(() => { 155 | return { run }; 156 | }); 157 | }); 158 | 159 | const a = require('../check'); 160 | 161 | await expect(a.run(checkName, {})).rejects.toThrow(exitErrorText); 162 | 163 | expect(run).toBeCalledTimes(1); 164 | expect(run).toBeCalledWith(checkName); 165 | 166 | expect(logger.error).toBeCalled(); 167 | expect(logger.error).toBeCalledWith('Check failed', { 168 | report: expectedResult, 169 | }); 170 | 171 | expect(processExit).toBeCalled(); 172 | expect(processExit).toBeCalledWith(1); 173 | }); 174 | 175 | test('exit with code 1 if check failed', async () => { 176 | const checkRunResult = 'Fake unexpected fail'; 177 | const run = jest.fn().mockRejectedValue(checkRunResult); 178 | 179 | jest.doMock('../../check/runner', () => { 180 | return jest.fn().mockImplementation(() => { 181 | return { run }; 182 | }); 183 | }); 184 | 185 | const a = require('../check'); 186 | 187 | await expect(a.run(checkName, {})).rejects.toThrow(exitErrorText); 188 | 189 | expect(run).toBeCalledTimes(1); 190 | expect(run).toBeCalledWith(checkName); 191 | 192 | expect(logger.error).toBeCalled(); 193 | expect(logger.error).toBeCalledWith('Check failed', { 194 | report: 'Fake unexpected fail', 195 | }); 196 | 197 | expect(processExit).toBeCalled(); 198 | expect(processExit).toBeCalledWith(1); 199 | }); 200 | -------------------------------------------------------------------------------- /src/cli/__tests__/cli-check.test.js: -------------------------------------------------------------------------------- 1 | const originArgv = process.argv.slice(); 2 | 3 | let processExit; 4 | const exitErrorText = 'process.exit prevented in tests. Code:'; 5 | 6 | beforeEach(() => { 7 | jest.resetModules(); 8 | jest.restoreAllMocks(); 9 | 10 | processExit = jest 11 | .spyOn(process, 'exit') 12 | .mockName('processExit') 13 | .mockImplementation((code) => { 14 | throw Error(`${exitErrorText} ${code}`); 15 | }); 16 | }); 17 | 18 | afterEach(() => { 19 | process.argv = originArgv; 20 | }); 21 | 22 | describe('run check', () => { 23 | let check; 24 | const checkName = 'some-check-name'; 25 | 26 | beforeEach(() => { 27 | jest.mock('../check'); 28 | check = require('../check'); 29 | }); 30 | 31 | test('default options', () => { 32 | process.argv = [process.argv[0], './cli-check.js', checkName]; 33 | 34 | require('../cli-check'); 35 | 36 | expect(check.run).toBeCalledTimes(1); 37 | expect(check.run).toBeCalledWith(checkName, { 38 | hideActions: false, 39 | shorten: true, 40 | }); 41 | expect(processExit).not.toBeCalled(); 42 | }); 43 | 44 | test('changed options', () => { 45 | process.argv = [ 46 | process.argv[0], 47 | './cli-check.js', 48 | checkName, 49 | '--hide-actions', 50 | ]; 51 | 52 | require('../cli-check'); 53 | 54 | expect(check.run).toBeCalledTimes(1); 55 | expect(check.run).toBeCalledWith(checkName, { 56 | hideActions: true, 57 | shorten: true, 58 | }); 59 | expect(processExit).not.toBeCalled(); 60 | }); 61 | 62 | test('changed options to false', () => { 63 | process.argv = [ 64 | process.argv[0], 65 | './cli-check.js', 66 | checkName, 67 | '--hide-actions', 68 | '--no-shorten', 69 | ]; 70 | 71 | require('../cli-check'); 72 | 73 | expect(check.run).toBeCalledTimes(1); 74 | expect(check.run).toBeCalledWith(checkName, { 75 | hideActions: true, 76 | shorten: false, 77 | }); 78 | expect(processExit).not.toBeCalled(); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/cli/__tests__/cli-suite.test.js: -------------------------------------------------------------------------------- 1 | const originArgv = process.argv.slice(); 2 | 3 | let processExit; 4 | const exitErrorText = 'process.exit prevented in tests. Code:'; 5 | 6 | beforeEach(() => { 7 | jest.resetModules(); 8 | jest.restoreAllMocks(); 9 | 10 | processExit = jest 11 | .spyOn(process, 'exit') 12 | .mockName('processExit') 13 | .mockImplementation((code) => { 14 | throw Error(`${exitErrorText} ${code}`); 15 | }); 16 | }); 17 | 18 | afterEach(() => { 19 | process.argv = originArgv; 20 | }); 21 | 22 | describe('run suite', () => { 23 | let suite; 24 | const suiteName = 'some-suite-name'; 25 | 26 | beforeEach(() => { 27 | jest.mock('../suite'); 28 | suite = require('../suite'); 29 | }); 30 | 31 | test.each([true, false])('default options', (useRedis) => { 32 | if (useRedis) { 33 | process.argv = [process.argv[0], './cli-suite.js', '--redis', suiteName]; 34 | } else { 35 | process.argv = [process.argv[0], './cli-suite.js', suiteName]; 36 | } 37 | 38 | require('../cli-suite'); 39 | 40 | expect(suite.run).toBeCalledTimes(1); 41 | expect(suite.run).toBeCalledWith(suiteName, useRedis, { 42 | report: { 43 | checkOptions: { 44 | hideActions: false, 45 | shorten: true, 46 | }, 47 | }, 48 | run: { 49 | part: 1, 50 | split: 1, 51 | }, 52 | }); 53 | expect(processExit).not.toBeCalled(); 54 | }); 55 | 56 | test('changed options', () => { 57 | process.argv = [ 58 | process.argv[0], 59 | './cli-suite.js', 60 | suiteName, 61 | '--hide-actions', 62 | '--part', 63 | '2', 64 | '--split', 65 | '3', 66 | ]; 67 | 68 | require('../cli-suite'); 69 | 70 | expect(suite.run).toBeCalledTimes(1); 71 | expect(suite.run).toBeCalledWith(suiteName, false, { 72 | report: { 73 | checkOptions: { 74 | hideActions: true, 75 | shorten: true, 76 | }, 77 | }, 78 | run: { 79 | part: 2, 80 | split: 3, 81 | }, 82 | }); 83 | expect(processExit).not.toBeCalled(); 84 | }); 85 | 86 | test('changed options to false', () => { 87 | process.argv = [ 88 | process.argv[0], 89 | './cli-suite.js', 90 | suiteName, 91 | '--hide-actions', 92 | '--no-shorten', 93 | ]; 94 | 95 | require('../cli-suite'); 96 | 97 | expect(suite.run).toBeCalledTimes(1); 98 | expect(suite.run).toBeCalledWith(suiteName, false, { 99 | report: { 100 | checkOptions: { 101 | hideActions: true, 102 | shorten: false, 103 | }, 104 | }, 105 | run: { 106 | part: 1, 107 | split: 1, 108 | }, 109 | }); 110 | expect(processExit).not.toBeCalled(); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /src/cli/__tests__/cli-worker.test.js: -------------------------------------------------------------------------------- 1 | jest.mock('../../queue/RedisQueueWorker'); 2 | jest.mock('../../check/runner'); 3 | 4 | const originArgv = process.argv.slice(); 5 | 6 | let logger; 7 | 8 | beforeEach(() => { 9 | jest.resetModules(); 10 | jest.restoreAllMocks(); 11 | 12 | jest.doMock('../../logger'); 13 | logger = require('../../logger'); 14 | }); 15 | 16 | afterEach(() => { 17 | process.argv = originArgv; 18 | }); 19 | 20 | describe('run', () => { 21 | test('success run', () => { 22 | process.argv = [process.argv[0], './cli-worker.js', 'check']; 23 | 24 | require('../cli-worker'); 25 | 26 | expect(logger.info).toBeCalledWith('Running queue worker', { 27 | queue: 'checks-queue', 28 | }); 29 | }); 30 | }); 31 | 32 | describe('validate parameters', () => { 33 | test('do not run worker for incorrect name', () => { 34 | const name = 'dummy'; 35 | 36 | process.argv = [process.argv[0], './cli-worker.js', name]; 37 | 38 | const exitMock = jest.spyOn(process, 'exit').mockImplementation(() => {}); 39 | 40 | require('../cli-worker'); 41 | 42 | expect(logger.error).toHaveBeenCalledWith( 43 | 'Worker with specified name does not exist', 44 | { 45 | name, 46 | } 47 | ); 48 | expect(exitMock).toHaveBeenCalledWith(1); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/cli/__tests__/cli.test.js: -------------------------------------------------------------------------------- 1 | const originArgv = process.argv.slice(); 2 | 3 | beforeEach(() => { 4 | jest.resetModules(); 5 | jest.restoreAllMocks(); 6 | }); 7 | 8 | afterEach(() => { 9 | process.argv = originArgv; 10 | }); 11 | 12 | test('call help if command not specified', () => { 13 | const commander = require('commander'); 14 | jest.spyOn(commander, 'outputHelp').mockImplementation(() => { 15 | throw new Error('Mocked'); 16 | }); 17 | 18 | process.argv = [process.argv[0], './cli.js']; 19 | 20 | expect(() => { 21 | require('../cli'); 22 | }).toThrow('Mocked'); 23 | }); 24 | -------------------------------------------------------------------------------- /src/cli/__tests__/suite.test.js: -------------------------------------------------------------------------------- 1 | const originArgv = process.argv.slice(); 2 | 3 | let logger; 4 | let processExit; 5 | 6 | const exitErrorText = 'process.exit prevented in tests. Code:'; 7 | 8 | const suiteName = 'some-suite-name'; 9 | const defaultReportOptions = { 10 | checkOptions: { 11 | hideActions: false, 12 | shorten: false, 13 | }, 14 | }; 15 | const defaultRunOptions = { 16 | part: 1, 17 | split: 1, 18 | }; 19 | const defaultOptions = { 20 | report: defaultReportOptions, 21 | run: defaultRunOptions, 22 | }; 23 | 24 | const successfulSuiteReportShort = { 25 | shortMessage: 'Fake suite success', 26 | success: true, 27 | checks: [ 28 | { 29 | success: true, 30 | }, 31 | ], 32 | }; 33 | 34 | const successfulSuiteReport = { 35 | ...successfulSuiteReportShort, 36 | checks: [ 37 | { 38 | shortMessage: 'Fake check success', 39 | success: true, 40 | actions: ['fake action'], 41 | }, 42 | ], 43 | }; 44 | 45 | const failedSuiteReport = { 46 | shortMessage: 'Fake suite fail', 47 | success: false, 48 | checks: [ 49 | { 50 | shortMessage: 'Fake check fail', 51 | success: false, 52 | actions: ['fake action'], 53 | }, 54 | ], 55 | }; 56 | 57 | beforeEach(() => { 58 | jest.resetModules(); 59 | jest.restoreAllMocks(); 60 | 61 | jest.doMock('../../logger'); 62 | logger = require('../../logger'); 63 | 64 | processExit = jest 65 | .spyOn(process, 'exit') 66 | .mockName('processExit') 67 | .mockImplementation((code) => { 68 | throw Error(`${exitErrorText} ${code}`); 69 | }); 70 | }); 71 | 72 | afterEach(() => { 73 | process.argv = originArgv; 74 | }); 75 | 76 | describe('exit code', () => { 77 | test.each([true, false])( 78 | 'code 0 if suite is complete successful. (redis: %s)', 79 | async (useRedis) => { 80 | let usedQueue; 81 | 82 | const run = jest.fn().mockResolvedValue(successfulSuiteReport); 83 | 84 | if (useRedis) { 85 | jest.doMock('../../queue/RedisQueue'); 86 | } 87 | 88 | jest.doMock('../../suite/runner', () => { 89 | return jest.fn().mockImplementation((queue) => { 90 | usedQueue = queue; 91 | return { run }; 92 | }); 93 | }); 94 | 95 | const a = require('../suite'); 96 | 97 | await expect( 98 | a.run(suiteName, useRedis, defaultOptions) 99 | ).resolves.toBeUndefined(); 100 | 101 | if (useRedis) { 102 | const RedisQueue = require('../../queue/RedisQueue'); 103 | expect(usedQueue).toBeInstanceOf(RedisQueue); 104 | expect(RedisQueue).toBeCalledTimes(1); 105 | } else { 106 | expect(usedQueue).toBeUndefined(); 107 | } 108 | 109 | expect(run).toBeCalledTimes(1); 110 | expect(run).toBeCalledWith(suiteName, defaultRunOptions); 111 | 112 | expect(logger.info).toBeCalled(); 113 | expect(logger.info).toBeCalledWith('Suite success', { 114 | report: successfulSuiteReport, 115 | }); 116 | } 117 | ); 118 | 119 | test('code 1 if suite is complete but not successful', async () => { 120 | const expectedResult = { 121 | shortMessage: 'Fake fail', 122 | success: false, 123 | checks: [], 124 | }; 125 | 126 | const run = jest.fn().mockResolvedValue(expectedResult); 127 | 128 | jest.doMock('../../suite/runner', () => { 129 | return jest.fn().mockImplementation(() => { 130 | return { run }; 131 | }); 132 | }); 133 | 134 | const a = require('../suite'); 135 | 136 | await expect(a.run(suiteName, false, defaultOptions)).rejects.toThrow( 137 | exitErrorText 138 | ); 139 | 140 | expect(run).toBeCalledTimes(1); 141 | expect(run).toBeCalledWith(suiteName, defaultRunOptions); 142 | 143 | expect(logger.error).toBeCalled(); 144 | expect(logger.error).toBeCalledWith('Suite failed', { 145 | report: expectedResult, 146 | }); 147 | 148 | expect(processExit).toBeCalled(); 149 | expect(processExit).toBeCalledWith(1); 150 | }); 151 | 152 | test('code 1 if suite failed', async () => { 153 | const suiteRunResult = 'Fake unexpected fail'; 154 | const run = jest.fn().mockRejectedValue(suiteRunResult); 155 | 156 | jest.doMock('../../suite/runner', () => { 157 | return jest.fn().mockImplementation(() => { 158 | return { run }; 159 | }); 160 | }); 161 | 162 | const a = require('../suite'); 163 | 164 | await expect(a.run(suiteName, false, defaultOptions)).rejects.toThrow( 165 | exitErrorText 166 | ); 167 | 168 | expect(run).toBeCalledTimes(1); 169 | expect(run).toBeCalledWith(suiteName, defaultRunOptions); 170 | 171 | expect(logger.error).toBeCalled(); 172 | expect(logger.error).toBeCalledWith('Suite failed', { 173 | report: suiteRunResult, 174 | }); 175 | 176 | expect(processExit).toBeCalled(); 177 | expect(processExit).toBeCalledWith(1); 178 | }); 179 | }); 180 | 181 | describe('suite options', () => { 182 | test('return full report if suite is complete successful', async () => { 183 | const run = jest.fn().mockResolvedValue(successfulSuiteReport); 184 | 185 | jest.doMock('../../suite/runner', () => { 186 | return jest.fn().mockImplementation(() => { 187 | return { run }; 188 | }); 189 | }); 190 | 191 | const a = require('../suite'); 192 | 193 | await expect( 194 | a.run(suiteName, false, defaultOptions) 195 | ).resolves.toBeUndefined(); 196 | 197 | expect(run).toBeCalledTimes(1); 198 | expect(run).toBeCalledWith(suiteName, defaultRunOptions); 199 | 200 | expect(logger.info).toBeCalled(); 201 | expect(logger.info).toBeCalledWith('Suite success', { 202 | report: successfulSuiteReport, 203 | }); 204 | }); 205 | 206 | test('shorten report if check is complete successful', async () => { 207 | const run = jest.fn().mockResolvedValue(successfulSuiteReport); 208 | 209 | jest.doMock('../../suite/runner', () => { 210 | return jest.fn().mockImplementation(() => { 211 | return { run }; 212 | }); 213 | }); 214 | 215 | const a = require('../suite'); 216 | 217 | await expect( 218 | a.run(suiteName, false, { 219 | ...defaultOptions, 220 | report: { 221 | checkOptions: { 222 | hideActions: true, 223 | shorten: true, 224 | }, 225 | }, 226 | }) 227 | ).resolves.toBeUndefined(); 228 | 229 | expect(run).toBeCalledTimes(1); 230 | expect(run).toBeCalledWith(suiteName, defaultRunOptions); 231 | 232 | expect(logger.info).toBeCalled(); 233 | expect(logger.info).toBeCalledWith('Suite success', { 234 | report: successfulSuiteReportShort, 235 | }); 236 | }); 237 | 238 | test('not shorten report if check is complete but not successful', async () => { 239 | const run = jest.fn().mockResolvedValue(failedSuiteReport); 240 | 241 | jest.doMock('../../suite/runner', () => { 242 | return jest.fn().mockImplementation(() => { 243 | return { 244 | run, 245 | }; 246 | }); 247 | }); 248 | 249 | const a = require('../suite'); 250 | 251 | await expect( 252 | a.run(suiteName, false, { 253 | ...defaultOptions, 254 | report: { 255 | checkOptions: { 256 | hideActions: false, 257 | shorten: true, 258 | }, 259 | }, 260 | }) 261 | ).rejects.toThrow(exitErrorText); 262 | 263 | expect(run).toBeCalledTimes(1); 264 | expect(run).toBeCalledWith(suiteName, defaultRunOptions); 265 | 266 | expect(logger.error).toBeCalled(); 267 | expect(logger.error).toBeCalledWith('Suite failed', { 268 | report: failedSuiteReport, 269 | }); 270 | }); 271 | }); 272 | -------------------------------------------------------------------------------- /src/cli/check.js: -------------------------------------------------------------------------------- 1 | const log = require('../logger'); 2 | const SimpleQueue = require('../queue/SimpleQueue'); 3 | const CheckRunner = require('../check/runner'); 4 | const { processReport } = require('../report/check'); 5 | 6 | /** 7 | * @param {string} name Check name 8 | * @param {(import('../report/check').CheckReportViewOptions)} options View options 9 | * @returns {object} 10 | */ 11 | function run(name, options) { 12 | const checkRunner = new CheckRunner(new SimpleQueue()); 13 | 14 | log.info(`Running check`, { name }); 15 | 16 | return checkRunner 17 | .run(name) 18 | .then((result) => { 19 | const prepared = processReport(result, options); 20 | 21 | if (!result.success) { 22 | log.error('Check failed', { report: result }); 23 | process.exit(1); 24 | } 25 | 26 | log.info('Check success', { report: prepared }); 27 | }) 28 | .catch((err) => { 29 | log.error('Check failed', { report: err }); 30 | process.exit(1); 31 | }); 32 | } 33 | 34 | module.exports = { run }; 35 | -------------------------------------------------------------------------------- /src/cli/cli-check.js: -------------------------------------------------------------------------------- 1 | const commander = require('commander'); 2 | 3 | process.env.PRETTY_LOG = 'true'; 4 | const config = require('../config'); 5 | const utils = require('../utils'); 6 | const check = require('./check'); 7 | 8 | config.artifactsGroupByCheckName = true; 9 | 10 | utils.logUnhandledRejections(true); 11 | 12 | let checkName; 13 | 14 | commander 15 | .arguments('') 16 | .option('--no-shorten', 'disable successful reports shortening') 17 | .option('--hide-actions', 'hide actions from reports (default: false)') 18 | .action((name) => { 19 | checkName = name; 20 | }) 21 | .parse(process.argv); 22 | 23 | const { shorten } = commander.opts(); 24 | let { hideActions } = commander.opts(); 25 | hideActions = hideActions !== undefined; 26 | 27 | // Workaround for tests coverage 28 | check.run(checkName, { shorten, hideActions }); 29 | -------------------------------------------------------------------------------- /src/cli/cli-queue.js: -------------------------------------------------------------------------------- 1 | const commander = require('commander'); 2 | 3 | const log = require('../logger'); 4 | const config = require('../config'); 5 | const utils = require('../utils'); 6 | const RedisQueue = require('../queue/RedisQueue'); 7 | 8 | utils.logUnhandledRejections(true); 9 | 10 | commander 11 | .command('clean ') 12 | .description('Remove queue jobs older than N(time modifiers: s, m, h, d, w)') 13 | .action(async (timeAsString) => { 14 | const ms = utils.humanReadableTimeToMS(timeAsString); 15 | 16 | const queue = new RedisQueue(config.checksQueueName); 17 | 18 | const jobs = { 19 | completed: (await queue.bull.clean(ms, 'completed')).length || 0, 20 | failed: (await queue.bull.clean(ms, 'failed')).length || 0, 21 | }; 22 | 23 | log.info('Queue jobs removed', { jobs }); 24 | 25 | await queue.close(); 26 | }); 27 | 28 | commander.parse(process.argv); 29 | 30 | if (!process.argv.slice(2).length) { 31 | commander.help(); 32 | } 33 | -------------------------------------------------------------------------------- /src/cli/cli-schedule.js: -------------------------------------------------------------------------------- 1 | const commander = require('commander'); 2 | 3 | const utils = require('../utils'); 4 | 5 | utils.logUnhandledRejections(true); 6 | 7 | const log = require('../logger'); 8 | const config = require('../config'); 9 | const RedisQueue = require('../queue/RedisQueue'); 10 | const ScheduleRunner = require('../schedule/runner'); 11 | 12 | const queue = new RedisQueue(config.checksQueueName); 13 | const scheduleRunner = new ScheduleRunner(queue); 14 | 15 | commander 16 | .command('show') 17 | .description('Show scheduled checks') 18 | .action(() => { 19 | scheduleRunner 20 | .getScheduledChecks() 21 | .then((checks) => { 22 | log.info('Scheduled checks', { checks }); 23 | process.exit(0); 24 | }) 25 | .catch((err) => { 26 | log.error('Can not get scheduled checks: ', err); 27 | process.exit(1); 28 | }); 29 | }); 30 | 31 | commander 32 | .command('clean') 33 | .description('Remove scheduled checks') 34 | .action(() => { 35 | scheduleRunner 36 | .removeScheduledChecks() 37 | .then(() => { 38 | log.info('Scheduled checks has been removed'); 39 | process.exit(0); 40 | }) 41 | .catch((err) => { 42 | log.error('Can not remove scheduled checks: ', err); 43 | process.exit(1); 44 | }); 45 | }); 46 | 47 | commander 48 | .command('apply') 49 | .description('Apply schedules') 50 | .action(async () => { 51 | await scheduleRunner 52 | .runAll() 53 | .then((checks) => { 54 | log.info('Schedules applied', { count: checks.length }); 55 | process.exit(0); 56 | }) 57 | .catch((err) => { 58 | log.error('Can not schedule checks: ', err); 59 | process.exit(1); 60 | }); 61 | }); 62 | 63 | commander.parse(process.argv); 64 | 65 | if (!process.argv.slice(2).length) { 66 | commander.help(); 67 | } 68 | -------------------------------------------------------------------------------- /src/cli/cli-server.js: -------------------------------------------------------------------------------- 1 | const commander = require('commander'); 2 | 3 | const log = require('../logger'); 4 | const Server = require('../api/Server'); 5 | 6 | let action; 7 | 8 | commander 9 | .arguments('') 10 | .action((actionArg) => { 11 | action = actionArg; 12 | }) 13 | .parse(process.argv); 14 | 15 | if (!process.argv.slice(2).length) { 16 | commander.help(); 17 | } 18 | 19 | if (action !== 'start') { 20 | log.error('Action does not exists', { action }); 21 | process.exit(1); 22 | } else { 23 | log.info('Running api server'); 24 | 25 | Server.start(); 26 | } 27 | -------------------------------------------------------------------------------- /src/cli/cli-suite.js: -------------------------------------------------------------------------------- 1 | const commander = require('commander'); 2 | 3 | process.env.PRETTY_LOG = 'true'; 4 | const config = require('../config'); 5 | const utils = require('../utils'); 6 | const suite = require('./suite'); 7 | 8 | config.artifactsGroupByCheckName = true; 9 | 10 | utils.logUnhandledRejections(true); 11 | 12 | let suiteName; 13 | 14 | function parseNumberArg(arg) { 15 | const split = parseInt(arg, 10); 16 | if (split > 0) { 17 | return split; 18 | } 19 | return 1; 20 | } 21 | 22 | commander 23 | .arguments('') 24 | .option('--redis', 'use redis queue') 25 | .option('--no-shorten', 'disable successful reports shortening') 26 | .option('--hide-actions', 'hide actions from reports (default: false)') 27 | .option('--split ', 'split the scenario into N parts', parseNumberArg, 1) 28 | .option('--part ', 'run checks for part N only', parseNumberArg, 1) 29 | .action((name) => { 30 | suiteName = name; 31 | }) 32 | .parse(process.argv); 33 | 34 | const { redis, shorten, split, part } = commander.opts(); 35 | let { hideActions } = commander.opts(); 36 | hideActions = hideActions !== undefined; 37 | 38 | // Workaround for tests coverage 39 | suite.run(suiteName, !!redis, { 40 | report: { 41 | checkOptions: { 42 | shorten, 43 | hideActions, 44 | }, 45 | }, 46 | run: { split, part }, 47 | }); 48 | -------------------------------------------------------------------------------- /src/cli/cli-worker.js: -------------------------------------------------------------------------------- 1 | const commander = require('commander'); 2 | const Sentry = require('@sentry/node'); 3 | const Redis = require('ioredis'); 4 | 5 | const log = require('../logger'); 6 | const config = require('../config'); 7 | const utils = require('../utils'); 8 | const metrics = require('../metrics/metrics'); 9 | const RedisQueue = require('../queue/RedisQueue'); 10 | const RedisQueueWorker = require('../queue/RedisQueueWorker'); 11 | const CheckRunner = require('../check/runner'); 12 | 13 | Sentry.init({ 14 | dsn: config.sentryDSN, 15 | environment: config.sentryEnvironment, 16 | release: config.sentryRelease, 17 | debug: config.sentryDebug, 18 | attachStacktrace: config.sentryAttachStacktrace, 19 | }); 20 | utils.logUnhandledRejections(); 21 | 22 | function checkProcessor(job, done) { 23 | const checksQueue = new RedisQueue(config.checksQueueName); 24 | 25 | const checkInfo = { 26 | name: job.data.name, 27 | schedule: job.data.scheduleName, 28 | id: job.id, 29 | }; 30 | 31 | log.info('Check running', checkInfo); 32 | 33 | /** 34 | * 35 | * @param {InstanceType} report Report instance 36 | */ 37 | async function saveReport(report) { 38 | if (job.data.scheduleInterval > 0) { 39 | const redis = new Redis({ 40 | port: config.redisPort, 41 | host: config.redisHost, 42 | password: config.redisPassword, 43 | }); 44 | 45 | const checkIdentifier = `${report.scheduleName}:${report.name}`; 46 | 47 | const oldReport = JSON.parse( 48 | await redis.get(`purr:reports:checks:${checkIdentifier}`) 49 | ); 50 | 51 | const executionTime = 52 | (new Date(report.endDateTime).getTime() - 53 | new Date(report.startDateTime).getTime()) / 54 | 1000; 55 | 56 | let waitTime = 0; 57 | if (oldReport !== null) { 58 | waitTime = 59 | (new Date(report.startDateTime).getTime() - 60 | new Date(oldReport.endDateTime).getTime()) / 61 | 1000; 62 | } 63 | 64 | let checksStatusCount = metrics.names.checksSuccessfulTotal; 65 | if (!report.success) { 66 | checksStatusCount = metrics.names.checksFailedTotal; 67 | } 68 | 69 | await redis 70 | .multi() 71 | .incr(`${metrics.redisKeyPrefix}:${checksStatusCount}`) 72 | .set( 73 | `purr:reports:checks:${checkIdentifier}`, 74 | JSON.stringify(report), 75 | 'ex', 76 | (job.data.scheduleInterval / 1000) * 2 77 | ) 78 | .set( 79 | `${metrics.redisKeyPrefix}:${metrics.names.checkDurationSeconds}:${checkIdentifier}`, 80 | executionTime 81 | ) 82 | .set( 83 | `${metrics.redisKeyPrefix}:${metrics.names.checkWaitTimeSeconds}:${checkIdentifier}`, 84 | waitTime 85 | ) 86 | .exec() 87 | .catch((err) => { 88 | log.error('Can not write report to redis: ', err); 89 | }) 90 | .finally(() => { 91 | redis.quit(); 92 | }); 93 | } 94 | } 95 | 96 | Sentry.setExtra('job', job); 97 | Sentry.setTags({ 98 | checkName: job.data.name, 99 | scheduleName: job.data.scheduleName, 100 | }); 101 | 102 | return new CheckRunner(checksQueue) 103 | .doCheck( 104 | job.data.name, 105 | job.id, 106 | job.data.params, 107 | job.data.scheduleName, 108 | job.data.labels, 109 | job.data.proxy, 110 | job.data.allowedCookies 111 | ) 112 | .then(async (result) => { 113 | log.info('Check complete', checkInfo); 114 | 115 | await saveReport(result); 116 | 117 | done(null, result); 118 | }) 119 | .catch(async (result) => { 120 | log.info('Check failed', checkInfo); 121 | 122 | if (result instanceof Error) { 123 | done(result); 124 | return; 125 | } 126 | 127 | await saveReport(result); 128 | 129 | done(null, result); 130 | }) 131 | .finally(() => { 132 | Sentry.setExtra('job', undefined); 133 | Sentry.setTags({ 134 | checkName: undefined, 135 | scheduleName: undefined, 136 | }); 137 | 138 | checksQueue.close(); 139 | }); 140 | } 141 | 142 | function createWorker(queueName, concurrency, queueProcessor) { 143 | return () => { 144 | const queueWorker = new RedisQueueWorker( 145 | queueName, 146 | concurrency, 147 | queueProcessor 148 | ); 149 | 150 | process.on('SIGINT', async () => { 151 | log.info('Caught SIGINT. Trying to perform a graceful shutdown...'); 152 | await queueWorker.stop(); 153 | }); 154 | process.on('SIGTERM', async () => { 155 | log.info('Caught SIGTERM. Trying to perform a graceful shutdown...'); 156 | await queueWorker.stop(); 157 | }); 158 | 159 | log.info('Running queue worker', { queue: queueName }); 160 | 161 | return queueWorker.start().catch((err) => { 162 | Sentry.captureException(err); 163 | log.error('Worker start failed: ', err); 164 | process.exit(1); 165 | }); 166 | }; 167 | } 168 | 169 | commander 170 | .command('check') 171 | .description('Run checks processing worker') 172 | .action( 173 | createWorker(config.checksQueueName, config.concurrency, checkProcessor) 174 | ); 175 | 176 | commander.command('*', { isDefault: true, noHelp: true }).action(async () => { 177 | log.error('Worker with specified name does not exist', { 178 | name: commander.args[0], 179 | }); 180 | process.exit(1); 181 | }); 182 | 183 | commander.parse(process.argv); 184 | 185 | if (!process.argv.slice(2).length) { 186 | commander.help(); 187 | } 188 | -------------------------------------------------------------------------------- /src/cli/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const commander = require('commander'); 3 | 4 | const log = require('../logger'); 5 | const utils = require('../utils'); 6 | const config = require('../config'); 7 | 8 | log.info('Request blocking is enabled for the following URLs:', { 9 | urls: config.blockedResourceDomains, 10 | }); 11 | 12 | utils.throwUnhandledRejections(); 13 | 14 | commander 15 | .version('0.1.0') 16 | .command('check ', 'Run check') 17 | .command('suite ', 'Run suite') 18 | .command('worker ', 'Run worker') 19 | .command('server ', 'Server controls') 20 | .command('schedule ', 'Schedules controls') 21 | .command('queue ', 'Queue controls') 22 | .parse(process.argv); 23 | -------------------------------------------------------------------------------- /src/cli/suite.js: -------------------------------------------------------------------------------- 1 | const log = require('../logger'); 2 | const config = require('../config'); 3 | const SuiteRunner = require('../suite/runner'); 4 | const { processReport } = require('../report/suite'); 5 | const RedisQueue = require('../queue/RedisQueue'); 6 | 7 | /** 8 | * @param {string} name Suite name 9 | * @param {boolean} useRedis Use redis queue 10 | * @param {object} options Suite options 11 | * @param {import('../report/suite').SuiteReportViewOptions} options.report Report options 12 | * @param {import('../suite/runner').SuiteRunOptions} options.run Run options 13 | * @returns {object} 14 | */ 15 | function run(name, useRedis, options) { 16 | let suiteRunner; 17 | let queue; 18 | 19 | if (useRedis) { 20 | log.info('Run with redis'); 21 | queue = new RedisQueue(config.checksQueueName); 22 | suiteRunner = new SuiteRunner(queue); 23 | } else { 24 | suiteRunner = new SuiteRunner(); 25 | } 26 | 27 | log.info('Running suite', { name }); 28 | 29 | return suiteRunner 30 | .run(name, options.run) 31 | .then((result) => { 32 | const prepared = processReport(result, options.report); 33 | 34 | if (!result.success) { 35 | log.error('Suite failed', { report: prepared }); 36 | process.exit(1); 37 | } 38 | 39 | log.info('Suite success', { report: prepared }); 40 | }) 41 | .catch((err) => { 42 | log.error('Suite failed', { report: err }); 43 | process.exit(1); 44 | }) 45 | .finally(() => { 46 | if (queue) { 47 | queue.close(); 48 | } 49 | }); 50 | } 51 | 52 | module.exports = { run }; 53 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | const { EnvParams } = require('./config/env'); 2 | const { Configuration } = require('./config/configuration'); 3 | 4 | const envParams = new EnvParams(); 5 | const config = new Configuration(envParams, __dirname); 6 | 7 | module.exports = config; 8 | -------------------------------------------------------------------------------- /src/config/__tests__/configuration.test.js: -------------------------------------------------------------------------------- 1 | const { EnvParams } = require('../env'); 2 | const { Configuration } = require('../configuration'); 3 | 4 | describe('Test Configuration class', () => { 5 | it('Should initialize with default configuration', () => { 6 | const envParams = new EnvParams(); 7 | const rootDir = '/rootDir/appDir'; 8 | const configuration = new Configuration(envParams, rootDir); 9 | 10 | const defaultConfiguration = { 11 | actionRetryCount: 5, 12 | actionRetryErrors: ['ERR_CONNECTION_RESET', 'ERR_CONNECTION_CLOSED'], 13 | actionRetryTimeout: 1000, 14 | apiUrlPrefix: '/api/v1', 15 | apiWaitTimeout: 27000, 16 | artifactsDir: '/rootDir/storage', 17 | artifactsGroupByCheckName: false, 18 | artifactsKeepSuccessful: true, 19 | artifactsTempDir: '/rootDir/storage_tmp', 20 | blockedResourceDomains: [], 21 | browserDumpIO: false, 22 | browserHeadless: true, 23 | browserProtocolTimeout: 180000, 24 | checksDir: '/rootDir/data/checks', 25 | checksQueueName: 'checks-queue', 26 | chromiumLaunchArgs: [], 27 | chromiumRemoteDebugging: false, 28 | chromiumRemoteDebuggingAddress: '127.0.0.1', 29 | chromiumRemoteDebuggingPort: 9222, 30 | concurrency: 4, 31 | consoleLog: true, 32 | consoleLogDir: '/rootDir/storage/console_log', 33 | cookieTracking: false, 34 | cookieTrackingHideValue: true, 35 | defaultPriorityLabel: 'p3', 36 | defaultProductLabel: '', 37 | defaultTeamLabel: 'sre', 38 | defaultAppNameLabel: '', 39 | defaultAppLinkLabel: '', 40 | defaultSlackChannelLabel: '', 41 | envVarParamPrefix: 'PURR_PARAM_', 42 | hars: false, 43 | harsDir: '/rootDir/storage/hars', 44 | harsTempDir: '/rootDir/storage_tmp/hars', 45 | latestFailedReports: true, 46 | logLevel: 'info', 47 | navigationTimeout: 30000, 48 | parametersInfoFilePath: '/rootDir/data/parameters.yml', 49 | redisHost: 'redis', 50 | redisJobTTL: 60000, 51 | redisPassword: '', 52 | redisPort: 6379, 53 | reports: true, 54 | reportsDir: '/rootDir/storage/reports', 55 | schedulesFilePath: '/rootDir/data/schedules.yml', 56 | screenshots: true, 57 | screenshotsDir: '/rootDir/storage/screenshots', 58 | sentryAttachStacktrace: false, 59 | sentryDSN: '', 60 | sentryDebug: false, 61 | sentryEnvironment: 'production', 62 | sentryRelease: '', 63 | suitesDir: '/rootDir/data/suites', 64 | traces: true, 65 | tracesDir: '/rootDir/storage/traces', 66 | tracesTempDir: '/rootDir/storage_tmp/traces', 67 | userAgent: 'uptime-agent', 68 | windowHeight: 1080, 69 | windowWidth: 1920, 70 | }; 71 | 72 | expect(configuration).toEqual(defaultConfiguration); 73 | }); 74 | 75 | it('Should process blocked domains list', () => { 76 | const envParams = new EnvParams(); 77 | envParams.BLOCKED_RESOURCE_DOMAINS = 'example.com, SEMRUSH.COM , localHost'; 78 | const rootDir = '/rootDir/src'; 79 | 80 | const config = new Configuration(envParams, rootDir); 81 | 82 | const expectedBlockedResourceDomains = [ 83 | 'example.com', 84 | 'semrush.com', 85 | 'localhost', 86 | ]; 87 | 88 | expect(config.blockedResourceDomains).toEqual( 89 | expectedBlockedResourceDomains 90 | ); 91 | }); 92 | 93 | it('Should process chromiumLaunchArgs', () => { 94 | const envParams = new EnvParams(); 95 | envParams.CHROMIUM_LAUNCH_ARGS = 96 | 'argument1, useBigScreen, useragent=sometext'; 97 | 98 | const config = new Configuration(envParams, '/rootDir'); 99 | 100 | const expectedChromiumLaunchArgs = [ 101 | 'argument1', 102 | 'useBigScreen', 103 | 'useragent=sometext', 104 | ]; 105 | 106 | expect(config.chromiumLaunchArgs).toEqual(expectedChromiumLaunchArgs); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /src/config/__tests__/env.test.js: -------------------------------------------------------------------------------- 1 | const { EnvParams } = require('../env'); 2 | 3 | describe('Test EnvParams', () => { 4 | it('Should return empty object if no keys given in env', () => { 5 | const config = new EnvParams(); 6 | expect(Object.keys(config)).toHaveLength(0); 7 | }); 8 | 9 | it('Should skip unknown keys', () => { 10 | process.env[`${EnvParams.PREFIX}NO_SUCH_KEY`] = '123'; 11 | const config = new EnvParams(); 12 | expect(config.NO_SUCH_KEY).toBeFalsy(); 13 | expect(Object.keys(config)).toHaveLength(0); 14 | }); 15 | 16 | it('Should add known keys', () => { 17 | process.env[`${EnvParams.PREFIX}SCREENSHOTS`] = 'true'; 18 | process.env[`${EnvParams.PREFIX}NAVIGATION_TIMEOUT`] = '10'; 19 | const config = new EnvParams(); 20 | expect(config.SCREENSHOTS).toEqual('true'); 21 | expect(config.NAVIGATION_TIMEOUT).toEqual('10'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/config/configuration.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const isUndefined = require('lodash.isundefined'); 3 | const utils = require('../utils'); 4 | 5 | /** 6 | * Returns defaultValue if value is undefined or NaN 7 | * @param {any} value 8 | * @param {any} defaultValue 9 | */ 10 | function getDefault(value, defaultValue = utils.mandatory('default')) { 11 | if (value === undefined || Number.isNaN(value)) { 12 | return defaultValue; 13 | } 14 | return value; 15 | } 16 | 17 | class Configuration { 18 | constructor(envParams, rootDir) { 19 | const dataDir = getDefault( 20 | envParams.DATA_DIR, 21 | path.resolve(rootDir, '../data') 22 | ); 23 | 24 | const reportsDirName = 'reports'; 25 | const screenshotsDirName = 'screenshots'; 26 | const tracesDirName = 'traces'; 27 | const harsDirName = 'hars'; 28 | const consoleLogDirName = 'console_log'; 29 | 30 | this.concurrency = getDefault(parseInt(envParams.CONCURRENCY, 10), 4); 31 | this.checksQueueName = getDefault( 32 | envParams.CHECKS_QUEUE_NAME, 33 | 'checks-queue' 34 | ); 35 | this.checksDir = path.resolve(dataDir, 'checks'); 36 | this.suitesDir = path.resolve(dataDir, 'suites'); 37 | 38 | this.parametersInfoFilePath = getDefault( 39 | envParams.PARAMETERS_INFO_FILE_PATH, 40 | path.resolve(dataDir, 'parameters.yml') 41 | ); 42 | 43 | this.schedulesFilePath = getDefault( 44 | envParams.SCHEDULES_FILE_PATH, 45 | path.resolve(dataDir, 'schedules.yml') 46 | ); 47 | 48 | this.defaultTeamLabel = 'sre'; 49 | this.defaultProductLabel = ''; 50 | this.defaultPriorityLabel = 'p3'; 51 | this.defaultAppNameLabel = ''; 52 | this.defaultAppLinkLabel = ''; 53 | this.defaultSlackChannelLabel = ''; 54 | 55 | this.artifactsKeepSuccessful = getDefault( 56 | envParams.ARTIFACTS_KEEP_SUCCESSFUL !== 'false', 57 | true 58 | ); 59 | 60 | this.artifactsGroupByCheckName = false; 61 | this.artifactsDir = getDefault( 62 | envParams.ARTIFACTS_DIR, 63 | path.resolve(rootDir, '../storage') 64 | ); 65 | this.artifactsTempDir = getDefault( 66 | envParams.ARTIFACTS_TEMP_DIR, 67 | path.resolve(rootDir, '../storage_tmp') 68 | ); 69 | 70 | this.reports = getDefault(envParams.REPORTS !== 'false', true); 71 | this.latestFailedReports = getDefault( 72 | envParams.LATEST_FAILED_REPORTS !== 'false', 73 | true 74 | ); 75 | this.reportsDir = getDefault( 76 | envParams.REPORTS_DIR, 77 | path.resolve(this.artifactsDir, reportsDirName) 78 | ); 79 | 80 | this.screenshots = getDefault(envParams.SCREENSHOTS !== 'false', true); 81 | this.screenshotsDir = getDefault( 82 | envParams.SCREENSHOTS_DIR, 83 | path.resolve(this.artifactsDir, screenshotsDirName) 84 | ); 85 | 86 | this.traces = getDefault(envParams.TRACES !== 'false', true); 87 | this.tracesDir = getDefault( 88 | envParams.TRACES_DIR, 89 | path.resolve(this.artifactsDir, tracesDirName) 90 | ); 91 | this.tracesTempDir = path.resolve(this.artifactsTempDir, tracesDirName); 92 | 93 | this.hars = getDefault(envParams.HARS === 'true', false); 94 | this.harsDir = getDefault( 95 | envParams.HARS_DIR, 96 | path.resolve(this.artifactsDir, harsDirName) 97 | ); 98 | 99 | this.harsTempDir = path.resolve(this.artifactsTempDir, harsDirName); 100 | 101 | this.consoleLog = getDefault(envParams.CONSOLE_LOG !== 'false', true); 102 | this.consoleLogDir = getDefault( 103 | envParams.CONSOLE_LOG_DIR, 104 | path.resolve(this.artifactsDir, consoleLogDirName) 105 | ); 106 | 107 | this.envVarParamPrefix = getDefault( 108 | envParams.ENV_VAR_PARAM_PREFIX, 109 | 'PURR_PARAM_' 110 | ); 111 | 112 | this.windowWidth = getDefault(parseInt(envParams.WINDOW_WIDTH, 10), 1920); 113 | this.windowHeight = getDefault(parseInt(envParams.WINDOW_HEIGHT, 10), 1080); 114 | this.navigationTimeout = getDefault( 115 | parseInt(envParams.NAVIGATION_TIMEOUT, 10), 116 | 30000 117 | ); 118 | this.userAgent = getDefault(envParams.USER_AGENT, 'uptime-agent'); 119 | 120 | this.redisHost = getDefault(envParams.REDIS_HOST, 'redis'); 121 | this.redisPort = getDefault(parseInt(envParams.REDIS_PORT, 10), 6379); 122 | this.redisPassword = getDefault(envParams.REDIS_PASSWORD, ''); 123 | // TODO: test this 124 | this.redisJobTTL = getDefault(parseInt(envParams.REDIS_JOB_TTL, 10), 60000); 125 | 126 | this.apiUrlPrefix = '/api/v1'; 127 | this.apiWaitTimeout = getDefault( 128 | parseInt(envParams.API_WAIT_TIMEOUT, 10), 129 | 27000 130 | ); 131 | 132 | this.logLevel = getDefault(envParams.LOG_LEVEL, 'info'); 133 | 134 | this.sentryDSN = getDefault(envParams.SENTRY_DSN, ''); 135 | this.sentryEnvironment = getDefault( 136 | envParams.SENTRY_ENVIRONMENT, 137 | 'production' 138 | ); 139 | this.sentryRelease = getDefault(envParams.SENTRY_RELEASE, ''); 140 | this.sentryDebug = getDefault(envParams.SENTRY_DEBUG === 'true', false); 141 | this.sentryAttachStacktrace = getDefault( 142 | envParams.SENTRY_ATTACH_STACKTRACE === 'true', 143 | false 144 | ); 145 | 146 | this.blockedResourceDomains = getDefault( 147 | envParams.BLOCKED_RESOURCE_DOMAINS, 148 | '' 149 | ) 150 | .split(',') 151 | .map((domain) => domain.toLowerCase().trim()) 152 | .filter((domain) => domain.length > 0); 153 | 154 | /** 155 | * Additional arguments to pass to the browser instance 156 | * @type String[] 157 | */ 158 | this.chromiumLaunchArgs = getDefault(envParams.CHROMIUM_LAUNCH_ARGS, '') 159 | .split(',') 160 | .map((arg) => arg.trim()) 161 | .filter((arg) => arg.length > 0); 162 | 163 | this.chromiumRemoteDebugging = getDefault( 164 | envParams.CHROMIUM_REMOTE_DEBUGGING === 'true', 165 | false 166 | ); 167 | 168 | this.chromiumRemoteDebuggingAddress = getDefault( 169 | envParams.CHROMIUM_REMOTE_DEBUGGING_ADDRESS, 170 | '127.0.0.1' 171 | ); 172 | 173 | this.chromiumRemoteDebuggingPort = getDefault( 174 | parseInt(envParams.CHROMIUM_REMOTE_DEBUGGING_PORT, 10), 175 | 9222 176 | ); 177 | 178 | this.cookieTracking = getDefault( 179 | envParams.COOKIE_TRACKING === 'true', 180 | false 181 | ); 182 | 183 | this.cookieTrackingHideValue = getDefault( 184 | envParams.COOKIE_TRACKING_HIDE_VALUE !== 'false', 185 | true 186 | ); 187 | 188 | this.browserHeadless = getDefault( 189 | envParams.BROWSER_HEADLESS !== 'false', 190 | true 191 | ); 192 | 193 | this.browserDumpIO = getDefault( 194 | envParams.BROWSER_DUMP_IO === 'true', 195 | false 196 | ); 197 | 198 | this.browserProtocolTimeout = getDefault( 199 | envParams.BROWSER_PROTOCOL_TIMEOUT, 200 | 180000 201 | ); 202 | 203 | this.actionRetryErrors = getDefault( 204 | envParams.ACTION_RETRY_ERRORS, 205 | 'ERR_CONNECTION_RESET,ERR_CONNECTION_CLOSED' 206 | ) 207 | .split(',') 208 | .map((msg) => msg.trim()) 209 | .filter((msg) => msg.length > 0); 210 | 211 | this.actionRetryCount = getDefault(envParams.ACTION_RETRY_COUNT, 5); 212 | 213 | this.actionRetryTimeout = getDefault(envParams.ACTION_RETRY_TIMEOUT, 1000); 214 | 215 | if (this.artifactsGroupByCheckName && isUndefined(this.artifactsDir)) { 216 | throw new Error( 217 | 'Enabled group artifacts by check name and artifacts path not specified' 218 | ); 219 | } 220 | 221 | if (!this.artifactsGroupByCheckName && isUndefined(this.tracesDir)) { 222 | throw new Error('Traces enabled but storage path not specified'); 223 | } 224 | 225 | if (!this.artifactsGroupByCheckName && isUndefined(this.harsDir)) { 226 | throw new Error('HARs enabled but storage path not specified'); 227 | } 228 | 229 | if (!this.artifactsGroupByCheckName && isUndefined(this.screenshotsDir)) { 230 | throw new Error('Screenshots enabled but storage path not specified'); 231 | } 232 | 233 | if (!this.artifactsGroupByCheckName && isUndefined(this.consoleLogDir)) { 234 | throw new Error('Console logging enabled but storage path not specified'); 235 | } 236 | 237 | if (!this.artifactsGroupByCheckName && isUndefined(this.reportsDir)) { 238 | throw new Error('Reports enabled but storage path not specified'); 239 | } 240 | } 241 | } 242 | 243 | module.exports = { 244 | Configuration, 245 | }; 246 | -------------------------------------------------------------------------------- /src/config/env.js: -------------------------------------------------------------------------------- 1 | /** 2 | * File contains list of configurable parameters from ENV variables 3 | * matching `PURR_CONFIG_*` pattern. 4 | * 5 | * Please, not that real variable values will be calculated in `../config.js` 6 | * file and default values given here as example for better understanding 7 | * of application behaviour. 8 | */ 9 | 10 | const DEFAULT_ENV_PARAMS = { 11 | /** 12 | * Location of checks, suites, schedules and parameters for tests. 13 | * @type {string} 14 | */ 15 | DATA_DIR: '../data', 16 | 17 | /** 18 | * Location, where to store results of runs 19 | * @type {string} 20 | */ 21 | ARTIFACTS_DIR: '../storage', 22 | 23 | /** 24 | * Location of temporary storage for results 25 | * @type {string} 26 | */ 27 | ARTIFACTS_TEMP_DIR: '../storage_tmp', 28 | 29 | /** 30 | * Amount of concurrent processes when performing checks 31 | * @type {number} 32 | */ 33 | CONCURRENCY: 4, 34 | 35 | /** 36 | * ? 37 | * @type {string} 38 | */ 39 | CHECKS_QUEUE_NAME: 'checks-queue', 40 | 41 | /** 42 | * Name of file with parameters, absolute 43 | * @type {string} 44 | * @default "../data/parameters.yml" 45 | */ 46 | PARAMETERS_INFO_FILE_PATH: 'parameters.yml', 47 | 48 | /** 49 | * Name of file with scheduled checks 50 | * @type {string} 51 | * @default "../data/schedules.yml" 52 | */ 53 | SCHEDULES_FILE_PATH: 'schedules.yml', 54 | 55 | /** 56 | * Whether to keep artifacts for successful checks 57 | * @type {boolean} 58 | * @default {true} 59 | */ 60 | ARTIFACTS_KEEP_SUCCESSFUL: true, 61 | 62 | /** 63 | * Whether to generate test runs reports 64 | * @type {boolean} 65 | * @default {true} 66 | */ 67 | REPORTS: true, 68 | 69 | /** 70 | * Whether to generate test runs reports for failed checks (save only latest report) 71 | * @type {boolean} 72 | * @default {true} 73 | */ 74 | LATEST_FAILED_REPORTS: true, 75 | 76 | /** 77 | * Where to store tests reports. 78 | * @type {string} 79 | * @default "../storage/reports" 80 | */ 81 | REPORTS_DIR: '../storage/reports', 82 | 83 | /** 84 | * Whether to save screenshots for tests runs 85 | * @type {boolean} 86 | * @default {true} 87 | */ 88 | SCREENSHOTS: true, 89 | 90 | /** 91 | * Where to save screenshots 92 | * @type {string} 93 | * @default "../storage/screenshots" 94 | */ 95 | SCREENSHOTS_DIR: '../storage/screenshots', 96 | 97 | /** 98 | * Whether to store run traces 99 | * @type {boolean} 100 | * @default {true} 101 | */ 102 | TRACES: true, 103 | 104 | /** 105 | * Where to store traces 106 | * @type {string} 107 | * @default "../storage/traces" 108 | */ 109 | TRACES_DIR: '../storage/traces', 110 | 111 | /** 112 | * Whether to store HAR files for checks 113 | * @type {boolean} 114 | * @default {false} 115 | */ 116 | HARS: false, 117 | 118 | /** 119 | * Where to store HAR files 120 | * @type {string} 121 | * @default "../storage/hars" 122 | */ 123 | HARS_DIR: '../storage/hars', 124 | 125 | /** 126 | * Whether to store console logs from checks 127 | * @type {boolean} 128 | * @default {true} 129 | */ 130 | CONSOLE_LOG: true, 131 | 132 | /** 133 | * Where to store console log files 134 | * @type {string} 135 | * @default "../storage/console_log" 136 | */ 137 | CONSOLE_LOG_DIR: '../storage/console_log', 138 | 139 | /** 140 | * Prefix for PARAMS when configuring them from env variables. 141 | * @type {string} 142 | * @default "PURR_PARAM_" 143 | */ 144 | ENV_VAR_PARAM_PREFIX: 'PURR_PARAM_', 145 | 146 | /** 147 | * Default width of window for tests 148 | * @type {number} 149 | * @default 1920 150 | */ 151 | WINDOW_WIDTH: 1920, 152 | 153 | /** 154 | * Default height of window for tests 155 | * @type {number} 156 | * @default 1080 157 | */ 158 | WINDOW_HEIGHT: 1080, 159 | 160 | /** 161 | * Default timeout for navigation in milliseconds 162 | * @type {number} 163 | * @default 30000 164 | */ 165 | NAVIGATION_TIMEOUT: 30000, 166 | 167 | /** 168 | * Default user agent for requests from Puppeteer 169 | * @type {string} 170 | * @default "uptime-agent" 171 | */ 172 | USER_AGENT: 'uptime-agent', 173 | 174 | REDIS_HOST: 'redis', 175 | REDIS_PORT: 6379, 176 | REDIS_PASSWORD: '', 177 | REDIS_JOB_TTL: 60000, 178 | 179 | API_WAIT_TIMEOUT: 27000, 180 | LOG_LEVEL: 'info', 181 | SENTRY_DSN: '', 182 | SENTRY_ENVIRONMENT: 'production', 183 | SENTRY_RELEASE: '', 184 | SENTRY_DEBUG: false, 185 | SENTRY_ATTACH_STACKTRACE: false, 186 | 187 | /** 188 | * List of domains to block in Puppeteer. One should 189 | * use comma-separated string. For example: `google.com,facebook.com` 190 | */ 191 | BLOCKED_RESOURCE_DOMAINS: '', 192 | 193 | CHROMIUM_LAUNCH_ARGS: '', 194 | CHROMIUM_REMOTE_DEBUGGING: false, 195 | CHROMIUM_REMOTE_DEBUGGING_ADDRESS: '127.0.0.1', 196 | CHROMIUM_REMOTE_DEBUGGING_PORT: 9222, 197 | 198 | /** 199 | * Whether to add cookies from checks to action reports. 200 | * @type {boolean} 201 | * @default {false} 202 | */ 203 | COOKIE_TRACKING: false, 204 | 205 | /** 206 | * Whether to hide cookies values from checks to action reports. 207 | * Available only if COOKIE_TRACKING is true. 208 | * @type {boolean} 209 | * @default {true} 210 | */ 211 | COOKIE_TRACKING_HIDE_VALUE: true, 212 | 213 | /** 214 | * Enable/disable headless browser 215 | * 216 | * @type {boolean} 217 | * @default {true} 218 | */ 219 | BROWSER_HEADLESS: true, 220 | 221 | /** 222 | * Enable/disable dump io from browser 223 | * 224 | * @type {boolean} 225 | * @default {true} 226 | */ 227 | BROWSER_DUMP_IO: false, 228 | 229 | /** 230 | * Browser protocol timeout 231 | * 232 | * @type {number} 233 | * @default {180000} 234 | */ 235 | BROWSER_PROTOCOL_TIMEOUT: 180000, 236 | 237 | /** 238 | * Check action retry comma-separated errors list (checked by err.message.includes()) 239 | * 240 | * @type string 241 | * @default ERR_CONNECTION_RESET,ERR_CONNECTION_CLOSED 242 | */ 243 | ACTION_RETRY_ERRORS: 'ERR_CONNECTION_RESET,ERR_CONNECTION_CLOSED', 244 | 245 | /** 246 | * Check action retry count 247 | * 248 | * @type number 249 | * @default 5 250 | */ 251 | ACTION_RETRY_COUNT: 5, 252 | 253 | /** 254 | * Check action retry timeout (ms) 255 | * 256 | * @type number 257 | * @default 1000 258 | */ 259 | ACTION_RETRY_TIMEOUT: 1000, 260 | }; 261 | 262 | class EnvParams { 263 | constructor() { 264 | Object.keys(DEFAULT_ENV_PARAMS).forEach((key) => { 265 | const envVariableName = `${EnvParams.PREFIX}${key}`; 266 | if (Object.prototype.hasOwnProperty.call(process.env, envVariableName)) { 267 | this[key] = process.env[envVariableName]; 268 | } 269 | }); 270 | } 271 | } 272 | 273 | EnvParams.PREFIX = 'PURR_CONFIG_'; 274 | 275 | module.exports = { 276 | EnvParams, 277 | }; 278 | -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | 3 | const config = require('./config'); 4 | 5 | const logger = winston.createLogger({ 6 | level: config.logLevel, 7 | format: winston.format.combine( 8 | winston.format.timestamp(), 9 | winston.format.errors({ stack: true }), 10 | winston.format.splat(), 11 | winston.format.metadata(), 12 | winston.format.json() 13 | ), 14 | transports: [ 15 | new winston.transports.Console({ 16 | handleExceptions: true, 17 | // TODO: Doesn't work: https://github.com/winstonjs/winston/issues/1673 18 | // handleRejections: true, 19 | }), 20 | ], 21 | defaultMeta: { service: 'purr' }, 22 | }); 23 | 24 | // TODO: Should there be a better solution? 25 | if (process.env.PRETTY_LOG) { 26 | const cliTransport = new winston.transports.Console({ 27 | handleExceptions: true, 28 | format: winston.format.prettyPrint({ depth: 0 }), 29 | }); 30 | 31 | logger.clear().add(cliTransport); 32 | } 33 | 34 | module.exports = logger; 35 | -------------------------------------------------------------------------------- /src/metrics/metrics.js: -------------------------------------------------------------------------------- 1 | const redisKeyPrefix = 'purr:metrics'; 2 | 3 | const prefix = 'purr_'; 4 | const names = { 5 | checksScheduled: 'checks_scheduled', 6 | checksSuccessfulTotal: 'checks_successful_total', 7 | checksFailedTotal: 'checks_failed_total', 8 | 9 | checkDurationSeconds: 'check_duration_seconds', 10 | checkWaitTimeSeconds: 'check_wait_time_seconds', 11 | checkIntervalSeconds: 'check_interval_seconds', 12 | 13 | reportCheckSuccess: `report_check_success`, 14 | reportCheckForbiddenCookies: `report_check_forbidden_cookies`, 15 | reportCheckStart: `report_check_start_date`, 16 | reportCheckEnd: `report_check_end_date`, 17 | reportCheckLastStep: `report_check_last_step`, 18 | reportCheckCustomMetric: `report_check_custom_metric`, 19 | }; 20 | 21 | /** 22 | * @typedef CustomMetricMandatoryLabels 23 | * @property {string} name 24 | * @property {string} id 25 | */ 26 | 27 | /** 28 | * @typedef CustomMetric 29 | * @property {number} value Metric value 30 | * @property {CustomMetricMandatoryLabels & Record} labels Metric labels 31 | */ 32 | 33 | module.exports = { prefix, names, redisKeyPrefix }; 34 | -------------------------------------------------------------------------------- /src/parameters/ParamParser.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const yaml = require('js-yaml'); 3 | 4 | const config = require('../config'); 5 | const utils = require('../utils'); 6 | 7 | class ParamParser { 8 | constructor() { 9 | this.infoDoc = yaml.load( 10 | fs.readFileSync(config.parametersInfoFilePath, 'utf8') 11 | ); 12 | } 13 | 14 | getGlobalDefaults() { 15 | const params = {}; 16 | Object.entries(this.infoDoc).forEach(([k, v]) => { 17 | params[k] = v.default; 18 | }); 19 | return params; 20 | } 21 | 22 | mergeParams(entityParameters, params = {}) { 23 | return { 24 | // Defaults from params file 25 | ...this.getGlobalDefaults(), 26 | // Defaults from checks file 27 | ...entityParameters, 28 | // Params from envvars 29 | ...utils.getPrefixedEnvVars(config.envVarParamPrefix), 30 | // Params. Just params 31 | ...params, 32 | }; 33 | } 34 | } 35 | 36 | module.exports = ParamParser; 37 | -------------------------------------------------------------------------------- /src/queue/BaseQueue.js: -------------------------------------------------------------------------------- 1 | const utils = require('../utils'); 2 | 3 | /* eslint-disable class-methods-use-this */ 4 | /* eslint-disable no-unused-vars */ 5 | class BaseQueue { 6 | async close() { 7 | throw new Error('Not implemented'); 8 | } 9 | 10 | async add( 11 | name = utils.mandatory('name'), 12 | checkId = utils.mandatory('checkId'), 13 | params = {} 14 | ) { 15 | throw new Error('Not implemented'); 16 | } 17 | } 18 | /* eslint-enable class-methods-use-this */ 19 | /* eslint-enable no-unused-vars */ 20 | 21 | module.exports = BaseQueue; 22 | -------------------------------------------------------------------------------- /src/queue/RedisQueue.js: -------------------------------------------------------------------------------- 1 | const Bull = require('bull'); 2 | 3 | const config = require('../config'); 4 | const utils = require('../utils'); 5 | const BaseQueue = require('./BaseQueue'); 6 | 7 | /** 8 | * 9 | * @extends BaseQueue 10 | */ 11 | class RedisQueue extends BaseQueue { 12 | constructor(name = utils.mandatory('name')) { 13 | super(); 14 | this.name = name; 15 | this.bull = new Bull(this.name, { 16 | redis: { 17 | port: config.redisPort, 18 | host: config.redisHost, 19 | password: config.redisPassword, 20 | }, 21 | }); 22 | } 23 | 24 | async close() { 25 | return this.bull.close(); 26 | } 27 | 28 | async add( 29 | name = utils.mandatory('name'), 30 | checkId = utils.mandatory('checkId'), 31 | params = {}, 32 | repeat = {}, 33 | scheduleName = null, 34 | scheduleInterval = 0, 35 | waitJobFinish = true, 36 | labels = [], 37 | proxy = null, 38 | allowedCookies = [] 39 | ) { 40 | if (typeof name !== 'string') { 41 | throw new Error(`Task name should be 'string', now: '${typeof task}'`); 42 | } 43 | if (typeof params !== 'object') { 44 | throw new Error( 45 | `Task params should be 'object', now: '${typeof params}'` 46 | ); 47 | } 48 | 49 | const jobOptions = { 50 | jobId: checkId, 51 | timeout: config.redisJobTTL, 52 | removeOnComplete: true, 53 | removeOnFail: true, 54 | }; 55 | 56 | if (Object.keys(repeat).length > 0) { 57 | jobOptions.repeat = repeat; 58 | } 59 | 60 | const jobPromise = this.bull.add( 61 | name, 62 | { 63 | name, 64 | checkId, 65 | params: { 66 | ...utils.getPrefixedEnvVars(config.envVarParamPrefix), 67 | ...params, 68 | }, 69 | scheduleName, 70 | scheduleInterval, 71 | labels, 72 | proxy, 73 | allowedCookies, 74 | }, 75 | jobOptions 76 | ); 77 | 78 | if (waitJobFinish !== true) { 79 | return jobPromise; 80 | } 81 | 82 | return jobPromise.then(async (job) => { 83 | const finished = await job.finished(); 84 | return finished; 85 | }); 86 | } 87 | 88 | async getJobCounts() { 89 | return this.bull.getJobCounts(); 90 | } 91 | 92 | async getRepeatableJobs() { 93 | return this.bull.getRepeatableJobs(); 94 | } 95 | 96 | async removeRepeatableByKey(key = utils.mandatory('key')) { 97 | return this.bull.removeRepeatableByKey(key); 98 | } 99 | } 100 | 101 | module.exports = RedisQueue; 102 | -------------------------------------------------------------------------------- /src/queue/RedisQueueWorker.js: -------------------------------------------------------------------------------- 1 | const Bull = require('bull'); 2 | const Sentry = require('@sentry/node'); 3 | 4 | const log = require('../logger'); 5 | const config = require('../config'); 6 | const utils = require('../utils'); 7 | 8 | class RedisQueueWorker { 9 | constructor( 10 | queueName = utils.mandatory('queueName'), 11 | concurrency = utils.mandatory('concurrency'), 12 | processor = utils.mandatory('processor') 13 | ) { 14 | this.queueName = queueName; 15 | this.concurrency = concurrency; 16 | this.processor = processor; 17 | 18 | this.bull = new Bull(this.queueName, { 19 | redis: { 20 | port: config.redisPort, 21 | host: config.redisHost, 22 | password: config.redisPassword, 23 | showFriendlyErrorStack: true, 24 | }, 25 | defaultJobOptions: { 26 | removeOnComplete: true, 27 | removeOnFail: true, 28 | }, 29 | }); 30 | } 31 | 32 | async start() { 33 | try { 34 | await this.bull 35 | .on('error', (err) => { 36 | Sentry.captureException(err); 37 | log.error('Bull: error: ', err); 38 | }) 39 | .on('stalled', (job) => { 40 | const err = new Error('Bull: job is stalled'); 41 | 42 | Sentry.withScope((scope) => { 43 | scope.setExtra('job', job); 44 | scope.setTags({ 45 | checkName: job.data.name, 46 | scheduleName: job.data.scheduleName, 47 | }); 48 | Sentry.captureException(err); 49 | }); 50 | 51 | log.warn(err); 52 | }) 53 | .on('failed', (job, err) => { 54 | Sentry.withScope((scope) => { 55 | scope.setExtra('job', job); 56 | scope.setTags({ 57 | checkName: job.data.name, 58 | scheduleName: job.data.scheduleName, 59 | }); 60 | Sentry.captureException(err); 61 | }); 62 | 63 | log.error('Bull: job failed: ', err); 64 | }) 65 | .process('*', this.concurrency, this.processor); 66 | } catch (err) { 67 | throw utils.enrichError(err, `Bull: can not register processor`); 68 | } 69 | } 70 | 71 | async stop() { 72 | return this.bull.close(); 73 | } 74 | } 75 | 76 | module.exports = RedisQueueWorker; 77 | -------------------------------------------------------------------------------- /src/queue/SimpleQueue.js: -------------------------------------------------------------------------------- 1 | const config = require('../config'); 2 | const utils = require('../utils'); 3 | const BaseQueue = require('./BaseQueue'); 4 | const CheckRunner = require('../check/runner'); 5 | 6 | /** 7 | * 8 | * @extends BaseQueue 9 | */ 10 | class SimpleQueue extends BaseQueue { 11 | constructor() { 12 | super(); 13 | this.jobsRunning = 0; 14 | } 15 | 16 | async waitForQueue() { 17 | while (this.jobsRunning >= config.concurrency) { 18 | // eslint-disable-next-line no-await-in-loop 19 | await utils.sleep(1000); 20 | } 21 | this.jobsRunning += 1; 22 | } 23 | 24 | freeQueue() { 25 | this.jobsRunning -= 1; 26 | } 27 | 28 | async close() { 29 | return this; 30 | } 31 | 32 | async add( 33 | name = utils.mandatory('name'), 34 | checkId = utils.mandatory('checkId'), 35 | params = {}, 36 | // eslint-disable-next-line no-unused-vars 37 | repeat = {}, 38 | scheduleName = null, 39 | // eslint-disable-next-line no-unused-vars 40 | scheduleInterval = 0, 41 | // eslint-disable-next-line no-unused-vars 42 | waitJobFinish = true, 43 | labels = [], 44 | proxy = null, 45 | allowedCookies = [] 46 | ) { 47 | if (typeof name !== 'string') { 48 | throw new Error(`Task name should be 'string', now: '${typeof task}'`); 49 | } 50 | if (typeof params !== 'object') { 51 | throw new Error( 52 | `Task params should be 'object', now: '${typeof params}'` 53 | ); 54 | } 55 | 56 | return this.waitForQueue() 57 | .then(async () => { 58 | return new CheckRunner(this) 59 | .doCheck( 60 | name, 61 | checkId, 62 | params, 63 | scheduleName, 64 | labels, 65 | proxy, 66 | allowedCookies 67 | ) 68 | .then((result) => result) 69 | .catch((result) => result); 70 | }) 71 | .then((taskResult) => { 72 | return taskResult; 73 | }) 74 | .finally(() => { 75 | this.freeQueue(); 76 | }); 77 | } 78 | } 79 | 80 | module.exports = SimpleQueue; 81 | -------------------------------------------------------------------------------- /src/queue/__mocks__/RedisQueueWorker.js: -------------------------------------------------------------------------------- 1 | const utils = require('../../utils'); 2 | 3 | class RedisQueueWorker { 4 | constructor( 5 | concurrency = utils.mandatory('concurrency'), 6 | processor = utils.mandatory('processor') 7 | ) { 8 | this.concurrency = concurrency; 9 | this.processor = processor; 10 | this.bull = {}; 11 | } 12 | 13 | async start() {} 14 | } 15 | 16 | module.exports = RedisQueueWorker; 17 | -------------------------------------------------------------------------------- /src/queue/__tests__/BaseQueue.test.js: -------------------------------------------------------------------------------- 1 | jest.unmock('../BaseQueue'); 2 | jest.unmock('../../utils'); 3 | 4 | const BaseQueue = require('../BaseQueue'); 5 | 6 | const queue = new BaseQueue(); 7 | 8 | test('fail when name is not specified', async () => { 9 | await expect(queue.add()).rejects.toThrow( 10 | "Mandatory parameter 'name' is missing" 11 | ); 12 | }); 13 | 14 | test('fail when checkId is not specified', async () => { 15 | await expect(queue.add(10)).rejects.toThrow( 16 | "Mandatory parameter 'checkId' is missing" 17 | ); 18 | }); 19 | 20 | test('fail on non implemented call `close`', async () => { 21 | await expect(queue.close()).rejects.toThrow('Not implemented'); 22 | }); 23 | 24 | test('fail on non implemented call `add`', async () => { 25 | await expect(queue.add(10, 10)).rejects.toThrow('Not implemented'); 26 | }); 27 | -------------------------------------------------------------------------------- /src/queue/__tests__/RedisQueue.test.js: -------------------------------------------------------------------------------- 1 | jest.mock('../../config'); 2 | jest.mock('../../check/runner'); 3 | 4 | const config = require('../../config'); 5 | 6 | describe.skip('Calls', () => { 7 | const RedisQueue = require('../RedisQueue'); 8 | let queue; 9 | 10 | beforeEach(async () => { 11 | jest.doMock('bull'); 12 | queue = new RedisQueue(config.checksQueueName); 13 | }); 14 | 15 | afterEach(async () => { 16 | await queue.close(); 17 | }); 18 | 19 | test('fail when name is not specified', async () => { 20 | await expect(queue.add()).rejects.toThrow( 21 | "Mandatory parameter 'name' is missing" 22 | ); 23 | }); 24 | 25 | test('fail when checkId is not specified', async () => { 26 | await expect(queue.add(10)).rejects.toThrow( 27 | "Mandatory parameter 'checkId' is missing" 28 | ); 29 | }); 30 | 31 | test('fail when name is not a string', async () => { 32 | await expect(queue.add(10, 10, 10)).rejects.toThrow( 33 | "Task name should be 'string'" 34 | ); 35 | }); 36 | 37 | test('fail when params is not an object', async () => { 38 | await expect(queue.add('fake-check', 10, 10)).rejects.toThrow( 39 | "Task params should be 'object'" 40 | ); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/queue/__tests__/RedisQueueWorker.test.js: -------------------------------------------------------------------------------- 1 | jest.mock('bull'); 2 | 3 | jest.mock('../../config'); 4 | jest.mock('../../check/runner'); 5 | 6 | const RedisQueueWorker = require('../RedisQueueWorker'); 7 | 8 | describe('Calls', () => { 9 | test('fail when `concurrency` is not specified', async () => { 10 | await expect(() => { 11 | new RedisQueueWorker(); 12 | }).toThrow("Mandatory parameter 'queueName' is missing"); 13 | }); 14 | 15 | test('fail when `concurrency` is not specified', async () => { 16 | await expect(() => { 17 | new RedisQueueWorker('queueName'); 18 | }).toThrow("Mandatory parameter 'concurrency' is missing"); 19 | }); 20 | 21 | test('fail when `processor` is not specified', async () => { 22 | await expect(() => { 23 | new RedisQueueWorker('queueName', 1); 24 | }).toThrow("Mandatory parameter 'processor' is missing"); 25 | }); 26 | 27 | test('fail when `name` is not a string', async () => { 28 | await expect(new RedisQueueWorker('queueName', 1, () => {})).toBeInstanceOf( 29 | RedisQueueWorker 30 | ); 31 | }); 32 | }); 33 | 34 | describe('Start', () => { 35 | let worker; 36 | const processor = jest.fn(); 37 | 38 | beforeEach(async () => { 39 | worker = new RedisQueueWorker('queueName', 1, processor); 40 | }); 41 | 42 | afterEach(async () => { 43 | await worker.stop(); 44 | }); 45 | 46 | test('start', async () => { 47 | // TODO: Its should be more clever 48 | jest.spyOn(worker.bull, 'on').mockImplementation(() => worker.bull); 49 | 50 | await worker.start(); 51 | 52 | expect(worker.bull.process).toBeCalledTimes(1); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/queue/__tests__/SimpleQueue.test.js: -------------------------------------------------------------------------------- 1 | jest.mock('../../config'); 2 | jest.mock('../../check/runner'); 3 | 4 | describe('Calls', () => { 5 | const SimpleQueue = require('../SimpleQueue'); 6 | let queue; 7 | 8 | beforeEach(async () => { 9 | queue = new SimpleQueue(); 10 | }); 11 | 12 | afterEach(async () => { 13 | await queue.close(); 14 | }); 15 | 16 | test('fail when name is not specified', async () => { 17 | await expect(queue.add()).rejects.toThrow( 18 | "Mandatory parameter 'name' is missing" 19 | ); 20 | }); 21 | 22 | test('fail when checkId is not specified', async () => { 23 | await expect(queue.add(10)).rejects.toThrow( 24 | "Mandatory parameter 'checkId' is missing" 25 | ); 26 | }); 27 | 28 | test('fail when name is not a string', async () => { 29 | await expect(queue.add(10, 10, 10)).rejects.toThrow( 30 | "Task name should be 'string'" 31 | ); 32 | }); 33 | 34 | test('fail when params is not an object', async () => { 35 | await expect(queue.add('fake-check', 10, 10)).rejects.toThrow( 36 | "Task params should be 'object'" 37 | ); 38 | }); 39 | 40 | test('return successful task result', async () => { 41 | await expect(queue.add('fake-check', 10)).resolves.toEqual( 42 | expect.objectContaining({ 43 | shortMessage: 'Moked check is successful', 44 | success: true, 45 | }) 46 | ); 47 | }); 48 | 49 | test('return failed task result', async () => { 50 | await expect(queue.add('failing-fake-check', 10)).resolves.toEqual( 51 | expect.objectContaining({ 52 | shortMessage: 'Mocked failing check is failed', 53 | success: false, 54 | }) 55 | ); 56 | }); 57 | }); 58 | 59 | describe('Concurrency', () => { 60 | test('run many jobs with concurrency 1', async () => { 61 | const config = require('../../config'); 62 | config.concurrency = 1; 63 | 64 | const SimpleQueue = require('../SimpleQueue'); 65 | const queue = new SimpleQueue(); 66 | 67 | let jobs = []; 68 | const jobsCount = 3; 69 | 70 | await [...Array(jobsCount)].forEach((_, i) => { 71 | jobs.push(queue.add('fake-check', i)); 72 | }); 73 | 74 | jobs = Promise.all(jobs); 75 | 76 | await expect(jobs).resolves.toBeTruthy(); 77 | 78 | await jobs.then((values) => { 79 | expect( 80 | // ensure that all jobs were started after end of previous 81 | values.reduce((accumulator, value, index) => { 82 | if (value.startDateTime < accumulator.endDateTime) { 83 | throw new Error( 84 | `Task "${index}" started(${value.startDateTime}) before ` + 85 | `end(${accumulator.endDateTime}) of previous task` 86 | ); 87 | } 88 | return value; 89 | }) 90 | ).toEqual( 91 | expect.objectContaining({ 92 | id: jobsCount - 1, 93 | startDateTime: expect.any(String), 94 | endDateTime: expect.any(String), 95 | }) 96 | ); 97 | }); 98 | }); 99 | 100 | test('run many jobs with concurrency 10', async () => { 101 | const config = require('../../config'); 102 | config.concurrency = 10; 103 | 104 | const SimpleQueue = require('../SimpleQueue'); 105 | const queue = new SimpleQueue(); 106 | 107 | let jobs = []; 108 | const jobsCount = 3; 109 | 110 | await [...Array(jobsCount)].forEach((_, i) => { 111 | jobs.push(queue.add('fake-check', i)); 112 | }); 113 | 114 | jobs = Promise.all(jobs); 115 | 116 | await expect(jobs).resolves.toBeTruthy(); 117 | 118 | await jobs.then((values) => { 119 | expect( 120 | // ensure that all jobs were started at about the same time 121 | values.reduce((accumulator, value, index) => { 122 | if (value.startDateTime >= accumulator.endDateTime) { 123 | throw new Error( 124 | `Task "${index}" started(${value.startDateTime}) after end(${accumulator.endDateTime}) of previous task` 125 | ); 126 | } 127 | return value; 128 | }) 129 | ).toEqual( 130 | expect.objectContaining({ 131 | id: jobsCount - 1, 132 | startDateTime: expect.any(String), 133 | endDateTime: expect.any(String), 134 | }) 135 | ); 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /src/report/CheckReportCustomData.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Additional user defined report data. 3 | */ 4 | class CheckReportCustomData { 5 | constructor() { 6 | /** 7 | * @type {import('../metrics/metrics').CustomMetric[]} 8 | */ 9 | this.metrics = []; 10 | } 11 | } 12 | 13 | module.exports = CheckReportCustomData; 14 | -------------------------------------------------------------------------------- /src/report/__tests__/check.test.js: -------------------------------------------------------------------------------- 1 | const { ActionReport } = require('../action'); 2 | const { CheckReport, processReport, stringifyReport } = require('../check'); 3 | 4 | test('CheckReport constructor', () => { 5 | const report = new CheckReport( 6 | 'name', 7 | 'id', 8 | true, 9 | 'shortMessage', 10 | 'fullMessage', 11 | 'tracePath', 12 | 'screenshotPath', 13 | 'consoleLogPath', 14 | new Date().toISOString(), 15 | new Date().toISOString(), 16 | [] 17 | ); 18 | expect(report).toBeInstanceOf(CheckReport); 19 | 20 | expect(report.name).toEqual('name'); 21 | expect(report.id).toEqual('id'); 22 | expect(report.success).toEqual(true); 23 | expect(report.shortMessage).toEqual('shortMessage'); 24 | expect(report.fullMessage).toEqual('fullMessage'); 25 | expect(report.tracePath).toEqual('tracePath'); 26 | expect(report.screenshotPath).toEqual('screenshotPath'); 27 | expect(report.consoleLogPath).toEqual('consoleLogPath'); 28 | expect(report.actions).toEqual([]); 29 | }); 30 | 31 | test('ActionReport constructor', () => { 32 | const report = new ActionReport( 33 | 'name', 34 | 'step', 35 | 'params', 36 | true, 37 | 'shortMessage', 38 | 'fullMessage', 39 | new Date().toISOString(), 40 | new Date().toISOString() 41 | ); 42 | expect(report).toBeInstanceOf(ActionReport); 43 | 44 | expect(report.name).toEqual('name'); 45 | expect(report.step).toEqual('step'); 46 | expect(report.params).toEqual('params'); 47 | expect(report.success).toEqual(true); 48 | expect(report.shortMessage).toEqual('shortMessage'); 49 | expect(report.fullMessage).toEqual('fullMessage'); 50 | }); 51 | 52 | describe('report processing', () => { 53 | const failedReport = new CheckReport('name', 'id', false, 'shortMessage'); 54 | const successfulReport = new CheckReport('name', 'id', true, 'shortMessage'); 55 | 56 | test('default behavior', () => { 57 | const processed = processReport(successfulReport, {}); 58 | 59 | expect(processed).toHaveProperty('name', 'name'); 60 | expect(processed).toHaveProperty('id', 'id'); 61 | expect(processed).toHaveProperty('success', true); 62 | 63 | expect(processed).toHaveProperty('shortMessage', 'shortMessage'); 64 | expect(processed).toHaveProperty('actions', []); 65 | }); 66 | 67 | test('hide actions', () => { 68 | const processed = processReport(successfulReport, { hideActions: true }); 69 | 70 | expect(processed).toHaveProperty('name', 'name'); 71 | expect(processed).toHaveProperty('id', 'id'); 72 | expect(processed).toHaveProperty('success', true); 73 | 74 | expect(processed).toHaveProperty('shortMessage', 'shortMessage'); 75 | expect(processed).not.toHaveProperty('actions'); 76 | }); 77 | 78 | test('successful is shorten', () => { 79 | const processed = processReport(successfulReport, { shorten: true }); 80 | 81 | expect(processed).toHaveProperty('name', 'name'); 82 | expect(processed).toHaveProperty('id', 'id'); 83 | expect(processed).toHaveProperty('success', true); 84 | 85 | expect(processed).not.toHaveProperty('shortMessage', 'shortMessage'); 86 | expect(processed).not.toHaveProperty('actions'); 87 | }); 88 | 89 | test('failed is not shorten', () => { 90 | const processed = processReport(failedReport, { shorten: true }); 91 | 92 | expect(processed).toHaveProperty('name', 'name'); 93 | expect(processed).toHaveProperty('id', 'id'); 94 | expect(processed).toHaveProperty('success', false); 95 | 96 | expect(processed).toHaveProperty('shortMessage', 'shortMessage'); 97 | expect(processed).toHaveProperty('actions', []); 98 | }); 99 | }); 100 | 101 | test('stringifyReport', () => { 102 | const report = new CheckReport('name', 'id', false, 'shortMessage'); 103 | const action = new ActionReport('name', 'step', 'params', true); 104 | action.cookies = ['test']; 105 | report.actions = [action]; 106 | 107 | const stringified = stringifyReport(report); 108 | 109 | expect(stringified).not.toContain('[Array]'); 110 | }); 111 | -------------------------------------------------------------------------------- /src/report/__tests__/suite.test.js: -------------------------------------------------------------------------------- 1 | const { SuiteReport } = require('../suite'); 2 | 3 | const data = [ 4 | 'mocked-report', 5 | 123, 6 | false, 7 | 'mock', 8 | 'An mocked report', 9 | new Date().toISOString(), 10 | new Date().toISOString(), 11 | ]; 12 | test.each([data])( 13 | 'SuiteReport no checks', 14 | ( 15 | name, 16 | id, 17 | success, 18 | shortMessage, 19 | fullMessage, 20 | startDateTime, 21 | endDateTime 22 | ) => { 23 | const report = new SuiteReport( 24 | name, 25 | id, 26 | success, 27 | shortMessage, 28 | fullMessage, 29 | startDateTime, 30 | endDateTime 31 | ); 32 | 33 | expect(report.id).toEqual(id); 34 | expect(report.success).toEqual(success); 35 | expect(report.shortMessage).toEqual(shortMessage); 36 | expect(report.fullMessage).toEqual(fullMessage); 37 | expect(report.startDateTime).toEqual(startDateTime); 38 | expect(report.endDateTime).toEqual(endDateTime); 39 | expect(report.checks).toEqual([]); 40 | expect(report.runOptions).toEqual({}); 41 | } 42 | ); 43 | test.each([data.concat(['check1', 'check2'])])( 44 | 'SuiteReport with checks', 45 | ( 46 | name, 47 | id, 48 | success, 49 | shortMessage, 50 | fullMessage, 51 | startDateTime, 52 | endDateTime, 53 | checks 54 | ) => { 55 | const report = new SuiteReport( 56 | name, 57 | id, 58 | success, 59 | shortMessage, 60 | fullMessage, 61 | startDateTime, 62 | endDateTime, 63 | checks 64 | ); 65 | expect(report.checks).toEqual(checks); 66 | } 67 | ); 68 | -------------------------------------------------------------------------------- /src/report/action.js: -------------------------------------------------------------------------------- 1 | class ActionReport { 2 | /** 3 | * Create a check action report. 4 | * @param {string} name - Action name 5 | * @param {number} step - Action step number 6 | * @param {object} params - Action parameters 7 | * @param {boolean} [success] - Success status 8 | * @param {string} [shortMessage] - Short message 9 | * @param {string} [fullMessage] - Full message 10 | * @param {string} [startDateTime] - Check start datetime 11 | * @param {string} [endDateTime] - Check completion datetime 12 | * @param {object[]} [cookies=[]] - Check completion datetime 13 | */ 14 | constructor( 15 | name, 16 | step, 17 | params, 18 | success, 19 | shortMessage, 20 | fullMessage, 21 | startDateTime, 22 | endDateTime, 23 | cookies = [] 24 | ) { 25 | this.name = name; 26 | this.step = step; 27 | this.params = params; 28 | this.success = success; 29 | this.shortMessage = shortMessage; 30 | this.fullMessage = fullMessage; 31 | this.startDateTime = startDateTime; 32 | this.endDateTime = endDateTime; 33 | this.cookies = cookies; 34 | } 35 | } 36 | 37 | module.exports = { 38 | ActionReport, 39 | }; 40 | -------------------------------------------------------------------------------- /src/report/check.js: -------------------------------------------------------------------------------- 1 | const util = require('util'); 2 | 3 | class CheckReport { 4 | /** 5 | * Create a check report. 6 | * @param {string} name - Check name 7 | * @param {string} id - Check id 8 | * @param {boolean} [success] - Success status 9 | * @param {string} [shortMessage] - Short message 10 | * @param {string} [fullMessage] - Full message 11 | * @param {string} [tracePath] - Trace path 12 | * @param {string} [screenshotPath] - Screenshot path 13 | * @param {string} [consoleLogPath] - Console log path 14 | * @param {string} [startDateTime] - Check start datetime 15 | * @param {string} [endDateTime] - Check completion datetime 16 | * @param {InstanceType[]} [actions=[]] - Check action list 17 | * @param {string|null} [scheduleName=null] - Schedule name 18 | * @param {string[]} [labels=[]] - Labels 19 | * @param {string[]} [forbiddenCookies=[]] - Found forbidden cookies 20 | * @param {number} [forbiddenCookiesCount=0] - Count of forbidden cookies found 21 | * @param {string} [harPath=null] - Har path 22 | */ 23 | constructor( 24 | name, 25 | id, 26 | success, 27 | shortMessage, 28 | fullMessage, 29 | tracePath, 30 | screenshotPath, 31 | consoleLogPath, 32 | startDateTime, 33 | endDateTime, 34 | actions = [], 35 | scheduleName = null, 36 | labels = [], 37 | forbiddenCookies = [], 38 | forbiddenCookiesCount = 0, 39 | harPath = null 40 | ) { 41 | this.name = name; 42 | this.id = id; 43 | this.success = success; 44 | this.shortMessage = shortMessage; 45 | this.fullMessage = fullMessage; 46 | this.tracePath = tracePath; 47 | this.harPath = harPath; 48 | this.screenshotPath = screenshotPath; 49 | this.consoleLogPath = consoleLogPath; 50 | this.startDateTime = startDateTime; 51 | this.endDateTime = endDateTime; 52 | this.actions = actions; 53 | this.scheduleName = scheduleName; 54 | this.labels = labels; 55 | this.forbiddenCookies = forbiddenCookies; 56 | this.forbiddenCookiesCount = forbiddenCookiesCount; 57 | 58 | /** 59 | * @type {import('../metrics/metrics').CustomMetric[]} 60 | */ 61 | this.metrics = []; 62 | } 63 | } 64 | 65 | /** 66 | * Report view options. 67 | * @typedef {object} CheckReportViewOptions 68 | * @property {boolean} hideActions Indicates whether the actions should be showed. 69 | * @property {boolean} shorten Indicates whether the successful reports should be shorten. 70 | */ 71 | 72 | /** 73 | * Prepare report view. 74 | * @param {CheckReport} report Report instance 75 | * @param {CheckReportViewOptions} options View options 76 | * @returns {object} 77 | */ 78 | function processReport(report, options) { 79 | if (options && options.shorten && report.success === true) { 80 | const allowedKeys = [ 81 | 'name', 82 | 'id', 83 | 'success', 84 | 'startDateTime', 85 | 'endDateTime', 86 | 'forbiddenCookies', 87 | ]; 88 | 89 | const processed = Object.fromEntries( 90 | Object.entries(report).filter((entry) => { 91 | return allowedKeys.includes(entry[0]); 92 | }) 93 | ); 94 | 95 | return processed; 96 | } 97 | 98 | const processed = Object.fromEntries( 99 | Object.entries(report).filter((entry) => { 100 | if (options && options.hideActions && entry[0] === 'actions') { 101 | return false; 102 | } 103 | return true; 104 | }) 105 | ); 106 | 107 | return processed; 108 | } 109 | 110 | /** 111 | * Prepare report view. 112 | * @param {object} report Report 113 | * @returns {string} 114 | */ 115 | function stringifyReport(report) { 116 | return util.inspect(report, { 117 | colors: true, 118 | depth: null, 119 | maxArrayLength: null, 120 | }); 121 | } 122 | 123 | module.exports = { 124 | CheckReport, 125 | processReport, 126 | stringifyReport, 127 | }; 128 | -------------------------------------------------------------------------------- /src/report/pathGenerator.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const filter = require('lodash.filter'); 3 | const ReportPaths = require('./paths'); 4 | 5 | class ReportPathsGenerator { 6 | /** 7 | * @param {Configuration} config 8 | */ 9 | constructor(config) { 10 | this.config = config; 11 | } 12 | 13 | /** 14 | * 15 | * @param {CheckData} check 16 | * @returns {ReportPaths} 17 | */ 18 | get(check) { 19 | let tracePath; 20 | let harPath; 21 | let screenshotPath; 22 | let consoleLogPath; 23 | let reportPath; 24 | 25 | switch (this.config.artifactsGroupByCheckName) { 26 | case true: 27 | tracePath = path.resolve(this.config.artifactsDir, check.name); 28 | harPath = path.resolve(this.config.artifactsDir, check.name); 29 | screenshotPath = path.resolve(this.config.artifactsDir, check.name); 30 | consoleLogPath = path.resolve(this.config.artifactsDir, check.name); 31 | reportPath = path.resolve(this.config.artifactsDir, check.name); 32 | break; 33 | case false: 34 | default: 35 | tracePath = path.resolve(this.config.tracesDir); 36 | harPath = path.resolve(this.config.harsDir); 37 | screenshotPath = path.resolve(this.config.screenshotsDir); 38 | consoleLogPath = path.resolve(this.config.consoleLogDir); 39 | reportPath = path.resolve(this.config.reportsDir); 40 | break; 41 | } 42 | 43 | const traceTempPath = path.resolve(this.config.tracesTempDir); 44 | const harTempPath = path.resolve(this.config.harsTempDir); 45 | 46 | const checkId = check.id.replace(/[^\w]/g, '_'); 47 | const checkName = check.name.replace(/[^\w]/g, '_'); 48 | const latestFailedReportFile = filter([checkName, check.scheduleName]).join( 49 | '_' 50 | ); 51 | 52 | return new ReportPaths( 53 | path.resolve(tracePath, `${checkId}_trace.json`), 54 | path.resolve(traceTempPath, `${checkId}_trace.json`), 55 | path.resolve(harPath, `${checkId}.har`), 56 | path.resolve(harTempPath, `${checkId}.har`), 57 | path.resolve(screenshotPath, `${checkId}_screenshot.png`), 58 | path.resolve(consoleLogPath, `${checkId}_console.log`), 59 | path.resolve(reportPath, `${checkId}_report.json`), 60 | path.resolve( 61 | reportPath, 62 | `${latestFailedReportFile}_latest_failed_report.json` 63 | ) 64 | ); 65 | } 66 | } 67 | 68 | module.exports = ReportPathsGenerator; 69 | -------------------------------------------------------------------------------- /src/report/paths.js: -------------------------------------------------------------------------------- 1 | const isEmpty = require('lodash.isempty'); 2 | 3 | class ReportPaths { 4 | /** 5 | * @param {string} tracePath 6 | * @param {string} traceTempPath 7 | * @param {string} harPath 8 | * @param {string} harTempPath 9 | * @param {string} screenshotPath 10 | * @param {string} consoleLogPath 11 | * @param {string} reportPath 12 | * @param {string} latestFailedReportPath 13 | */ 14 | constructor( 15 | tracePath, 16 | traceTempPath, 17 | harPath, 18 | harTempPath, 19 | screenshotPath, 20 | consoleLogPath, 21 | reportPath, 22 | latestFailedReportPath 23 | ) { 24 | this.tracePath = tracePath; 25 | this.traceTempPath = traceTempPath; 26 | this.harPath = harPath; 27 | this.harTempPath = harTempPath; 28 | this.screenshotPath = screenshotPath; 29 | this.consoleLogPath = consoleLogPath; 30 | this.reportPath = reportPath; 31 | this.latestFailedReportPath = latestFailedReportPath; 32 | } 33 | 34 | /** 35 | * @returns {string} 36 | */ 37 | getTracePath() { 38 | if (isEmpty(this.tracePath)) { 39 | throw Error('Trace path is empty'); 40 | } 41 | return this.tracePath; 42 | } 43 | 44 | /** 45 | * @returns {string} 46 | */ 47 | getTraceTempPath() { 48 | if (isEmpty(this.traceTempPath)) { 49 | throw Error('Trace temp path is empty'); 50 | } 51 | return this.traceTempPath; 52 | } 53 | 54 | /** 55 | * @returns {string} 56 | */ 57 | getHarPath() { 58 | if (isEmpty(this.harPath)) { 59 | throw Error('Har path is empty'); 60 | } 61 | return this.harPath; 62 | } 63 | 64 | /** 65 | * @returns {string} 66 | */ 67 | getHarTempPath() { 68 | if (isEmpty(this.harTempPath)) { 69 | throw Error('Har temp path is empty'); 70 | } 71 | return this.harTempPath; 72 | } 73 | 74 | /** 75 | * @returns {string} 76 | */ 77 | getScreenshotPath() { 78 | if (isEmpty(this.screenshotPath)) { 79 | throw Error('Screenshot path is empty'); 80 | } 81 | return this.screenshotPath; 82 | } 83 | 84 | /** 85 | * @returns {string} 86 | */ 87 | getConsoleLogPath() { 88 | if (isEmpty(this.consoleLogPath)) { 89 | throw Error('ConsoleLog path is empty'); 90 | } 91 | return this.consoleLogPath; 92 | } 93 | 94 | /** 95 | * @returns {string} 96 | */ 97 | getReportPath() { 98 | if (isEmpty(this.reportPath)) { 99 | throw Error('Report path is empty'); 100 | } 101 | return this.reportPath; 102 | } 103 | 104 | /** 105 | * @returns {string} 106 | */ 107 | getLatestFailedReportPath() { 108 | if (isEmpty(this.latestFailedReportPath)) { 109 | throw Error('Latest failed report path is empty'); 110 | } 111 | return this.latestFailedReportPath; 112 | } 113 | } 114 | 115 | module.exports = ReportPaths; 116 | -------------------------------------------------------------------------------- /src/report/suite.js: -------------------------------------------------------------------------------- 1 | const util = require('util'); 2 | 3 | const checkReport = require('./check'); 4 | 5 | class SuiteReport { 6 | /** 7 | * Create a suite report. 8 | * @param {string} name Suite name 9 | * @param {string} id Suite id 10 | * @param {boolean} [success] Success status 11 | * @param {string} [shortMessage] Short message 12 | * @param {string} [fullMessage] Full message 13 | * @param {string} [startDateTime] Suite start datetime 14 | * @param {string} [endDateTime] Suite completion datetime 15 | * @param {InstanceType[]} [checks=[]] Suite check list 16 | * @param {import('../suite/runner').SuiteRunOptions} [runOptions] 17 | */ 18 | constructor( 19 | name, 20 | id, 21 | success, 22 | shortMessage, 23 | fullMessage, 24 | startDateTime, 25 | endDateTime, 26 | checks = [], 27 | runOptions = {} 28 | ) { 29 | this.name = name; 30 | this.id = id; 31 | this.success = success; 32 | this.shortMessage = shortMessage; 33 | this.fullMessage = fullMessage; 34 | this.startDateTime = startDateTime; 35 | this.endDateTime = endDateTime; 36 | this.checks = checks; 37 | this.runOptions = runOptions; 38 | } 39 | } 40 | 41 | /** 42 | * Report view options. 43 | * @typedef {object} SuiteReportViewOptions 44 | * @property {import('./check').CheckReportViewOptions} checkOptions . 45 | */ 46 | 47 | /** 48 | * Prepare report view. 49 | * @param {SuiteReport} report Report instance 50 | * @param {SuiteReportViewOptions} options View options 51 | * @returns {object} 52 | */ 53 | function processReport(report, options) { 54 | const processedCheckReports = report.checks.map((origCheckReport) => { 55 | return checkReport.processReport(origCheckReport, options.checkOptions); 56 | }); 57 | 58 | return { ...report, checks: processedCheckReports }; 59 | } 60 | 61 | /** 62 | * Prepare report view. 63 | * @param {object} report Report 64 | * @returns {string} 65 | */ 66 | function stringifyReport(report) { 67 | return util.inspect(report, { 68 | colors: true, 69 | depth: null, 70 | maxArrayLength: null, 71 | }); 72 | } 73 | 74 | module.exports = { 75 | SuiteReport, 76 | processReport, 77 | stringifyReport, 78 | }; 79 | -------------------------------------------------------------------------------- /src/report/urlReplacer.js: -------------------------------------------------------------------------------- 1 | const has = require('lodash.has'); 2 | 3 | class ReportURLReplacer { 4 | /** 5 | * @param {Configuration} config 6 | */ 7 | constructor(config) { 8 | this.config = config; 9 | } 10 | 11 | /** 12 | * @param {CheckReport} report 13 | * @param req 14 | * @returns {CheckReport} 15 | */ 16 | replacePaths(report, req) { 17 | const current = report; 18 | if (has(report, 'tracePath') && report.tracePath) { 19 | current.tracePath = report.tracePath.replace( 20 | this.config.artifactsDir, 21 | `${req.protocol}://${req.headers.host}/storage` 22 | ); 23 | } 24 | if (has(report, 'harPath') && report.harPath) { 25 | current.harPath = report.harPath.replace( 26 | this.config.artifactsDir, 27 | `${req.protocol}://${req.headers.host}/storage` 28 | ); 29 | } 30 | if (has(report, 'screenshotPath') && report.screenshotPath) { 31 | current.screenshotPath = report.screenshotPath.replace( 32 | this.config.artifactsDir, 33 | `${req.protocol}://${req.headers.host}/storage` 34 | ); 35 | } 36 | if (has(report, 'consoleLogPath') && report.consoleLogPath) { 37 | current.consoleLogPath = report.consoleLogPath.replace( 38 | this.config.artifactsDir, 39 | `${req.protocol}://${req.headers.host}/storage` 40 | ); 41 | } 42 | return current; 43 | } 44 | } 45 | 46 | module.exports = ReportURLReplacer; 47 | -------------------------------------------------------------------------------- /src/schedule/__tests__/parser.test.js: -------------------------------------------------------------------------------- 1 | jest.mock('../../config'); 2 | 3 | const config = require('../../config'); 4 | const { ScheduleParser } = require('../parser'); 5 | 6 | let parser; 7 | 8 | beforeEach(async () => { 9 | parser = new ScheduleParser(config.schedulesFilePath); 10 | }); 11 | 12 | describe('SuiteParser', () => { 13 | test('get list', () => { 14 | expect(parser.getList()).toMatchInlineSnapshot(` 15 | [ 16 | "schedule-full", 17 | "schedule-without-labels", 18 | "empty-schedule", 19 | "schedule-with-incorrect-labels-type", 20 | "schedule-with-incorrect-label-priority", 21 | "schedule-with-incorrect-labels", 22 | ] 23 | `); 24 | }); 25 | 26 | test('fail when name argument is not specified', () => { 27 | expect(() => { 28 | parser.getSchedule(); 29 | }).toThrow("Mandatory parameter 'name' is missing"); 30 | }); 31 | 32 | test('getSchedule', () => { 33 | expect(parser.getSchedule('schedule-full')).toMatchInlineSnapshot(` 34 | { 35 | "allowedCookies": [ 36 | "/^regex_test{1,30}$/", 37 | ], 38 | "checks": [ 39 | "mocked-check", 40 | ], 41 | "interval": 60000, 42 | "labels": { 43 | "appLink": "app-link", 44 | "appName": "some-app", 45 | "priority": "p1", 46 | "product": "some-product", 47 | "slackChannel": "app-slack", 48 | "team": "some-team", 49 | }, 50 | } 51 | `); 52 | }); 53 | 54 | test('fail when interval incorrect', () => { 55 | expect(() => { 56 | return parser.getSchedule('empty-schedule'); 57 | }).toThrowErrorMatchingInlineSnapshot( 58 | `"Schedule should have interval in format "60(s|m)". Now: undefined"` 59 | ); 60 | }); 61 | 62 | test('default labels is null', () => { 63 | expect(parser.getSchedule('schedule-without-labels')) 64 | .toMatchInlineSnapshot(` 65 | { 66 | "allowedCookies": [ 67 | "/^regex_test{1,30}$/", 68 | ], 69 | "checks": [ 70 | "mocked-check", 71 | ], 72 | "interval": 60000, 73 | "labels": [], 74 | } 75 | `); 76 | }); 77 | 78 | test('fail when labels incorrect', () => { 79 | expect(() => { 80 | parser.getSchedule('schedule-with-incorrect-labels-type'); 81 | }).toThrowErrorMatchingInlineSnapshot( 82 | `"Schedule labels should be object, not string"` 83 | ); 84 | }); 85 | 86 | test('fail when label priority incorrect', () => { 87 | expect(() => { 88 | parser.getSchedule('schedule-with-incorrect-label-priority'); 89 | }).toThrowErrorMatchingInlineSnapshot( 90 | `"This value is not allowed for label priority"p10""` 91 | ); 92 | }); 93 | 94 | test('fail when labels incorrect', () => { 95 | expect(() => { 96 | parser.getSchedule('schedule-with-incorrect-labels'); 97 | }).toThrowErrorMatchingInlineSnapshot( 98 | `"Next schedule labels are not allowed "all-in-fire""` 99 | ); 100 | }); 101 | 102 | test('fail when trying to get non-existing schedule', () => { 103 | expect(() => { 104 | parser.getSchedule('unknown-schedule'); 105 | }).toThrow("Schedule with name 'unknown-schedule' does not exist"); 106 | }); 107 | 108 | test('fail when name argument is not specified', () => { 109 | expect(() => { 110 | ScheduleParser.parseSchedule(); 111 | }).toThrow("Mandatory parameter 'data' is missing"); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /src/schedule/parser.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const yaml = require('js-yaml'); 3 | const nunjucks = require('nunjucks'); 4 | 5 | const utils = require('../utils'); 6 | const ParamParser = require('../parameters/ParamParser'); 7 | 8 | class ScheduleParser { 9 | constructor(dataFilePath) { 10 | this.rawContent = fs.readFileSync(dataFilePath, 'utf8'); 11 | this.rawDoc = yaml.load(this.rawContent); 12 | this.paramParser = new ParamParser(); 13 | this.preparedDoc = null; 14 | } 15 | 16 | getList() { 17 | return Object.keys(this.rawDoc.schedules); 18 | } 19 | 20 | getSchedule(name = utils.mandatory('name'), params = {}) { 21 | if (typeof this.rawDoc.schedules[name] === 'undefined') { 22 | throw new Error(`Schedule with name '${name}' does not exist`); 23 | } 24 | 25 | const mergedParams = this.paramParser.mergeParams( 26 | this.rawDoc.schedules[name].parameters, 27 | params 28 | ); 29 | 30 | const parsedDoc = yaml.load( 31 | nunjucks.renderString(this.rawContent, mergedParams) 32 | ); 33 | return ScheduleParser.parseSchedule(parsedDoc.schedules[name]); 34 | } 35 | 36 | static parseSchedule(data = utils.mandatory('data')) { 37 | const preparedData = data; 38 | 39 | if (!preparedData.interval) { 40 | throw new Error( 41 | `Schedule should have interval in format "60(s|m)". Now: ` + 42 | `${preparedData.interval}` 43 | ); 44 | } 45 | 46 | preparedData.interval = utils.humanReadableTimeToMS(preparedData.interval); 47 | 48 | if (preparedData.allowedCookies === undefined) { 49 | preparedData.allowedCookies = []; 50 | } 51 | 52 | if (preparedData.labels === undefined) { 53 | preparedData.labels = []; 54 | } 55 | 56 | if (typeof preparedData.labels !== 'object') { 57 | throw new Error( 58 | `Schedule labels should be object, not ${typeof preparedData.labels}` 59 | ); 60 | } 61 | 62 | const allowedLabels = [ 63 | 'team', 64 | 'product', 65 | 'priority', 66 | 'appName', 67 | 'appLink', 68 | 'slackChannel', 69 | ]; 70 | const notAllowedLabels = Object.keys(preparedData.labels).filter( 71 | (labelName) => !allowedLabels.includes(labelName) 72 | ); 73 | 74 | const allowedPriorities = ['p1', 'p2', 'p3', 'p4', 'p5']; 75 | if ( 76 | preparedData.labels.priority && 77 | !allowedPriorities.includes(preparedData.labels.priority) 78 | ) { 79 | throw new Error( 80 | `This value is not allowed for label priority` + 81 | `"${preparedData.labels.priority}"` 82 | ); 83 | } 84 | 85 | if (notAllowedLabels.length > 0) { 86 | throw new Error( 87 | `Next schedule labels are not allowed "${notAllowedLabels}"` 88 | ); 89 | } 90 | 91 | return data; 92 | } 93 | } 94 | 95 | module.exports = { 96 | ScheduleParser, 97 | }; 98 | -------------------------------------------------------------------------------- /src/schedule/runner.js: -------------------------------------------------------------------------------- 1 | const { v4: uuidv4 } = require('uuid'); 2 | const Redis = require('ioredis'); 3 | 4 | const log = require('../logger'); 5 | const config = require('../config'); 6 | const utils = require('../utils'); 7 | const { ScheduleParser } = require('./parser'); 8 | const CheckRunner = require('../check/runner'); 9 | const metrics = require('../metrics/metrics'); 10 | 11 | const redisParams = { 12 | port: config.redisPort, 13 | host: config.redisHost, 14 | password: config.redisPassword, 15 | }; 16 | 17 | class ScheduleRunner { 18 | constructor(queue = utils.mandatory('queue')) { 19 | this.scheduleParser = new ScheduleParser(config.schedulesFilePath); 20 | this.checkRunner = new CheckRunner(queue); 21 | 22 | if (typeof queue !== 'object') { 23 | throw new Error( 24 | `Queue should be instance of Queue, not '${typeof queue}'` 25 | ); 26 | } 27 | this.queue = queue; 28 | } 29 | 30 | async runAll() { 31 | const scheduled = []; 32 | 33 | this.scheduleParser.getList().forEach((schedule) => { 34 | scheduled.push(this.run(schedule)); 35 | }); 36 | 37 | return Promise.all(scheduled); 38 | } 39 | 40 | async run(name = utils.mandatory('name')) { 41 | const redis = new Redis(redisParams); 42 | 43 | const schedule = this.scheduleParser.getSchedule(name); 44 | 45 | if (typeof schedule.proxy === 'undefined') { 46 | schedule.proxy = null; 47 | } 48 | 49 | try { 50 | redis 51 | .multi() 52 | .set(`purr:schedules:${name}`, JSON.stringify(schedule.checks)) 53 | .set( 54 | [ 55 | metrics.redisKeyPrefix, 56 | metrics.names.checkIntervalSeconds, 57 | name, 58 | ].join(':'), 59 | schedule.interval / 1000 60 | ) 61 | .incrby( 62 | `${metrics.redisKeyPrefix}:${metrics.names.checksScheduled}`, 63 | schedule.checks.length 64 | ) 65 | .exec(); 66 | } catch (err) { 67 | log.error('Can not save info about schedule to redis: ', err); 68 | } finally { 69 | redis.quit(); 70 | } 71 | 72 | return Promise.all( 73 | schedule.checks.map((check) => { 74 | return this.checkRunner.run( 75 | check, 76 | uuidv4(), 77 | schedule.parameters, 78 | { 79 | every: schedule.interval, 80 | }, 81 | name, 82 | schedule.interval, 83 | false, 84 | schedule.labels, 85 | schedule.proxy, 86 | schedule.allowedCookies 87 | ); 88 | }) 89 | ); 90 | } 91 | 92 | getScheduledChecks() { 93 | return this.queue.getRepeatableJobs(); 94 | } 95 | 96 | async removeScheduledChecks() { 97 | const checks = await this.getScheduledChecks(); 98 | const redis = new Redis(redisParams); 99 | 100 | try { 101 | await redis 102 | .keys('purr:schedules:*') 103 | .then((keys) => { 104 | return Promise.all( 105 | keys.map(async (key) => { 106 | return redis.del(key).catch((err) => { 107 | log.error('Can not remove schedule from redis: ', err); 108 | }); 109 | }) 110 | ); 111 | }) 112 | .catch((err) => { 113 | log.error('Can not get a list of schedules from redis: ', err); 114 | }); 115 | 116 | await redis 117 | .keys(`${metrics.redisKeyPrefix}:*`) 118 | .then((keys) => { 119 | return Promise.all( 120 | keys.map(async (key) => { 121 | return redis.del(key).catch((err) => { 122 | log.error('Can not remove metric from redis: ', err); 123 | }); 124 | }) 125 | ); 126 | }) 127 | .catch((err) => { 128 | log.error('Can not get a list of metrics from redis: ', err); 129 | }); 130 | } finally { 131 | redis.quit(); 132 | } 133 | 134 | return Promise.all( 135 | checks.map(async (check) => { 136 | return this.queue.removeRepeatableByKey(check.key); 137 | }) 138 | ); 139 | } 140 | } 141 | 142 | module.exports = ScheduleRunner; 143 | -------------------------------------------------------------------------------- /src/suite/__tests__/parser.test.js: -------------------------------------------------------------------------------- 1 | jest.mock('../../config'); 2 | 3 | const config = require('../../config'); 4 | const { SuiteParser } = require('../parser'); 5 | 6 | let parser; 7 | 8 | beforeEach(async () => { 9 | parser = new SuiteParser(config.suitesDir); 10 | }); 11 | 12 | describe('SuiteParser', () => { 13 | test('get list', () => { 14 | expect(parser.getList()).toEqual([ 15 | 'mocked-suite', 16 | 'failing-mocked-suite', 17 | 'mocked-suite-with-exception', 18 | 'empty-suite', 19 | 'empty-steps-suite', 20 | ]); 21 | }); 22 | 23 | test('fail when name argument is not specified', () => { 24 | expect(() => { 25 | parser.getSuite(); 26 | }).toThrow("Mandatory parameter 'name' is missing"); 27 | }); 28 | 29 | test('getSuiteSteps', () => { 30 | expect(parser.getSuiteSteps('mocked-suite')).toEqual([ 31 | 'mocked-check', 32 | 'mocked-check-with-param', 33 | ]); 34 | }); 35 | 36 | test('fail when trying to get non-existing suite', () => { 37 | expect(() => { 38 | parser.getSuiteSteps('unknown-suite'); 39 | }).toThrow("Suite with name 'unknown-suite' does not exist"); 40 | }); 41 | 42 | test('fail when trying to get empty suite', () => { 43 | expect(() => { 44 | parser.getSuiteSteps('empty-suite'); 45 | }).toThrow("Suite with name 'empty-suite' is empty"); 46 | }); 47 | 48 | test('fail when trying to get suite without steps', () => { 49 | expect(() => { 50 | parser.getSuiteSteps('empty-steps-suite'); 51 | }).toThrow("Suite with name 'empty-steps-suite' has no steps"); 52 | }); 53 | 54 | test('fail when name argument is not specified', () => { 55 | expect(() => { 56 | parser.getSuiteSteps(); 57 | }).toThrow("Mandatory parameter 'name' is missing"); 58 | }); 59 | 60 | test('proxy is null by default', () => { 61 | expect(parser.getSuiteProxy('empty-steps-suite')).toBe(null); 62 | }); 63 | 64 | test('getSuiteProxy', () => { 65 | expect(parser.getSuiteProxy('mocked-suite')).toBe('some_url'); 66 | }); 67 | 68 | test('fail when name argument is not specified', () => { 69 | expect(() => { 70 | parser.getSuiteProxy(); 71 | }).toThrow("Mandatory parameter 'name' is missing"); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/suite/__tests__/runner.test.js: -------------------------------------------------------------------------------- 1 | jest.mock('../../config'); 2 | jest.mock('../../check/runner'); 3 | 4 | const SuiteRunner = require('../runner'); 5 | const SimpleQueue = require('../../queue/SimpleQueue'); 6 | const { SuiteReport } = require('../../report/suite'); 7 | 8 | test('successful suite', () => { 9 | const queue = new SimpleQueue(); 10 | const runner = new SuiteRunner(queue); 11 | 12 | return runner.run('mocked-suite').then((data) => { 13 | expect(data).toBeInstanceOf(SuiteReport); 14 | expect(data.success).toEqual(true); 15 | expect(data.checks[0].name).toEqual('mocked-check'); 16 | }); 17 | }); 18 | 19 | test('failing suite', async () => { 20 | const runner = new SuiteRunner(); 21 | 22 | const data = await runner.run('failing-mocked-suite', Math.random()); 23 | 24 | expect(data).toBeInstanceOf(SuiteReport); 25 | expect(data.success).toEqual(false); 26 | expect(data.checks[0].success).toEqual(true); 27 | expect(data.checks[1].success).toEqual(false); 28 | }); 29 | 30 | test('suite with exception', () => { 31 | const runner = new SuiteRunner(); 32 | 33 | return runner 34 | .run('mocked-suite-with-exception', Math.random()) 35 | .then((data) => { 36 | expect(data).toBeInstanceOf(SuiteReport); 37 | expect(data.success).toEqual(false); 38 | expect(data.checks[0].success).toEqual(true); 39 | expect(data.checks[1]).toBeInstanceOf(Error); 40 | }); 41 | }); 42 | 43 | test('unknown suite', async () => { 44 | const runner = new SuiteRunner(); 45 | 46 | await expect( 47 | runner.run('unexisted-mocked-suite', Math.random()) 48 | ).rejects.toThrowError( 49 | "Suite with name 'unexisted-mocked-suite' does not exist" 50 | ); 51 | }); 52 | 53 | test('fail when name argument is not specified', () => { 54 | const runner = new SuiteRunner(); 55 | expect(runner.run()).rejects.toThrow("Mandatory parameter 'name' is missing"); 56 | }); 57 | -------------------------------------------------------------------------------- /src/suite/parser.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const yaml = require('js-yaml'); 4 | const nunjucks = require('nunjucks'); 5 | 6 | const utils = require('../utils'); 7 | const ParamParser = require('../parameters/ParamParser'); 8 | 9 | /** 10 | * @typedef Suite 11 | * @property {string[]} steps 12 | * @property {string} proxy 13 | */ 14 | 15 | class SuiteParser { 16 | /** 17 | * @param {string} suitesDir 18 | */ 19 | constructor(suitesDir) { 20 | const rawContent = []; 21 | 22 | const dir = fs.opendirSync(suitesDir); 23 | let dirent = dir.readSync(); 24 | 25 | while (dirent !== null) { 26 | if (dirent.isFile()) { 27 | const file = fs.readFileSync(path.resolve(suitesDir, dirent.name)); 28 | 29 | if (dirent.name.startsWith('.common.')) { 30 | rawContent.unshift(file); 31 | } else { 32 | rawContent.push(file); 33 | } 34 | } 35 | dirent = dir.readSync(); 36 | } 37 | 38 | dir.close(); 39 | 40 | this.rawContent = rawContent.join('\n'); 41 | this.rawDoc = yaml.load(this.rawContent); 42 | this.paramParser = new ParamParser(); 43 | } 44 | 45 | getList() { 46 | return Object.keys(this.rawDoc); 47 | } 48 | 49 | /** 50 | * 51 | * @param {string} name Suite name 52 | * @param {object} params 53 | * @returns {Suite} 54 | */ 55 | getSuite( 56 | // @ts-ignore 57 | name = utils.mandatory('name'), 58 | params = {} 59 | ) { 60 | if (typeof this.rawDoc[name] === 'undefined') { 61 | throw new Error(`Suite with name '${name}' does not exist`); 62 | } 63 | 64 | if (this.rawDoc[name] === null) { 65 | throw new Error(`Suite with name '${name}' is empty`); 66 | } 67 | 68 | const mergedParams = this.paramParser.mergeParams( 69 | this.rawDoc[name].parameters, 70 | params 71 | ); 72 | 73 | const parsedDoc = yaml.load( 74 | nunjucks.renderString(this.rawContent, mergedParams) 75 | ); 76 | return parsedDoc[name]; 77 | } 78 | 79 | /** 80 | * 81 | * @param {string} name Suite name 82 | * @returns {string[]} 83 | */ 84 | getSuiteSteps( 85 | // @ts-ignore 86 | name = utils.mandatory('name') 87 | ) { 88 | const suite = this.getSuite(name); 89 | if (!suite.steps) { 90 | throw new Error(`Suite with name '${name}' has no steps`); 91 | } 92 | return suite.steps; 93 | } 94 | 95 | /** 96 | * 97 | * @param {string} name Suite name 98 | * @param {object} params 99 | * @returns 100 | */ 101 | getSuiteProxy( 102 | // @ts-ignore 103 | name = utils.mandatory('name'), 104 | params = {} 105 | ) { 106 | const suite = this.getSuite(name, params); 107 | if (typeof suite.proxy === 'undefined') { 108 | return null; 109 | } 110 | return suite.proxy; 111 | } 112 | } 113 | 114 | module.exports = { 115 | SuiteParser, 116 | }; 117 | -------------------------------------------------------------------------------- /src/suite/runner.js: -------------------------------------------------------------------------------- 1 | const { v4: uuidv4 } = require('uuid'); 2 | 3 | const config = require('../config'); 4 | const utils = require('../utils'); 5 | const { SuiteReport } = require('../report/suite'); 6 | const { SuiteParser } = require('./parser'); 7 | const SimpleQueue = require('../queue/SimpleQueue'); 8 | const CheckRunner = require('../check/runner'); 9 | 10 | /** 11 | * Suite options. 12 | * @typedef SuiteRunOptions 13 | * @property {number} [split] Number of part to split suite 14 | * @property {number} [part] Part number for execution 15 | * @property {string} [suiteId] 16 | */ 17 | 18 | class SuiteRunner { 19 | constructor(queue = new SimpleQueue()) { 20 | this.suiteParser = new SuiteParser(config.suitesDir); 21 | this.checkRunner = new CheckRunner(queue); 22 | } 23 | 24 | /** 25 | * 26 | * @param {string} name 27 | * @param {SuiteRunOptions} [options] 28 | * @returns {Promise} 29 | */ 30 | async run( 31 | // @ts-ignore 32 | name = utils.mandatory('name'), 33 | options = {} 34 | ) { 35 | const split = options.split ? options.split : 1; 36 | const part = options.part ? options.part : 1; 37 | const suiteId = options.suiteId ? options.suiteId : uuidv4(); 38 | const suiteParts = utils.splitArray( 39 | this.suiteParser.getSuiteSteps(name).slice(), 40 | split 41 | ); 42 | const suiteSteps = suiteParts[part - 1]; 43 | if (!suiteSteps.length) { 44 | throw Error(`Specified part of suite(${part} of ${split}) is empty`); 45 | } 46 | 47 | const proxy = this.suiteParser.getSuiteProxy(name); 48 | const suiteReport = new SuiteReport(name, suiteId); 49 | suiteReport.runOptions = options; 50 | 51 | let result = Promise.resolve().then(() => { 52 | suiteReport.startDateTime = new Date().toISOString(); 53 | suiteReport.success = true; 54 | }); 55 | 56 | while (suiteSteps.length > 0) { 57 | const check = suiteSteps.shift(); 58 | 59 | const checkPromise = Promise.resolve().then(async () => { 60 | const errorText = 'At least one check failed'; 61 | 62 | await this.checkRunner 63 | .run(check, uuidv4(), {}, {}, '', 0, true, [], proxy) 64 | .then((checkReport) => { 65 | suiteReport.checks.push(checkReport); 66 | 67 | if (!checkReport.success) { 68 | suiteReport.success = false; 69 | suiteReport.shortMessage = errorText; 70 | suiteReport.fullMessage = errorText; 71 | } 72 | }) 73 | .catch((err) => { 74 | suiteReport.success = false; 75 | suiteReport.shortMessage = errorText; 76 | suiteReport.fullMessage = errorText; 77 | suiteReport.checks.push(err); 78 | }); 79 | }); 80 | 81 | result = result.then(async () => { 82 | await checkPromise; 83 | }); 84 | } 85 | 86 | result = result.then(async () => { 87 | return Promise.resolve(suiteReport); 88 | }); 89 | 90 | result = result.catch(async (err) => { 91 | suiteReport.success = false; 92 | suiteReport.shortMessage = err.message; 93 | // FIXME: circular? 94 | suiteReport.fullMessage = JSON.stringify(err); 95 | return Promise.reject(suiteReport); 96 | }); 97 | 98 | result = result.finally(async () => { 99 | suiteReport.endDateTime = new Date().toISOString(); 100 | }); 101 | 102 | return result; 103 | } 104 | } 105 | 106 | module.exports = SuiteRunner; 107 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | /** 4 | * 5 | * @param {string} name 6 | */ 7 | function mandatory(name) { 8 | if (name === undefined) { 9 | throw new Error('Mandatory parameter name is not specified'); 10 | } 11 | if (typeof name !== 'string' || name.length <= 0) { 12 | throw new Error('Mandatory parameter name must be non-empty string'); 13 | } 14 | throw new Error(`Mandatory parameter '${name}' is missing`); 15 | } 16 | 17 | /** 18 | * Enrich error with custom message 19 | * 20 | * @param {Error} error 21 | * @param {string} message 22 | * @returns 23 | */ 24 | function enrichError( 25 | // @ts-ignore 26 | error = mandatory('error'), 27 | // @ts-ignore 28 | message = mandatory('message') 29 | ) { 30 | const newError = new Error(message); 31 | newError.stack += `\nOriginal error:\n${error.stack}`; 32 | return newError; 33 | } 34 | 35 | // TODO: tests 36 | function flattenArray(arr, recursive = false) { 37 | return arr.reduce( 38 | (acc, val) => 39 | Array.isArray(val) && recursive 40 | ? acc.concat(flattenArray(val)) 41 | : acc.concat(val), 42 | [] 43 | ); 44 | } 45 | 46 | /** 47 | * 48 | * @param {number} ms 49 | * @returns 50 | */ 51 | // @ts-ignore 52 | function sleep(ms = mandatory('ms')) { 53 | return new Promise((resolve) => { 54 | setTimeout(resolve, ms); 55 | }); 56 | } 57 | 58 | /** 59 | * Force loging of unhandledRejection events. 60 | * 61 | * Most likely should be used only for entrypoints. 62 | * 63 | * @param {boolean} exitWithError - Call process.exit(1) after log 64 | */ 65 | function logUnhandledRejections(exitWithError = false) { 66 | /* istanbul ignore next */ 67 | // In Node v7 unhandled promise rejections will terminate the process 68 | if (!process.env.LOG_UNHANDLED_REJECTION) { 69 | process.on('unhandledRejection', (err) => { 70 | // eslint-disable-next-line no-console 71 | console.error('Unhandled Rejection:', err); 72 | 73 | if (exitWithError) { 74 | process.exit(1); 75 | } 76 | }); 77 | 78 | // Avoid memory leak by adding too many listeners 79 | process.env.LOG_UNHANDLED_REJECTION = 'true'; 80 | } 81 | } 82 | 83 | /** 84 | * Force throwing of unhandledRejection events. 85 | * 86 | * Most likely should be used only for entrypoints. 87 | */ 88 | function throwUnhandledRejections() { 89 | /* istanbul ignore next */ 90 | // In Node v7 unhandled promise rejections will terminate the process 91 | if (!process.env.LISTENING_TO_UNHANDLED_REJECTION) { 92 | process.on('unhandledRejection', (err) => { 93 | throw err; 94 | }); 95 | 96 | // Avoid memory leak by adding too many listeners 97 | process.env.LISTENING_TO_UNHANDLED_REJECTION = 'true'; 98 | } 99 | } 100 | 101 | /** 102 | * 103 | * @param {string} prefix 104 | * @returns {object} 105 | */ 106 | // @ts-ignore 107 | function getPrefixedEnvVars(prefix = mandatory('prefix')) { 108 | const prefixPattern = new RegExp(`^${prefix}`, 'i'); 109 | const params = {}; 110 | 111 | Object.entries(process.env).forEach(([k, v]) => { 112 | if (prefixPattern.test(k)) { 113 | params[k.replace(prefixPattern, '')] = v; 114 | } 115 | }); 116 | 117 | return params; 118 | } 119 | 120 | /** 121 | * Convert human friendly time format to ms. 122 | * @param {string} timeString 123 | * @returns 124 | */ 125 | // @ts-ignore 126 | function humanReadableTimeToMS(timeString = mandatory('timeString')) { 127 | if (typeof timeString !== 'string') { 128 | throw new Error( 129 | `Time must be a string(i.e '60s', '10m'). Now: ${typeof timeString}` 130 | ); 131 | } 132 | 133 | const type = timeString[timeString.length - 1]; 134 | const interval = parseInt(timeString.slice(0, timeString.length - 1), 10); 135 | 136 | let intervalMultiplier; 137 | 138 | switch (type) { 139 | case 's': 140 | intervalMultiplier = 1; 141 | break; 142 | case 'm': 143 | intervalMultiplier = 60; 144 | break; 145 | case 'h': 146 | intervalMultiplier = 60 * 60; 147 | break; 148 | case 'd': 149 | intervalMultiplier = 60 * 60 * 24; 150 | break; 151 | case 'w': 152 | intervalMultiplier = 60 * 60 * 24 * 7; 153 | break; 154 | default: 155 | throw new Error( 156 | `Unknown time modifier: "${type}". Available modifiers: s, m, h, d, w` 157 | ); 158 | } 159 | 160 | return interval * 1000 * intervalMultiplier; 161 | } 162 | 163 | /** 164 | * Converts string to RegExp 165 | * @param {string} str 166 | * @returns {RegExp} 167 | */ 168 | // @ts-ignore 169 | function stringToRegExp(str = mandatory('str')) { 170 | const lastSlashIndex = str.lastIndexOf('/'); 171 | const pattern = str.slice(1, lastSlashIndex); 172 | const flags = str.slice(lastSlashIndex + 1); 173 | 174 | return new RegExp(pattern, flags); 175 | } 176 | 177 | /** 178 | * Move file 179 | * @param {string} sourcePath 180 | * @param {string} targetPath 181 | */ 182 | function moveFile(sourcePath, targetPath) { 183 | try { 184 | fs.renameSync(sourcePath, targetPath); 185 | } catch (renameErr) { 186 | try { 187 | if (renameErr.code === 'EXDEV') { 188 | // Source and target paths on different devices 189 | fs.copyFileSync(sourcePath, targetPath); 190 | } else { 191 | throw renameErr; 192 | } 193 | } finally { 194 | fs.unlinkSync(sourcePath); 195 | } 196 | } 197 | } 198 | 199 | /** 200 | * Splits an array into parts 201 | * @param {Array} array 202 | * @param {number} parts 203 | * @returns {any[][]} 204 | */ 205 | function splitArray(array, parts) { 206 | const restElements = array.slice(); 207 | const resultArray = []; 208 | for (let i = 0; i < parts; i += 1) { 209 | const partSize = Math.ceil(restElements.length / (parts - i)); 210 | resultArray.push(restElements.splice(0, partSize)); 211 | } 212 | return resultArray; 213 | } 214 | 215 | module.exports = { 216 | mandatory, 217 | enrichError, 218 | flattenArray, 219 | sleep, 220 | logUnhandledRejections, 221 | throwUnhandledRejections, 222 | getPrefixedEnvVars, 223 | humanReadableTimeToMS, 224 | stringToRegExp, 225 | moveFile, 226 | splitArray, 227 | }; 228 | -------------------------------------------------------------------------------- /src/validators.js: -------------------------------------------------------------------------------- 1 | const serverPattern = /^[0-9A-Za-z.-_-]*$/; 2 | 3 | function isServerAllowed(value) { 4 | return serverPattern.test(value); 5 | } 6 | 7 | function isSchemaAllowed(value) { 8 | return ['http', 'https'].includes(value); 9 | } 10 | 11 | function isDomainAllowed(value) { 12 | return ['www.example.com', 'example.com'].includes(value); 13 | } 14 | 15 | module.exports = { 16 | isServerAllowed, 17 | isSchemaAllowed, 18 | isDomainAllowed, 19 | }; 20 | --------------------------------------------------------------------------------