├── .babelrc ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── Semgrep.yml │ └── ci.yml ├── .gitignore ├── CODEOWNERS ├── LICENSE ├── README.md ├── RELEASING.md ├── package.json ├── src ├── dotenv.js ├── environment.js ├── main.js ├── user-agent.js └── utils.js ├── test ├── data │ ├── test-resource.css │ └── test-resource.js ├── environment-test.js ├── main-test.js ├── user-agent-test.js └── util-test.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"], 3 | "plugins": ["transform-object-assign"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/** 2 | node_modules/** 3 | test/data/test-resource.js 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parserOptions: { 4 | ecmaVersion: 2017, 5 | sourceType: "module" 6 | }, 7 | env: { 8 | es6: true, 9 | node: true, 10 | mocha: true 11 | }, 12 | plugins: ["prettier"], 13 | extends: ["eslint:recommended", "prettier"], 14 | rules: { 15 | "prettier/prettier": [ 16 | "error", 17 | { 18 | singleQuote: true, 19 | trailingComma: "all", 20 | bracketSpacing: false, 21 | printWidth: 100 22 | } 23 | ] 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /.github/workflows/Semgrep.yml: -------------------------------------------------------------------------------- 1 | # Name of this GitHub Actions workflow. 2 | name: Semgrep 3 | 4 | on: 5 | # Scan changed files in PRs (diff-aware scanning): 6 | # The branches below must be a subset of the branches above 7 | pull_request: 8 | branches: ["master", "main"] 9 | push: 10 | branches: ["master", "main"] 11 | schedule: 12 | - cron: '0 6 * * *' 13 | 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | semgrep: 20 | # User definable name of this GitHub Actions job. 21 | permissions: 22 | contents: read # for actions/checkout to fetch code 23 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 24 | name: semgrep/ci 25 | # If you are self-hosting, change the following `runs-on` value: 26 | runs-on: ubuntu-latest 27 | 28 | container: 29 | # A Docker image with Semgrep installed. Do not change this. 30 | image: returntocorp/semgrep 31 | 32 | # Skip any PR created by dependabot to avoid permission issues: 33 | if: (github.actor != 'dependabot[bot]') 34 | 35 | steps: 36 | # Fetch project source with GitHub Actions Checkout. 37 | - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 38 | # Run the "semgrep ci" command on the command line of the docker image. 39 | - run: semgrep ci --sarif --output=semgrep.sarif 40 | env: 41 | # Add the rules that Semgrep uses by setting the SEMGREP_RULES environment variable. 42 | SEMGREP_RULES: p/default # more at semgrep.dev/explore 43 | 44 | - name: Upload SARIF file for GitHub Advanced Security Dashboard 45 | uses: github/codeql-action/upload-sarif@6c089f53dd51dc3fc7e599c3cb5356453a52ca9e # v2.20.0 46 | with: 47 | sarif_file: semgrep.sarif 48 | if: always() -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | os: [ubuntu-latest, windows-latest, macos-latest] 9 | node: [12] 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v1.0.0 13 | - name: Install 14 | run: yarn 15 | - name: Lint 16 | run: yarn lint 17 | - name: Test 18 | run: yarn test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 28 | node_modules 29 | 30 | # Optional npm cache directory 31 | .npm 32 | 33 | # Optional REPL history 34 | .node_repl_history 35 | 36 | # Distribution directory. 37 | dist/ -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @percy/percy-product-reviewers 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Perceptual Inc. 2 | 3 | The MIT License (MIT) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ⚠️ This package has been deprecated in favor of [`@percy/client`](https://github.com/percy/cli/tree/master/packages/client) 2 | 3 | # percy-js 4 | 5 | [![Build Status](https://travis-ci.org/percy/percy-js.svg?branch=master)](https://travis-ci.org/percy/percy-js) 6 | [![Package Status](https://img.shields.io/npm/v/percy-client.svg)](https://www.npmjs.com/package/percy-client) 7 | 8 | JavaScript API client library for [Percy](https://percy.io). 9 | 10 | #### Docs here: [https://percy.io/docs/api/javascript-client](https://percy.io/docs/api/javascript-client) 11 | 12 | ## Testing 13 | 14 | Use `yarn test` to run the tests 15 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | 1. `git checkout -b version-bump` 4 | 1. `yarn login` 5 | 1. `yarn version` - enter new version 6 | 1. `git push origin version-bump` 7 | 1. Issue a PR for `version-bump` and merge. 8 | 1. `git push --tags` (possibly wrong now, improve next time through) 9 | 1. Ensure tests have passed on that tag 10 | 1. [Update the release notes](https://github.com/percy/percy-js/releases) on GitHub 11 | 1. `yarn publish` (leave new version blank), or use `npm publish` if you have 2FA enabled for npm until [this issue](https://github.com/yarnpkg/yarn/issues/4904) is fixed 12 | 1. [Visit npm](https://www.npmjs.com/package/percy-client) and see the new version is live 13 | 1. Update [percy-web](https://github.com/percy/percy-web) to use the new version 14 | 15 | * Announce the new release, 16 | making sure to say "thank you" to the contributors 17 | who helped shape this version! 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "percy-client", 3 | "version": "3.9.0", 4 | "description": "JavaScript API client library for Percy (https://percy.io).", 5 | "main": "dist/main.js", 6 | "scripts": { 7 | "build": "npm run build:compile && npm run build:browserify", 8 | "build:browserify": "browserify --standalone percy-client dist/main.js -o dist/bundle.js", 9 | "build:compile": "babel src --presets babel-preset-es2015 --out-dir dist", 10 | "debug": "mocha debug --require babel-core/register", 11 | "lint": "./node_modules/eslint/bin/eslint.js .", 12 | "lint:fix": "./node_modules/eslint/bin/eslint.js . --fix", 13 | "prepublish": "npm run build", 14 | "tdd": "mocha --require babel-core/register --watch", 15 | "test": "mocha --require babel-core/register" 16 | }, 17 | "lint-staged": { 18 | "*.{js,css}": [ 19 | "./node_modules/eslint/bin/eslint.js --fix --color", 20 | "git add" 21 | ] 22 | }, 23 | "files": [ 24 | "dist/" 25 | ], 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/percy/percy-js.git" 29 | }, 30 | "author": "Perceptual Inc.", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/percy/percy-js/issues" 34 | }, 35 | "homepage": "https://github.com/percy/percy-js", 36 | "devDependencies": { 37 | "babel-cli": "^6.26.0", 38 | "babel-core": "^6.26.0", 39 | "babel-plugin-transform-object-assign": "^6.22.0", 40 | "babel-preset-es2015": "^6.3.13", 41 | "browserify": "^16.2.3", 42 | "eslint": "^6.0.0", 43 | "eslint-config-prettier": "^6.0.0", 44 | "eslint-plugin-prettier": "^3.0.1", 45 | "husky": "^4.2.1", 46 | "lint-staged": "^10.0.7", 47 | "mocha": "^7.1.0", 48 | "nock": "^10.0.6", 49 | "prettier": "^1.12.1", 50 | "sinon": "^9.0.0" 51 | }, 52 | "dependencies": { 53 | "bluebird": "^3.5.1", 54 | "bluebird-retry": "^0.11.0", 55 | "dotenv": "^8.1.0", 56 | "es6-promise-pool": "^2.5.0", 57 | "jssha": "^2.1.0", 58 | "regenerator-runtime": "^0.13.1", 59 | "request": "^2.87.0", 60 | "request-promise": "^4.2.2", 61 | "walk": "^2.3.14" 62 | }, 63 | "husky": { 64 | "hooks": { 65 | "pre-commit": "lint-staged" 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/dotenv.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | 3 | // mimic dotenv-rails file hierarchy 4 | // https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use 5 | export function config() { 6 | let {NODE_ENV: env, PERCY_DISABLE_DOTENV: disable} = process.env; 7 | 8 | // don't load dotenv files when disabled 9 | if (disable) return; 10 | 11 | let paths = [ 12 | env && `.env.${env}.local`, 13 | // .env.local is not loaded in test environments 14 | env === 'test' ? null : '.env.local', 15 | env && `.env.${env}`, 16 | '.env', 17 | ].filter(Boolean); 18 | 19 | for (let path of paths) { 20 | dotenv.config({path}); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/environment.js: -------------------------------------------------------------------------------- 1 | const utils = require('./utils'); 2 | 3 | const GIT_COMMIT_FORMAT = [ 4 | 'COMMIT_SHA:%H', 5 | 'AUTHOR_NAME:%an', 6 | 'AUTHOR_EMAIL:%ae', 7 | 'COMMITTER_NAME:%cn', 8 | 'COMMITTER_EMAIL:%ce', 9 | 'COMMITTED_DATE:%ai', 10 | // Note: order is important, this must come last because the regex is a multiline match. 11 | 'COMMIT_MESSAGE:%B', 12 | ].join('%n'); // git show format uses %n for newlines. 13 | 14 | class Environment { 15 | constructor(env) { 16 | if (!env) { 17 | throw new Error('"env" arg is required to create an Environment.'); 18 | } 19 | this._env = env; 20 | } 21 | 22 | get ci() { 23 | if (this._env.TRAVIS_BUILD_ID) { 24 | return 'travis'; 25 | } else if (this._env.JENKINS_URL && this._env.ghprbPullId) { 26 | // Pull Request Builder plugin. 27 | return 'jenkins-prb'; 28 | } else if (this._env.JENKINS_URL) { 29 | return 'jenkins'; 30 | } else if (this._env.CIRCLECI) { 31 | return 'circle'; 32 | } else if (this._env.CI_NAME && this._env.CI_NAME == 'codeship') { 33 | return 'codeship'; 34 | } else if (this._env.DRONE == 'true') { 35 | return 'drone'; 36 | } else if (this._env.SEMAPHORE == 'true') { 37 | return 'semaphore'; 38 | } else if (this._env.BUILDKITE == 'true') { 39 | return 'buildkite'; 40 | } else if (this._env.HEROKU_TEST_RUN_ID) { 41 | return 'heroku'; 42 | } else if (this._env.GITLAB_CI == 'true') { 43 | return 'gitlab'; 44 | } else if (this._env.TF_BUILD == 'True') { 45 | return 'azure'; 46 | } else if (this._env.APPVEYOR == 'True' || this._env.APPVEYOR == 'true') { 47 | return 'appveyor'; 48 | } else if (this._env.PROBO_ENVIRONMENT == 'TRUE') { 49 | return 'probo'; 50 | } else if (this._env.BITBUCKET_BUILD_NUMBER) { 51 | return 'bitbucket'; 52 | } else if (this._env.GITHUB_ACTIONS == 'true') { 53 | return 'github'; 54 | } else if (this._env.NETLIFY == 'true') { 55 | return 'netlify'; 56 | } else if (this._env.CI) { 57 | // this should always be the last branch 58 | return 'CI/unknown'; 59 | } 60 | 61 | return null; 62 | } 63 | 64 | get ciVersion() { 65 | switch (this.ci) { 66 | case 'github': 67 | return `github/${this._env.PERCY_GITHUB_ACTION || 'unknown'}`; 68 | case 'gitlab': 69 | return `gitlab/${this._env.CI_SERVER_VERSION}`; 70 | case 'semaphore': 71 | return this._env.SEMAPHORE_GIT_SHA ? 'semaphore/2.0' : 'semaphore'; 72 | } 73 | return this.ci; 74 | } 75 | 76 | gitExec(args) { 77 | const child_process = require('child_process'); 78 | try { 79 | let result = child_process.spawnSync('git', args); 80 | if (result.status == 0) { 81 | return result.stdout.toString().trim(); 82 | } else { 83 | return ''; 84 | } 85 | } catch (error) { 86 | return ''; 87 | } 88 | } 89 | 90 | rawCommitData(commitSha) { 91 | // Make sure commitSha is only alphanumeric characters and ^ to prevent command injection. 92 | if (commitSha.length > 100 || !commitSha.match(/^[0-9a-zA-Z^]+$/)) { 93 | return ''; 94 | } 95 | 96 | const args = ['show', commitSha, '--quiet', `--format="${GIT_COMMIT_FORMAT}"`]; 97 | return this.gitExec(args); 98 | } 99 | 100 | // If not running in a git repo, allow undefined for certain commit attributes. 101 | parse(formattedCommitData, regex) { 102 | return ((formattedCommitData && formattedCommitData.match(regex)) || [])[1]; 103 | } 104 | 105 | get commitData() { 106 | // Read the result from environment data 107 | let result = { 108 | branch: this.branch, 109 | sha: this.commitSha, 110 | 111 | // These GIT_ environment vars are from the Jenkins Git Plugin, but could be 112 | // used generically. This behavior may change in the future. 113 | authorName: this._env['GIT_AUTHOR_NAME'], 114 | authorEmail: this._env['GIT_AUTHOR_EMAIL'], 115 | committerName: this._env['GIT_COMMITTER_NAME'], 116 | committerEmail: this._env['GIT_COMMITTER_EMAIL'], 117 | }; 118 | 119 | // Try and get more meta-data from git 120 | let formattedCommitData = ''; 121 | if (this.commitSha) { 122 | formattedCommitData = this.rawCommitData(this.commitSha); 123 | } 124 | if (!formattedCommitData) { 125 | formattedCommitData = this.rawCommitData('HEAD'); 126 | } 127 | if (!formattedCommitData) { 128 | return result; 129 | } 130 | 131 | // If this.commitSha didn't provide a sha, use the one from the commit 132 | if (!result.sha) { 133 | result.sha = this.parse(formattedCommitData, /COMMIT_SHA:(.*)/); 134 | } 135 | 136 | result.message = this.parse(formattedCommitData, /COMMIT_MESSAGE:(.*)/m); 137 | result.committedAt = this.parse(formattedCommitData, /COMMITTED_DATE:(.*)/); 138 | result.authorName = this.parse(formattedCommitData, /AUTHOR_NAME:(.*)/); 139 | result.authorEmail = this.parse(formattedCommitData, /AUTHOR_EMAIL:(.*)/); 140 | result.committerName = this.parse(formattedCommitData, /COMMITTER_NAME:(.*)/); 141 | result.committerEmail = this.parse(formattedCommitData, /COMMITTER_EMAIL:(.*)/); 142 | 143 | return result; 144 | } 145 | 146 | get jenkinsMergeCommitBuild() { 147 | let formattedCommitData = this.rawCommitData('HEAD'); 148 | 149 | if (!formattedCommitData) { 150 | return false; 151 | } 152 | 153 | let authorName = this.parse(formattedCommitData, /AUTHOR_NAME:(.*)/); 154 | let authorEmail = this.parse(formattedCommitData, /AUTHOR_EMAIL:(.*)/); 155 | let message = this.parse(formattedCommitData, /COMMIT_MESSAGE:(.*)/m); 156 | 157 | if (authorName === 'Jenkins' && authorEmail === 'nobody@nowhere') { 158 | // Example merge message: Merge commit 'ec4d24c3d22f3c95e34af95c1fda2d462396d885' into HEAD 159 | if (message.substring(0, 13) === 'Merge commit ' && message.substring(55) === ' into HEAD') { 160 | return true; 161 | } 162 | } 163 | 164 | return false; 165 | } 166 | 167 | get secondToLastCommitSHA() { 168 | let formattedCommitData = this.rawCommitData('HEAD^'); 169 | 170 | if (!formattedCommitData) { 171 | return null; 172 | } 173 | 174 | return this.parse(formattedCommitData, /COMMIT_SHA:(.*)/); 175 | } 176 | 177 | get commitSha() { 178 | if (this._env.PERCY_COMMIT) { 179 | return this._env.PERCY_COMMIT; 180 | } 181 | switch (this.ci) { 182 | case 'travis': 183 | return this._env.TRAVIS_COMMIT; 184 | case 'jenkins-prb': 185 | // Pull Request Builder Plugin OR Git Plugin. 186 | return this._env.ghprbActualCommit || this._env.GIT_COMMIT; 187 | case 'jenkins': 188 | if (this.jenkinsMergeCommitBuild) { 189 | return this.secondToLastCommitSHA; 190 | } 191 | return this._env.GIT_COMMIT; 192 | case 'circle': 193 | return this._env.CIRCLE_SHA1; 194 | case 'codeship': 195 | return this._env.CI_COMMIT_ID; 196 | case 'drone': 197 | return this._env.DRONE_COMMIT; 198 | case 'semaphore': 199 | return this._env.REVISION || this._env.SEMAPHORE_GIT_PR_SHA || this._env.SEMAPHORE_GIT_SHA; 200 | case 'buildkite': { 201 | let commitSha = this._env.BUILDKITE_COMMIT; 202 | // Buildkite mixes SHAs and non-SHAs in BUILDKITE_COMMIT, so we return null if non-SHA. 203 | return commitSha !== 'HEAD' ? this._env.BUILDKITE_COMMIT : null; 204 | } 205 | case 'heroku': 206 | return this._env.HEROKU_TEST_RUN_COMMIT_VERSION; 207 | case 'gitlab': 208 | return this._env.CI_COMMIT_SHA; 209 | case 'azure': 210 | return this._env.SYSTEM_PULLREQUEST_SOURCECOMMITID || this._env.BUILD_SOURCEVERSION; 211 | case 'appveyor': 212 | return this._env.APPVEYOR_PULL_REQUEST_HEAD_COMMIT || this._env.APPVEYOR_REPO_COMMIT; 213 | case 'probo': 214 | case 'netlify': 215 | return this._env.COMMIT_REF; 216 | case 'bitbucket': 217 | return this._env.BITBUCKET_COMMIT; 218 | case 'github': 219 | return this._env.GITHUB_SHA; 220 | } 221 | 222 | return null; 223 | } 224 | 225 | get targetCommitSha() { 226 | return this._env.PERCY_TARGET_COMMIT || null; 227 | } 228 | 229 | get partialBuild() { 230 | let partial = this._env.PERCY_PARTIAL_BUILD; 231 | return !!partial && partial !== '0'; 232 | } 233 | 234 | get branch() { 235 | if (this._env.PERCY_BRANCH) { 236 | return this._env.PERCY_BRANCH; 237 | } 238 | let result = ''; 239 | switch (this.ci) { 240 | case 'travis': 241 | if (this.pullRequestNumber && this._env.TRAVIS_PULL_REQUEST_BRANCH) { 242 | result = this._env.TRAVIS_PULL_REQUEST_BRANCH; 243 | } else { 244 | result = this._env.TRAVIS_BRANCH; 245 | } 246 | break; 247 | case 'jenkins-prb': 248 | result = this._env.ghprbSourceBranch; 249 | break; 250 | case 'jenkins': 251 | result = this._env.CHANGE_BRANCH || this._env.GIT_BRANCH; 252 | break; 253 | case 'circle': 254 | result = this._env.CIRCLE_BRANCH; 255 | break; 256 | case 'codeship': 257 | result = this._env.CI_BRANCH; 258 | break; 259 | case 'drone': 260 | result = this._env.DRONE_BRANCH; 261 | break; 262 | case 'semaphore': 263 | result = 264 | this._env.BRANCH_NAME || 265 | this._env.SEMAPHORE_GIT_PR_BRANCH || 266 | this._env.SEMAPHORE_GIT_BRANCH; 267 | break; 268 | case 'buildkite': 269 | result = this._env.BUILDKITE_BRANCH; 270 | break; 271 | case 'heroku': 272 | result = this._env.HEROKU_TEST_RUN_BRANCH; 273 | break; 274 | case 'gitlab': 275 | result = this._env.CI_COMMIT_REF_NAME; 276 | break; 277 | case 'azure': 278 | result = this._env.SYSTEM_PULLREQUEST_SOURCEBRANCH || this._env.BUILD_SOURCEBRANCHNAME; 279 | break; 280 | case 'appveyor': 281 | result = this._env.APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH || this._env.APPVEYOR_REPO_BRANCH; 282 | break; 283 | case 'probo': 284 | result = this._env.BRANCH_NAME; 285 | break; 286 | case 'bitbucket': 287 | result = this._env.BITBUCKET_BRANCH; 288 | break; 289 | case 'github': 290 | if (this._env.GITHUB_REF && this._env.GITHUB_REF.match(/^refs\//)) { 291 | result = this._env.GITHUB_REF.replace(/^refs\/\w+?\//, ''); 292 | } else { 293 | result = this._env.GITHUB_REF; 294 | } 295 | break; 296 | case 'netlify': 297 | result = this._env.HEAD; 298 | break; 299 | } 300 | 301 | if (result == '') { 302 | result = this.rawBranch(); 303 | } 304 | 305 | if (result == '') { 306 | // Branch not specified 307 | result = null; 308 | } 309 | 310 | return result; 311 | } 312 | 313 | rawBranch() { 314 | return this.gitExec(['rev-parse', '--abbrev-ref', 'HEAD']); 315 | } 316 | 317 | get targetBranch() { 318 | return this._env.PERCY_TARGET_BRANCH || null; 319 | } 320 | 321 | get pullRequestNumber() { 322 | if (this._env.PERCY_PULL_REQUEST) { 323 | return this._env.PERCY_PULL_REQUEST; 324 | } 325 | switch (this.ci) { 326 | case 'travis': 327 | return this._env.TRAVIS_PULL_REQUEST !== 'false' ? this._env.TRAVIS_PULL_REQUEST : null; 328 | case 'jenkins-prb': 329 | return this._env.ghprbPullId; 330 | case 'jenkins': 331 | return this._env.CHANGE_ID || null; 332 | case 'circle': 333 | if (this._env.CI_PULL_REQUESTS && this._env.CI_PULL_REQUESTS !== '') { 334 | return this._env.CI_PULL_REQUESTS.split('/').slice(-1)[0]; 335 | } 336 | break; 337 | case 'codeship': 338 | // Unfortunately, codeship always returns 'false' for CI_PULL_REQUEST. For now, return null. 339 | break; 340 | case 'drone': 341 | return this._env.CI_PULL_REQUEST; 342 | case 'semaphore': 343 | return this._env.PULL_REQUEST_NUMBER || this._env.SEMAPHORE_GIT_PR_NUMBER || null; 344 | case 'buildkite': 345 | return this._env.BUILDKITE_PULL_REQUEST !== 'false' 346 | ? this._env.BUILDKITE_PULL_REQUEST 347 | : null; 348 | case 'gitlab': 349 | return this._env.CI_MERGE_REQUEST_IID || null; 350 | case 'azure': 351 | return ( 352 | this._env.SYSTEM_PULLREQUEST_PULLREQUESTID || 353 | this._env.SYSTEM_PULLREQUEST_PULLREQUESTNUMBER || 354 | null 355 | ); 356 | case 'appveyor': 357 | return this._env.APPVEYOR_PULL_REQUEST_NUMBER || null; 358 | case 'probo': 359 | if (this._env.PULL_REQUEST_LINK && this._env.PULL_REQUEST_LINK !== '') { 360 | return this._env.PULL_REQUEST_LINK.split('/').slice(-1)[0]; 361 | } 362 | break; 363 | case 'bitbucket': 364 | return this._env.BITBUCKET_PR_ID || null; 365 | case 'netlify': 366 | if (this._env.PULL_REQUEST == 'true') { 367 | return this._env.REVIEW_ID; 368 | } 369 | break; 370 | } 371 | return null; 372 | } 373 | 374 | // A nonce which will be the same for all nodes of a parallel build, used to identify shards 375 | // of the same CI build. This is usually just the CI environment build ID. 376 | get parallelNonce() { 377 | if (this._env.PERCY_PARALLEL_NONCE) { 378 | return this._env.PERCY_PARALLEL_NONCE; 379 | } 380 | switch (this.ci) { 381 | case 'travis': 382 | return this._env.TRAVIS_BUILD_NUMBER; 383 | case 'jenkins-prb': 384 | return this._env.BUILD_NUMBER; 385 | case 'jenkins': 386 | if (this._env.BUILD_TAG) { 387 | return utils.reverseString(this._env.BUILD_TAG).substring(0, 60); 388 | } 389 | break; 390 | case 'circle': 391 | return this._env.CIRCLE_WORKFLOW_ID || this._env.CIRCLE_BUILD_NUM; 392 | case 'codeship': 393 | return this._env.CI_BUILD_NUMBER || this._env.CI_BUILD_ID; 394 | case 'drone': 395 | return this._env.DRONE_BUILD_NUMBER; 396 | case 'semaphore': 397 | return ( 398 | this._env.SEMAPHORE_WORKFLOW_ID || 399 | `${this._env.SEMAPHORE_BRANCH_ID}/${this._env.SEMAPHORE_BUILD_NUMBER}` 400 | ); 401 | case 'buildkite': 402 | return this._env.BUILDKITE_BUILD_ID; 403 | case 'heroku': 404 | return this._env.HEROKU_TEST_RUN_ID; 405 | case 'gitlab': 406 | return this._env.CI_PIPELINE_ID; 407 | case 'azure': 408 | return this._env.BUILD_BUILDID; 409 | case 'appveyor': 410 | return this._env.APPVEYOR_BUILD_ID; 411 | case 'probo': 412 | return this._env.BUILD_ID; 413 | case 'bitbucket': 414 | return this._env.BITBUCKET_BUILD_NUMBER; 415 | } 416 | return null; 417 | } 418 | 419 | get parallelTotalShards() { 420 | if (this._env.PERCY_PARALLEL_TOTAL) { 421 | return parseInt(this._env.PERCY_PARALLEL_TOTAL); 422 | } 423 | switch (this.ci) { 424 | case 'travis': 425 | // Support for https://github.com/ArturT/knapsack 426 | if (this._env.CI_NODE_TOTAL) { 427 | return parseInt(this._env.CI_NODE_TOTAL); 428 | } 429 | break; 430 | case 'jenkins-prb': 431 | break; 432 | case 'jenkins': 433 | break; 434 | case 'circle': 435 | if (this._env.CIRCLE_NODE_TOTAL) { 436 | return parseInt(this._env.CIRCLE_NODE_TOTAL); 437 | } 438 | break; 439 | case 'codeship': 440 | if (this._env.CI_NODE_TOTAL) { 441 | return parseInt(this._env.CI_NODE_TOTAL); 442 | } 443 | break; 444 | case 'drone': 445 | break; 446 | case 'semaphore': 447 | if (this._env.SEMAPHORE_THREAD_COUNT) { 448 | return parseInt(this._env.SEMAPHORE_THREAD_COUNT); 449 | } 450 | break; 451 | case 'buildkite': 452 | if (this._env.BUILDKITE_PARALLEL_JOB_COUNT) { 453 | return parseInt(this._env.BUILDKITE_PARALLEL_JOB_COUNT); 454 | } 455 | break; 456 | case 'heroku': 457 | if (this._env.CI_NODE_TOTAL) { 458 | return parseInt(this._env.CI_NODE_TOTAL); 459 | } 460 | break; 461 | case 'azure': 462 | // SYSTEM_TOTALJOBSINPHASE is set for parallel builds and non-parallel matrix builds, so 463 | // check build strategy is parallel by ensuring SYSTEM_PARALLELEXECUTIONTYPE == MultiMachine 464 | if ( 465 | this._env.SYSTEM_PARALLELEXECUTIONTYPE == 'MultiMachine' && 466 | this._env.SYSTEM_TOTALJOBSINPHASE 467 | ) { 468 | return parseInt(this._env.SYSTEM_TOTALJOBSINPHASE); 469 | } 470 | break; 471 | } 472 | return null; 473 | } 474 | } 475 | 476 | module.exports = Environment; 477 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const https = require('https'); 3 | const utils = require('./utils'); 4 | const Environment = require('./environment'); 5 | const UserAgent = require('./user-agent'); 6 | const retry = require('bluebird-retry'); 7 | const requestPromise = require('request-promise'); 8 | const PromisePool = require('es6-promise-pool'); 9 | const regeneratorRuntime = require('regenerator-runtime'); // eslint-disable-line no-unused-vars 10 | const fs = require('fs'); 11 | 12 | require('./dotenv').config(); 13 | 14 | const RETRY_ERROR_CODES = ['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'EHOSTUNREACH', 'EAI_AGAIN']; 15 | const JSON_API_CONTENT_TYPE = 'application/vnd.api+json'; 16 | const CONCURRENCY = 2; 17 | 18 | function retryPredicate(err) { 19 | if (err.statusCode) { 20 | return err.statusCode >= 500 && err.statusCode < 600; 21 | } else if (err.error && !!err.error.code) { 22 | return RETRY_ERROR_CODES.includes(err.error.code); 23 | } else { 24 | return false; 25 | } 26 | } 27 | 28 | class Resource { 29 | constructor(options) { 30 | if (!options.resourceUrl) { 31 | throw new Error('"resourceUrl" arg is required to create a Resource.'); 32 | } 33 | if (!options.sha && !options.content) { 34 | throw new Error('Either "sha" or "content" is required to create a Resource.'); 35 | } 36 | if (/\s/.test(options.resourceUrl)) { 37 | throw new Error('"resourceUrl" arg includes whitespace. It needs to be encoded.'); 38 | } 39 | this.resourceUrl = options.resourceUrl; 40 | this.content = options.content; 41 | this.sha = options.sha || utils.sha256hash(options.content); 42 | this.mimetype = options.mimetype; 43 | this.isRoot = options.isRoot; 44 | 45 | // Temporary convenience attributes, will not be serialized. These are used, for example, 46 | // to hold the local path so reading file contents can be deferred. 47 | this.localPath = options.localPath; 48 | } 49 | 50 | serialize() { 51 | return { 52 | type: 'resources', 53 | id: this.sha, 54 | attributes: { 55 | 'resource-url': this.resourceUrl, 56 | mimetype: this.mimetype || null, 57 | 'is-root': this.isRoot || null, 58 | }, 59 | }; 60 | } 61 | } 62 | 63 | class PercyClient { 64 | constructor(options) { 65 | options = options || {}; 66 | this.token = options.token; 67 | this.apiUrl = options.apiUrl || 'https://percy.io/api/v1'; 68 | this.environment = options.environment || new Environment(process.env); 69 | this._httpClient = requestPromise; 70 | this._httpModule = this.apiUrl.indexOf('http://') === 0 ? http : https; 71 | // A custom HttpAgent with pooling and keepalive. 72 | this._httpAgent = new this._httpModule.Agent({ 73 | maxSockets: 5, 74 | keepAlive: true, 75 | }); 76 | this._clientInfo = options.clientInfo; 77 | this._environmentInfo = options.environmentInfo; 78 | this._sdkClientInfo = null; 79 | this._sdkEnvironmentInfo = null; 80 | } 81 | 82 | _headers(headers) { 83 | return Object.assign( 84 | { 85 | Authorization: `Token token=${this.token}`, 86 | 'User-Agent': new UserAgent(this).toString(), 87 | }, 88 | headers, 89 | ); 90 | } 91 | 92 | _httpGet(uri) { 93 | let requestOptions = { 94 | method: 'GET', 95 | uri: uri, 96 | headers: this._headers(), 97 | json: true, 98 | resolveWithFullResponse: true, 99 | agent: this._httpAgent, 100 | }; 101 | 102 | return retry(this._httpClient, { 103 | context: this, 104 | args: [uri, requestOptions], 105 | interval: 50, 106 | max_tries: 5, 107 | throw_original: true, 108 | predicate: retryPredicate, 109 | }); 110 | } 111 | 112 | _httpPost(uri, data) { 113 | let requestOptions = { 114 | method: 'POST', 115 | uri: uri, 116 | body: data, 117 | headers: this._headers({'Content-Type': JSON_API_CONTENT_TYPE}), 118 | json: true, 119 | resolveWithFullResponse: true, 120 | agent: this._httpAgent, 121 | }; 122 | 123 | return retry(this._httpClient, { 124 | context: this, 125 | args: [uri, requestOptions], 126 | interval: 50, 127 | max_tries: 5, 128 | throw_original: true, 129 | predicate: retryPredicate, 130 | }); 131 | } 132 | 133 | createBuild(options) { 134 | let parallelNonce = this.environment.parallelNonce; 135 | let parallelTotalShards = this.environment.parallelTotalShards; 136 | 137 | // Only pass parallelism data if it all exists. 138 | if (!parallelNonce || !parallelTotalShards) { 139 | parallelNonce = null; 140 | parallelTotalShards = null; 141 | } 142 | 143 | options = options || {}; 144 | 145 | const commitData = options['commitData'] || this.environment.commitData; 146 | 147 | let data = { 148 | data: { 149 | type: 'builds', 150 | attributes: { 151 | branch: commitData.branch, 152 | 'target-branch': this.environment.targetBranch, 153 | 'target-commit-sha': this.environment.targetCommitSha, 154 | 'commit-sha': commitData.sha, 155 | 'commit-committed-at': commitData.committedAt, 156 | 'commit-author-name': commitData.authorName, 157 | 'commit-author-email': commitData.authorEmail, 158 | 'commit-committer-name': commitData.committerName, 159 | 'commit-committer-email': commitData.committerEmail, 160 | 'commit-message': commitData.message, 161 | 'pull-request-number': this.environment.pullRequestNumber, 162 | 'parallel-nonce': parallelNonce, 163 | 'parallel-total-shards': parallelTotalShards, 164 | partial: this.environment.partialBuild, 165 | }, 166 | }, 167 | }; 168 | 169 | if (options.resources) { 170 | data['data']['relationships'] = { 171 | resources: { 172 | data: options.resources.map(function(resource) { 173 | return resource.serialize(); 174 | }), 175 | }, 176 | }; 177 | } 178 | 179 | return this._httpPost(`${this.apiUrl}/builds/`, data); 180 | } 181 | 182 | // This method is unavailable to normal write-only project tokens. 183 | getBuild(buildId) { 184 | return this._httpGet(`${this.apiUrl}/builds/${buildId}`); 185 | } 186 | 187 | // This method is unavailable to normal write-only project tokens. 188 | getBuilds(project, filter) { 189 | filter = filter || {}; 190 | let queryString = Object.keys(filter) 191 | .map(key => { 192 | if (Array.isArray(filter[key])) { 193 | // If filter value is an array, match Percy API's format expectations of: 194 | // filter[key][]=value1&filter[key][]=value2 195 | return filter[key].map(array_value => `filter[${key}][]=${array_value}`).join('&'); 196 | } else { 197 | return 'filter[' + key + ']=' + filter[key]; 198 | } 199 | }) 200 | .join('&'); 201 | 202 | if (queryString.length > 0) { 203 | queryString = '?' + queryString; 204 | } 205 | 206 | return this._httpGet(`${this.apiUrl}/projects/${project}/builds${queryString}`); 207 | } 208 | 209 | makeResource(options) { 210 | return new Resource(options); 211 | } 212 | 213 | // Synchronously walks a directory of compiled assets and returns an array of Resource objects. 214 | gatherBuildResources(rootDir, options) { 215 | return utils.gatherBuildResources(this, rootDir, options); 216 | } 217 | 218 | uploadResource(buildId, content) { 219 | let sha = utils.sha256hash(content); 220 | let data = { 221 | data: { 222 | type: 'resources', 223 | id: sha, 224 | attributes: { 225 | 'base64-content': utils.base64encode(content), 226 | }, 227 | }, 228 | }; 229 | 230 | return this._httpPost(`${this.apiUrl}/builds/${buildId}/resources/`, data); 231 | } 232 | 233 | uploadResources(buildId, resources) { 234 | const _this = this; 235 | function* generatePromises() { 236 | for (const resource of resources) { 237 | const content = resource.localPath ? fs.readFileSync(resource.localPath) : resource.content; 238 | yield _this.uploadResource(buildId, content); 239 | } 240 | } 241 | 242 | const pool = new PromisePool(generatePromises(), CONCURRENCY); 243 | return pool.start(); 244 | } 245 | 246 | uploadMissingResources(buildId, response, resources) { 247 | const missingResourceShas = utils.getMissingResources(response); 248 | if (!missingResourceShas.length) { 249 | return Promise.resolve(); 250 | } 251 | 252 | const resourcesBySha = resources.reduce((map, resource) => { 253 | map[resource.sha] = resource; 254 | return map; 255 | }, {}); 256 | const missingResources = missingResourceShas.map(resource => resourcesBySha[resource.id]); 257 | 258 | return this.uploadResources(buildId, missingResources); 259 | } 260 | 261 | createSnapshot(buildId, resources, options) { 262 | options = options || {}; 263 | resources = resources || []; 264 | 265 | let data = { 266 | data: { 267 | type: 'snapshots', 268 | attributes: { 269 | name: options.name || null, 270 | 'enable-javascript': options.enableJavaScript || null, 271 | widths: options.widths || null, 272 | 'minimum-height': options.minimumHeight || null, 273 | }, 274 | relationships: { 275 | resources: { 276 | data: resources.map(function(resource) { 277 | return resource.serialize(); 278 | }), 279 | }, 280 | }, 281 | }, 282 | }; 283 | 284 | this._sdkClientInfo = options.clientInfo; 285 | this._sdkEnvironmentInfo = options.environmentInfo; 286 | return this._httpPost(`${this.apiUrl}/builds/${buildId}/snapshots/`, data); 287 | } 288 | 289 | finalizeSnapshot(snapshotId) { 290 | return this._httpPost(`${this.apiUrl}/snapshots/${snapshotId}/finalize`, {}); 291 | } 292 | 293 | finalizeBuild(buildId, options) { 294 | options = options || {}; 295 | let allShards = options.allShards || false; 296 | let query = allShards ? '?all-shards=true' : ''; 297 | return this._httpPost(`${this.apiUrl}/builds/${buildId}/finalize${query}`, {}); 298 | } 299 | } 300 | 301 | module.exports = PercyClient; 302 | -------------------------------------------------------------------------------- /src/user-agent.js: -------------------------------------------------------------------------------- 1 | import {version} from '../package.json'; 2 | 3 | class UserAgent { 4 | constructor(client) { 5 | if (!client) { 6 | throw new Error('"client" arg is required to create a UserAgent.'); 7 | } 8 | this._client = client; 9 | } 10 | 11 | toString() { 12 | let client = [ 13 | `Percy/${this._apiVersion()}`, 14 | this._client._sdkClientInfo, 15 | this._client._clientInfo, 16 | `percy-js/${version}`, 17 | ] 18 | .filter(el => el != null) 19 | .join(' '); 20 | 21 | let environment = [ 22 | this._client._sdkEnvironmentInfo, 23 | this._client._environmentInfo, 24 | `node/${this._nodeVersion()}`, 25 | this._client.environment.ciVersion, 26 | ] 27 | .filter(el => el != null) 28 | .join('; '); 29 | 30 | return `${client} (${environment})`; 31 | } 32 | 33 | _nodeVersion() { 34 | return process.version; 35 | } 36 | 37 | _apiVersion() { 38 | return /\w+$/.exec(this._client.apiUrl); 39 | } 40 | } 41 | 42 | module.exports = UserAgent; 43 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const walk = require('walk'); 5 | 6 | const MAX_FILE_SIZE_BYTES = 26214400; // 25MB. 7 | 8 | module.exports = { 9 | sha256hash(content) { 10 | return crypto 11 | .createHash('sha256') 12 | .update(content, 'utf8') 13 | .digest('hex'); 14 | }, 15 | 16 | base64encode(content) { 17 | return Buffer.from(content).toString('base64'); 18 | }, 19 | 20 | getMissingResources(response) { 21 | return ( 22 | (response && 23 | response.body && 24 | response.body.data && 25 | response.body.data.relationships && 26 | response.body.data.relationships['missing-resources'] && 27 | response.body.data.relationships['missing-resources'].data) || 28 | [] 29 | ); 30 | }, 31 | 32 | // Synchronously walk a directory of compiled assets, read each file and calculate its SHA 256 33 | // hash, and create an array of Resource objects. 34 | gatherBuildResources(percyClient, rootDir, options = {}) { 35 | // The base of the URL that will be prepended to every resource URL, such as "/assets". 36 | options.baseUrlPath = options.baseUrlPath || ''; 37 | options.skippedPathRegexes = options.skippedPathRegexes || []; 38 | options.followLinks = options.followLinks || true; 39 | 40 | let resources = []; 41 | 42 | let fileWalker = function(root, fileStats, next) { 43 | let absolutePath = path.join(root, fileStats.name); 44 | let resourceUrl = absolutePath.replace(rootDir, ''); 45 | 46 | if (path.sep == '\\') { 47 | // Windows support: transform filesystem backslashes into forward-slashes for the URL. 48 | resourceUrl = resourceUrl.replace(/\\/g, '/'); 49 | } 50 | 51 | // Prepend the baseUrlPath if it is given. We normalize it to make sure it does not have 52 | // a trailing slash, or it's a blank string. 53 | let normalizedBaseUrlPath = (options.baseUrlPath || '/').replace(/\/$/, ''); 54 | resourceUrl = normalizedBaseUrlPath + resourceUrl; 55 | 56 | // Skip excluded paths. 57 | for (let i in options.skippedPathRegexes) { 58 | if (resourceUrl.match(options.skippedPathRegexes[i])) { 59 | next(); 60 | return; 61 | } 62 | } 63 | 64 | // Skip large files. 65 | if (fs.statSync(absolutePath)['size'] > MAX_FILE_SIZE_BYTES) { 66 | // eslint-disable-next-line no-console 67 | console.warn('\n[percy][WARNING] Skipping large build resource: ', resourceUrl); 68 | return; 69 | } 70 | 71 | // Note: this is synchronous and potentially memory intensive, but we don't keep a 72 | // reference to the content around so each should be garbage collected. Reevaluate? 73 | let content = fs.readFileSync(absolutePath); 74 | let sha = crypto 75 | .createHash('sha256') 76 | .update(content) 77 | .digest('hex'); 78 | 79 | let resource = percyClient.makeResource({ 80 | resourceUrl: encodeURI(resourceUrl), 81 | sha: sha, 82 | localPath: absolutePath, 83 | }); 84 | 85 | resources.push(resource); 86 | next(); 87 | }; 88 | 89 | let walkOptions = { 90 | // Follow symlinks because assets may be just symlinks. 91 | followLinks: options.followLinks, 92 | listeners: { 93 | file: fileWalker, 94 | }, 95 | }; 96 | walk.walkSync(rootDir, walkOptions); 97 | 98 | return resources; 99 | }, 100 | 101 | reverseString(str) { 102 | return str 103 | .split('') 104 | .reverse() 105 | .join(''); 106 | }, 107 | }; 108 | -------------------------------------------------------------------------------- /test/data/test-resource.css: -------------------------------------------------------------------------------- 1 | .test { 2 | background: red; 3 | } 4 | -------------------------------------------------------------------------------- /test/data/test-resource.js: -------------------------------------------------------------------------------- 1 | window.testFunction = function() { 2 | return 'test'; 3 | }; 4 | -------------------------------------------------------------------------------- /test/environment-test.js: -------------------------------------------------------------------------------- 1 | let path = require('path'); 2 | let Environment = require(path.join(__dirname, '..', 'src', 'environment')); 3 | let assert = require('assert'); 4 | let sinon = require('sinon'); 5 | 6 | describe('Environment', function() { 7 | let environment; 8 | 9 | afterEach(function() { 10 | environment = null; 11 | }); 12 | 13 | context('no known environment', function() { 14 | beforeEach(function() { 15 | environment = new Environment({}); 16 | }); 17 | 18 | it('has the correct properties', function() { 19 | assert(typeof environment.branch == 'string'); 20 | assert.strictEqual(environment.ci, null); 21 | assert.strictEqual(environment.commitSha, null); 22 | assert.strictEqual(environment.targetBranch, null); 23 | assert.strictEqual(environment.pullRequestNumber, null); 24 | assert.strictEqual(environment.parallelNonce, null); 25 | assert.strictEqual(environment.parallelTotalShards, null); 26 | assert.strictEqual(environment.partialBuild, false); 27 | }); 28 | 29 | it('commitData reads and parses live git commit information', function() { 30 | // This checks the real information coming from rawCommitData is in the 31 | // expected format and can be correctly parsed. 32 | // Test for typeof string here rather than specific values because 33 | // these tests check that git info can be read from the local filesystem, 34 | // so all of the values will change between commits. 35 | let commit = environment.commitData; 36 | assert(typeof commit.branch == 'string'); 37 | assert(typeof commit.sha == 'string'); 38 | assert(typeof commit.authorName == 'string'); 39 | assert(typeof commit.authorEmail == 'string'); 40 | assert(typeof commit.committerName == 'string'); 41 | assert(typeof commit.committerEmail == 'string'); 42 | assert(typeof commit.committedAt == 'string'); 43 | assert(typeof commit.message == 'string'); 44 | }); 45 | 46 | it('commitData correctly parses rawCommitData', function() { 47 | let commitStub = sinon.stub(environment, 'rawCommitData') 48 | .returns(`COMMIT_SHA:620804c296827012104931d44b001f20eda9dbeb 49 | AUTHOR_NAME:Tim Haines 50 | AUTHOR_EMAIL:timhaines@example.com 51 | COMMITTER_NAME:Other Tim Haines 52 | COMMITTER_EMAIL:othertimhaines@example.com 53 | COMMITTED_DATE:2018-03-07 16:40:12 -0800 54 | COMMIT_MESSAGE:Sinon stubs are lovely`); 55 | let branchStub = sinon.stub(environment, 'rawBranch').returns('test-branch'); 56 | let commit = environment.commitData; 57 | assert.strictEqual(commit.branch, 'test-branch'); 58 | assert.strictEqual(commit.sha, '620804c296827012104931d44b001f20eda9dbeb'); 59 | assert.strictEqual(commit.authorName, 'Tim Haines'); 60 | assert.strictEqual(commit.authorEmail, 'timhaines@example.com'); 61 | assert.strictEqual(commit.committerName, 'Other Tim Haines'); 62 | assert.strictEqual(commit.committerEmail, 'othertimhaines@example.com'); 63 | assert.strictEqual(commit.committedAt, '2018-03-07 16:40:12 -0800'); 64 | assert.strictEqual(commit.message, 'Sinon stubs are lovely'); 65 | commitStub.restore(); 66 | branchStub.restore(); 67 | }); 68 | 69 | it('commitData returns branch only when git commit cannot be read', function() { 70 | let commitStub = sinon.stub(environment, 'rawCommitData').returns(''); 71 | let branchStub = sinon.stub(environment, 'rawBranch').returns('test-branch'); 72 | let commit = environment.commitData; 73 | assert.strictEqual(commit.branch, 'test-branch'); 74 | assert.strictEqual(commit.sha, null); 75 | commitStub.restore(); 76 | branchStub.restore(); 77 | }); 78 | 79 | it('branch returns null when git branch cannot be read', function() { 80 | let branchStub = sinon.stub(environment, 'rawBranch').returns(''); 81 | assert.strictEqual(environment.branch, null); 82 | branchStub.restore(); 83 | }); 84 | 85 | it('jenkinsMergeCommitBuild returns true for merge commits', function() { 86 | let commitStub = sinon.stub(environment, 'rawCommitData'); 87 | commitStub.returns(`COMMIT_SHA:jenkins-merge-commit-sha 88 | AUTHOR_NAME:Jenkins 89 | AUTHOR_EMAIL:nobody@nowhere 90 | COMMITTER_NAME:Jenkins 91 | COMMITTER_EMAIL:nobody@nowhere 92 | COMMITTED_DATE:2018-03-07 16:40:12 -0800 93 | COMMIT_MESSAGE:Merge commit 'ec4d24c3d22f3c95e34af95c1fda2d462396d885' into HEAD`); 94 | 95 | assert.strictEqual(environment.jenkinsMergeCommitBuild, true); 96 | 97 | commitStub.restore(); 98 | }); 99 | 100 | it('jenkinsMergeCommitBuild returns false for non merge commits', function() { 101 | let commitStub = sinon.stub(environment, 'rawCommitData'); 102 | commitStub.returns(`COMMIT_SHA:jenkins-non-merge-commit-sha 103 | AUTHOR_NAME:Fred 104 | AUTHOR_EMAIL:fred@example.com 105 | COMMITTER_NAME:Fred 106 | COMMITTER_EMAIL:fred@example.com 107 | COMMITTED_DATE:2018-03-07 16:40:12 -0800 108 | COMMIT_MESSAGE:A shiny new feature`); 109 | 110 | assert.strictEqual(environment.jenkinsMergeCommitBuild, false); 111 | 112 | commitStub.restore(); 113 | }); 114 | 115 | it('secondToLastCommitSHA returns commit SHA for HEAD^', function() { 116 | let commitStub = sinon.stub(environment, 'rawCommitData'); 117 | commitStub.withArgs('HEAD^').returns(`COMMIT_SHA:second-to-last-merge-commit-sha 118 | AUTHOR_NAME:Fred 119 | AUTHOR_EMAIL:fred@example.com 120 | COMMITTER_NAME:Fred 121 | COMMITTER_EMAIL:fred@example.com 122 | COMMITTED_DATE:2018-03-07 16:40:12 -0800 123 | COMMIT_MESSAGE:A shiny new feature`); 124 | 125 | assert.strictEqual(environment.secondToLastCommitSHA, 'second-to-last-merge-commit-sha'); 126 | 127 | commitStub.restore(); 128 | }); 129 | 130 | it('rawCommitData will return a value for HEAD^', function() { 131 | // Reads the real commit data from this repository. The value changes, but should not be ''. 132 | var commitData = environment.rawCommitData('HEAD^'); 133 | assert(typeof commitData == 'string'); 134 | assert.notEqual(commitData, ''); 135 | }); 136 | 137 | it('rawCommitData will return an empty string for an invalid commitSha', function() { 138 | var commitData = environment.rawCommitData('abc;invalid command'); 139 | assert(typeof commitData == 'string'); 140 | assert.strictEqual(commitData, ''); 141 | }); 142 | }); 143 | 144 | context('PERCY_* env vars are set', function() { 145 | beforeEach(function() { 146 | environment = new Environment({ 147 | PERCY_COMMIT: 'percy-commit', 148 | PERCY_BRANCH: 'percy-branch', 149 | PERCY_TARGET_BRANCH: 'percy-target-branch', 150 | PERCY_TARGET_COMMIT: 'percy-target-commit', 151 | PERCY_PULL_REQUEST: '256', 152 | PERCY_PARALLEL_NONCE: 'percy-nonce', 153 | PERCY_PARALLEL_TOTAL: '3', 154 | PERCY_PARTIAL_BUILD: '1', 155 | GIT_AUTHOR_NAME: 'git author', 156 | GIT_AUTHOR_EMAIL: 'gitauthor@example.com', 157 | GIT_COMMITTER_NAME: 'git committer', 158 | GIT_COMMITTER_EMAIL: 'git committer@example.com', 159 | }); 160 | }); 161 | 162 | it('allows override with percy env vars', function() { 163 | assert.strictEqual(environment.commitSha, 'percy-commit'); 164 | assert.strictEqual(environment.targetCommitSha, 'percy-target-commit'); 165 | assert.strictEqual(environment.branch, 'percy-branch'); 166 | assert.strictEqual(environment.targetBranch, 'percy-target-branch'); 167 | assert.strictEqual(environment.pullRequestNumber, '256'); 168 | assert.strictEqual(environment.parallelNonce, 'percy-nonce'); 169 | assert.strictEqual(environment.parallelTotalShards, 3); 170 | assert.strictEqual(environment.partialBuild, true); 171 | }); 172 | 173 | it('commitData returns ENV vars when git cannot be read', function() { 174 | let commitStub = sinon.stub(environment, 'rawCommitData').returns(''); 175 | let branchStub = sinon.stub(environment, 'rawBranch').returns(''); 176 | 177 | let commit = environment.commitData; 178 | assert.strictEqual(commit.branch, 'percy-branch'); 179 | assert.strictEqual(commit.sha, 'percy-commit'); 180 | assert.strictEqual(commit.authorName, 'git author'); 181 | assert.strictEqual(commit.authorEmail, 'gitauthor@example.com'); 182 | assert.strictEqual(commit.committerName, 'git committer'); 183 | assert.strictEqual(commit.committerEmail, 'git committer@example.com'); 184 | assert.strictEqual(commit.committedAt, undefined); 185 | assert.strictEqual(commit.message, undefined); 186 | 187 | commitStub.restore(); 188 | branchStub.restore(); 189 | }); 190 | }); 191 | 192 | context('in an unknown CI', function() { 193 | beforeEach(function() { 194 | environment = new Environment({ 195 | CI: 'true', 196 | }); 197 | }); 198 | 199 | it('returns the right CI value', function() { 200 | assert.strictEqual(environment.ci, 'CI/unknown'); 201 | }); 202 | }); 203 | 204 | context('in a known CI env with CI = true', function() { 205 | beforeEach(function() { 206 | environment = new Environment({ 207 | TRAVIS_BUILD_ID: '1234`', 208 | CI: 'true', 209 | }); 210 | }); 211 | 212 | it('returns the right CI value', function() { 213 | assert.notEqual(environment.ci, 'CI/unknown'); 214 | assert.strictEqual(environment.ci, 'travis'); 215 | }); 216 | }); 217 | 218 | context('in GitHub Actions', function() { 219 | beforeEach(function() { 220 | environment = new Environment({ 221 | PERCY_GITHUB_ACTION: 'test-action/0.1.0', 222 | GITHUB_ACTIONS: 'true', 223 | GITHUB_SHA: 'github-commit-sha', 224 | GITHUB_REF: 'refs/head/github/branch', 225 | }); 226 | }); 227 | 228 | it('has the correct properties', function() { 229 | assert.strictEqual(environment.ci, 'github'); 230 | assert.strictEqual(environment.ciVersion, 'github/test-action/0.1.0'); 231 | assert.strictEqual(environment.commitSha, 'github-commit-sha'); 232 | assert.strictEqual(environment.targetCommitSha, null); 233 | assert.strictEqual(environment.branch, 'github/branch'); 234 | assert.strictEqual(environment.targetBranch, null); 235 | assert.strictEqual(environment.pullRequestNumber, null); 236 | assert.strictEqual(environment.parallelNonce, null); 237 | assert.strictEqual(environment.parallelTotalShards, null); 238 | }); 239 | }); 240 | 241 | context('in Travis CI', function() { 242 | beforeEach(function() { 243 | environment = new Environment({ 244 | TRAVIS_BUILD_ID: '1234', 245 | TRAVIS_BUILD_NUMBER: 'build-number', 246 | TRAVIS_PULL_REQUEST: 'false', 247 | TRAVIS_PULL_REQUEST_BRANCH: '', 248 | TRAVIS_COMMIT: 'travis-commit-sha', 249 | TRAVIS_BRANCH: 'travis-branch', 250 | CI_NODE_TOTAL: '3', 251 | }); 252 | }); 253 | 254 | it('has the correct properties', function() { 255 | assert.strictEqual(environment.ci, 'travis'); 256 | assert.strictEqual(environment.commitSha, 'travis-commit-sha'); 257 | assert.strictEqual(environment.targetCommitSha, null); 258 | assert.strictEqual(environment.branch, 'travis-branch'); 259 | assert.strictEqual(environment.targetBranch, null); 260 | assert.strictEqual(environment.pullRequestNumber, null); 261 | assert.strictEqual(environment.parallelNonce, 'build-number'); 262 | assert.strictEqual(environment.parallelTotalShards, 3); 263 | }); 264 | 265 | context('in Pull Request build', function() { 266 | beforeEach(function() { 267 | environment._env.TRAVIS_PULL_REQUEST = '256'; 268 | environment._env.TRAVIS_PULL_REQUEST_BRANCH = 'travis-pr-branch'; 269 | }); 270 | 271 | it('has the correct properties', function() { 272 | assert.strictEqual(environment.pullRequestNumber, '256'); 273 | assert.strictEqual(environment.branch, 'travis-pr-branch'); 274 | assert.strictEqual(environment.targetBranch, null); 275 | assert.strictEqual(environment.commitSha, 'travis-commit-sha'); 276 | assert.strictEqual(environment.targetCommitSha, null); 277 | }); 278 | }); 279 | }); 280 | 281 | context('in Jenkins', function() { 282 | beforeEach(function() { 283 | environment = new Environment({ 284 | JENKINS_URL: 'http://jenkins.local/', 285 | GIT_COMMIT: 'jenkins-commit-sha', 286 | GIT_BRANCH: 'jenkins-branch', 287 | }); 288 | }); 289 | 290 | it('has the correct properties', function() { 291 | assert.strictEqual(environment.ci, 'jenkins'); 292 | assert.strictEqual(environment.commitSha, 'jenkins-commit-sha'); 293 | assert.strictEqual(environment.targetCommitSha, null); 294 | assert.strictEqual(environment.branch, 'jenkins-branch'); 295 | assert.strictEqual(environment.targetBranch, null); 296 | assert.strictEqual(environment.pullRequestNumber, null); 297 | assert.strictEqual(environment.parallelNonce, null); 298 | assert.strictEqual(environment.parallelTotalShards, null); 299 | }); 300 | 301 | context('in Pull Request build (non-merge)', function() { 302 | beforeEach(function() { 303 | environment._env.CHANGE_ID = '111'; 304 | environment._env.GIT_COMMIT = 'jenkins-non-merge-pr-commit-sha'; 305 | environment._env.CHANGE_BRANCH = 'jenkins-non-merge-pr-branch'; 306 | }); 307 | 308 | it('has the correct properties', function() { 309 | let jenkinsMergeCommitBuildStub = sinon.stub(environment, 'jenkinsMergeCommitBuild'); 310 | jenkinsMergeCommitBuildStub.get(function getterFn() { 311 | return false; 312 | }); 313 | 314 | assert.strictEqual(environment.pullRequestNumber, '111'); 315 | assert.strictEqual(environment.branch, 'jenkins-non-merge-pr-branch'); 316 | assert.strictEqual(environment.targetBranch, null); 317 | assert.strictEqual(environment.commitSha, 'jenkins-non-merge-pr-commit-sha'); 318 | assert.strictEqual(environment.targetCommitSha, null); 319 | 320 | jenkinsMergeCommitBuildStub.restore(); 321 | }); 322 | }); 323 | 324 | context('in Pull Request build (merge commit)', function() { 325 | beforeEach(function() { 326 | environment._env.CHANGE_ID = '123'; 327 | environment._env.GIT_COMMIT = 'jenkins-merge-pr-merge-commit-sha'; 328 | environment._env.CHANGE_BRANCH = 'jenkins-merge-pr-branch'; 329 | }); 330 | 331 | it('has the correct properties', function() { 332 | let jenkinsMergeCommitBuildStub = sinon.stub(environment, 'jenkinsMergeCommitBuild'); 333 | jenkinsMergeCommitBuildStub.get(function getterFn() { 334 | return true; 335 | }); 336 | 337 | let commitStub = sinon.stub(environment, 'rawCommitData'); 338 | 339 | commitStub.withArgs('HEAD^').returns(`COMMIT_SHA:jenkins-merge-pr-REAL-commit-sha 340 | AUTHOR_NAME:Tim Haines 341 | AUTHOR_EMAIL:timhaines@example.com 342 | COMMITTER_NAME:Other Tim Haines 343 | COMMITTER_EMAIL:othertimhaines@example.com 344 | COMMITTED_DATE:2018-03-07 16:40:12 -0800 345 | COMMIT_MESSAGE:Sinon stubs are lovely`); 346 | 347 | assert.strictEqual(environment.pullRequestNumber, '123'); 348 | assert.strictEqual(environment.branch, 'jenkins-merge-pr-branch'); 349 | assert.strictEqual(environment.targetBranch, null); 350 | assert.strictEqual(environment.commitSha, 'jenkins-merge-pr-REAL-commit-sha'); 351 | assert.strictEqual(environment.targetCommitSha, null); 352 | 353 | commitStub.restore(); 354 | jenkinsMergeCommitBuildStub.restore(); 355 | }); 356 | }); 357 | 358 | context('in parallel build', function() { 359 | beforeEach(function() { 360 | // Should be reversed and truncated for parallelNonce 361 | // Real BUILD_TAG example: jenkins-Percy-example-percy-puppeteer-PR-34-merge-2 362 | environment._env.BUILD_TAG = 363 | 'XXXb7b7a42f90d49dbe8767c2aebbf7-project-branch-build-number-123'; 364 | }); 365 | 366 | it('has the correct properties', function() { 367 | assert.strictEqual( 368 | environment.parallelNonce, 369 | '321-rebmun-dliub-hcnarb-tcejorp-7fbbea2c7678ebd94d09f24a7b7b', 370 | ); 371 | assert.strictEqual(environment.parallelTotalShards, null); 372 | }); 373 | }); 374 | 375 | context('with pull request builder plugin', function() { 376 | beforeEach(function() { 377 | environment = new Environment({ 378 | JENKINS_URL: 'http://jenkins.local/', 379 | BUILD_NUMBER: '111', 380 | ghprbPullId: '256', 381 | ghprbActualCommit: 'jenkins-prb-commit-sha', 382 | ghprbSourceBranch: 'jenkins-prb-branch', 383 | }); 384 | }); 385 | 386 | it('has the correct properties', function() { 387 | assert.strictEqual(environment.ci, 'jenkins-prb'); 388 | assert.strictEqual(environment.commitSha, 'jenkins-prb-commit-sha'); 389 | assert.strictEqual(environment.targetCommitSha, null); 390 | assert.strictEqual(environment.branch, 'jenkins-prb-branch'); 391 | assert.strictEqual(environment.targetBranch, null); 392 | assert.strictEqual(environment.pullRequestNumber, '256'); 393 | assert.strictEqual(environment.parallelNonce, '111'); 394 | assert.strictEqual(environment.parallelTotalShards, null); 395 | }); 396 | }); 397 | }); 398 | 399 | context('in Circle CI', function() { 400 | beforeEach(function() { 401 | environment = new Environment({ 402 | CIRCLECI: 'true', 403 | CIRCLE_BRANCH: 'circle-branch', 404 | CIRCLE_SHA1: 'circle-commit-sha', 405 | CIRCLE_BUILD_NUM: 'build-number', 406 | CIRCLE_NODE_TOTAL: '3', 407 | CI_PULL_REQUESTS: 'https://github.com/owner/repo-name/pull/123', 408 | }); 409 | }); 410 | 411 | it('has the correct properties', function() { 412 | assert.strictEqual(environment.ci, 'circle'); 413 | assert.strictEqual(environment.commitSha, 'circle-commit-sha'); 414 | assert.strictEqual(environment.targetCommitSha, null); 415 | assert.strictEqual(environment.branch, 'circle-branch'); 416 | assert.strictEqual(environment.targetBranch, null); 417 | assert.strictEqual(environment.pullRequestNumber, '123'); 418 | assert.strictEqual(environment.parallelNonce, 'build-number'); 419 | assert.strictEqual(environment.parallelTotalShards, 3); 420 | 421 | // Should be null if empty. 422 | environment._env.CIRCLE_NODE_TOTAL = ''; 423 | assert.strictEqual(environment.parallelTotalShards, null); 424 | }); 425 | 426 | context('in Circle 2.0', function() { 427 | it('has the correct properties', function() { 428 | environment._env.CIRCLE_WORKFLOW_ID = 'circle-workflow-workspace-id'; 429 | assert.strictEqual(environment.parallelNonce, 'circle-workflow-workspace-id'); 430 | }); 431 | }); 432 | }); 433 | 434 | context('in Codeship', function() { 435 | beforeEach(function() { 436 | environment = new Environment({ 437 | CI_NAME: 'codeship', 438 | CI_BRANCH: 'codeship-branch', 439 | CI_BUILD_NUMBER: 'codeship-build-number', 440 | CI_BUILD_ID: 'codeship-build-id', 441 | CI_PULL_REQUEST: 'false', // This is always false right now in Codeship. :| 442 | CI_COMMIT_ID: 'codeship-commit-sha', 443 | CI_NODE_TOTAL: '3', 444 | }); 445 | }); 446 | 447 | it('has the correct properties', function() { 448 | assert.strictEqual(environment.ci, 'codeship'); 449 | assert.strictEqual(environment.commitSha, 'codeship-commit-sha'); 450 | assert.strictEqual(environment.targetCommitSha, null); 451 | assert.strictEqual(environment.branch, 'codeship-branch'); 452 | assert.strictEqual(environment.targetBranch, null); 453 | assert.strictEqual(environment.pullRequestNumber, null); 454 | assert.strictEqual(environment.parallelNonce, 'codeship-build-number'); 455 | assert.strictEqual(environment.parallelTotalShards, 3); 456 | }); 457 | 458 | it('falls back from CI_BUILD_NUMBER to CI_BUILD_ID for CodeShip Pro', function() { 459 | delete environment._env.CI_BUILD_NUMBER; 460 | assert.strictEqual(environment.parallelNonce, 'codeship-build-id'); 461 | }); 462 | }); 463 | 464 | context('in Drone', function() { 465 | beforeEach(function() { 466 | environment = new Environment({ 467 | DRONE: 'true', 468 | DRONE_COMMIT: 'drone-commit-sha', 469 | DRONE_BRANCH: 'drone-branch', 470 | CI_PULL_REQUEST: '123', 471 | DRONE_BUILD_NUMBER: 'drone-build-number', 472 | }); 473 | }); 474 | 475 | it('has the correct properties', function() { 476 | assert.strictEqual(environment.ci, 'drone'); 477 | assert.strictEqual(environment.commitSha, 'drone-commit-sha'); 478 | assert.strictEqual(environment.targetCommitSha, null); 479 | assert.strictEqual(environment.branch, 'drone-branch'); 480 | assert.strictEqual(environment.targetBranch, null); 481 | assert.strictEqual(environment.pullRequestNumber, '123'); 482 | assert.strictEqual(environment.parallelNonce, 'drone-build-number'); 483 | assert.strictEqual(environment.parallelTotalShards, null); 484 | }); 485 | }); 486 | 487 | context('in Semaphore CI', function() { 488 | beforeEach(function() { 489 | environment = new Environment({ 490 | SEMAPHORE: 'true', 491 | BRANCH_NAME: 'semaphore-branch', 492 | REVISION: 'semaphore-commit-sha', 493 | SEMAPHORE_BRANCH_ID: 'semaphore-branch-id', 494 | SEMAPHORE_BUILD_NUMBER: 'semaphore-build-number', 495 | SEMAPHORE_THREAD_COUNT: '2', 496 | PULL_REQUEST_NUMBER: '123', 497 | }); 498 | }); 499 | 500 | it('has the correct properties', function() { 501 | assert.strictEqual(environment.ci, 'semaphore'); 502 | assert.strictEqual(environment.ciVersion, 'semaphore'); 503 | assert.strictEqual(environment.commitSha, 'semaphore-commit-sha'); 504 | assert.strictEqual(environment.targetCommitSha, null); 505 | assert.strictEqual(environment.branch, 'semaphore-branch'); 506 | assert.strictEqual(environment.targetBranch, null); 507 | assert.strictEqual(environment.pullRequestNumber, '123'); 508 | let expected_nonce = 'semaphore-branch-id/semaphore-build-number'; 509 | assert.strictEqual(environment.parallelNonce, expected_nonce); 510 | assert.strictEqual(environment.parallelTotalShards, 2); 511 | }); 512 | 513 | describe('Semaphore 2.0', () => { 514 | beforeEach(() => { 515 | environment = new Environment({ 516 | SEMAPHORE: 'true', 517 | SEMAPHORE_GIT_SHA: 'semaphore-2-sha', 518 | SEMAPHORE_GIT_BRANCH: 'semaphore-2-branch', 519 | SEMAPHORE_WORKFLOW_ID: 'semaphore-2-workflow-id', 520 | }); 521 | }); 522 | 523 | it('has the correct properties', () => { 524 | assert.strictEqual(environment.ci, 'semaphore'); 525 | assert.strictEqual(environment.ciVersion, 'semaphore/2.0'); 526 | assert.strictEqual(environment.commitSha, 'semaphore-2-sha'); 527 | assert.strictEqual(environment.branch, 'semaphore-2-branch'); 528 | assert.strictEqual(environment.targetCommitSha, null); 529 | assert.strictEqual(environment.targetBranch, null); 530 | assert.strictEqual(environment.pullRequestNumber, null); 531 | assert.strictEqual(environment.parallelNonce, 'semaphore-2-workflow-id'); 532 | assert.strictEqual(environment.parallelTotalShards, null); 533 | }); 534 | 535 | it('has the correct properties for PR builds', () => { 536 | environment = new Environment({ 537 | SEMAPHORE: 'true', 538 | SEMAPHORE_GIT_SHA: 'semaphore-2-sha', 539 | SEMAPHORE_GIT_PR_SHA: 'semaphore-2-pr-sha', 540 | SEMAPHORE_GIT_BRANCH: 'semaphore-2-branch', 541 | SEMAPHORE_GIT_PR_BRANCH: 'semaphore-2-pr-branch', 542 | SEMAPHORE_GIT_PR_NUMBER: '50', 543 | SEMAPHORE_WORKFLOW_ID: 'semaphore-2-workflow-id', 544 | }); 545 | 546 | assert.strictEqual(environment.ci, 'semaphore'); 547 | assert.strictEqual(environment.ciVersion, 'semaphore/2.0'); 548 | assert.strictEqual(environment.commitSha, 'semaphore-2-pr-sha'); 549 | assert.strictEqual(environment.branch, 'semaphore-2-pr-branch'); 550 | assert.strictEqual(environment.targetCommitSha, null); 551 | assert.strictEqual(environment.targetBranch, null); 552 | assert.strictEqual(environment.pullRequestNumber, '50'); 553 | assert.strictEqual(environment.parallelNonce, 'semaphore-2-workflow-id'); 554 | assert.strictEqual(environment.parallelTotalShards, null); 555 | }); 556 | }); 557 | }); 558 | 559 | context('in Buildkite', function() { 560 | beforeEach(function() { 561 | environment = new Environment({ 562 | BUILDKITE: 'true', 563 | BUILDKITE_COMMIT: 'buildkite-commit-sha', 564 | BUILDKITE_BRANCH: 'buildkite-branch', 565 | BUILDKITE_PULL_REQUEST: 'false', 566 | BUILDKITE_BUILD_ID: 'buildkite-build-id', 567 | BUILDKITE_PARALLEL_JOB_COUNT: '3', 568 | }); 569 | }); 570 | 571 | context('push build', function() { 572 | it('has the correct properties', function() { 573 | assert.strictEqual(environment.ci, 'buildkite'); 574 | assert.strictEqual(environment.commitSha, 'buildkite-commit-sha'); 575 | assert.strictEqual(environment.targetCommitSha, null); 576 | assert.strictEqual(environment.branch, 'buildkite-branch'); 577 | assert.strictEqual(environment.targetBranch, null); 578 | assert.strictEqual(environment.pullRequestNumber, null); 579 | assert.strictEqual(environment.parallelNonce, 'buildkite-build-id'); 580 | assert.strictEqual(environment.parallelTotalShards, 3); 581 | }); 582 | }); 583 | 584 | context('pull request build', function() { 585 | beforeEach(function() { 586 | environment._env.BUILDKITE_PULL_REQUEST = '123'; 587 | }); 588 | 589 | it('has the correct properties', function() { 590 | assert.strictEqual(environment.pullRequestNumber, '123'); 591 | }); 592 | }); 593 | 594 | context('UI-triggered HEAD build', function() { 595 | beforeEach(function() { 596 | environment._env.BUILDKITE_COMMIT = 'HEAD'; 597 | }); 598 | 599 | it('returns null commit SHA if set to HEAD', function() { 600 | assert.strictEqual(environment.commitSha, null); 601 | }); 602 | }); 603 | }); 604 | 605 | context('in GitLab', function() { 606 | beforeEach(function() { 607 | environment = new Environment({ 608 | GITLAB_CI: 'true', 609 | CI_COMMIT_SHA: 'gitlab-commit-sha', 610 | CI_COMMIT_REF_NAME: 'gitlab-branch', 611 | CI_PIPELINE_ID: 'gitlab-job-id', 612 | CI_SERVER_VERSION: '8.14.3-ee', 613 | }); 614 | }); 615 | 616 | context('push build', function() { 617 | it('has the correct properties', function() { 618 | assert.strictEqual(environment.ci, 'gitlab'); 619 | assert.strictEqual(environment.commitSha, 'gitlab-commit-sha'); 620 | assert.strictEqual(environment.targetCommitSha, null); 621 | assert.strictEqual(environment.branch, 'gitlab-branch'); 622 | assert.strictEqual(environment.targetBranch, null); 623 | assert.strictEqual(environment.pullRequestNumber, null); 624 | assert.strictEqual(environment.parallelNonce, 'gitlab-job-id'); 625 | assert.strictEqual(environment.parallelTotalShards, null); 626 | }); 627 | }); 628 | 629 | context('in Pull Request build', function() { 630 | beforeEach(function() { 631 | environment._env.CI_MERGE_REQUEST_IID = '2217'; 632 | }); 633 | 634 | it('has the correct properties', function() { 635 | assert.strictEqual(environment.pullRequestNumber, '2217'); 636 | assert.strictEqual(environment.branch, 'gitlab-branch'); 637 | assert.strictEqual(environment.targetBranch, null); 638 | assert.strictEqual(environment.commitSha, 'gitlab-commit-sha'); 639 | assert.strictEqual(environment.targetCommitSha, null); 640 | }); 641 | }); 642 | }); 643 | 644 | context('in Heroku CI', function() { 645 | beforeEach(function() { 646 | environment = new Environment({ 647 | HEROKU_TEST_RUN_COMMIT_VERSION: 'heroku-commit-sha', 648 | HEROKU_TEST_RUN_BRANCH: 'heroku-branch', 649 | HEROKU_TEST_RUN_ID: 'heroku-test-run-id', 650 | // TODO(fotinakis): need this. 651 | // HEROKU_PULL_REQUEST: '123', 652 | CI_NODE_TOTAL: '3', 653 | }); 654 | }); 655 | 656 | it('has the correct properties', function() { 657 | assert.strictEqual(environment.ci, 'heroku'); 658 | assert.strictEqual(environment.commitSha, 'heroku-commit-sha'); 659 | assert.strictEqual(environment.targetCommitSha, null); 660 | assert.strictEqual(environment.branch, 'heroku-branch'); 661 | assert.strictEqual(environment.targetBranch, null); 662 | assert.strictEqual(environment.pullRequestNumber, null); 663 | assert.strictEqual(environment.parallelNonce, 'heroku-test-run-id'); 664 | assert.strictEqual(environment.parallelTotalShards, 3); 665 | }); 666 | }); 667 | 668 | context('in Azure', function() { 669 | beforeEach(function() { 670 | environment = new Environment({ 671 | BUILD_BUILDID: 'azure-build-id', 672 | BUILD_SOURCEVERSION: 'azure-commit-sha', 673 | BUILD_SOURCEBRANCHNAME: 'azure-branch', 674 | SYSTEM_PARALLELEXECUTIONTYPE: 'None', 675 | TF_BUILD: 'True', 676 | }); 677 | }); 678 | 679 | it('has the correct properties', function() { 680 | assert.strictEqual(environment.ci, 'azure'); 681 | assert.strictEqual(environment.commitSha, 'azure-commit-sha'); 682 | assert.strictEqual(environment.targetCommitSha, null); 683 | assert.strictEqual(environment.branch, 'azure-branch'); 684 | assert.strictEqual(environment.targetBranch, null); 685 | assert.strictEqual(environment.pullRequestNumber, null); 686 | assert.strictEqual(environment.parallelNonce, 'azure-build-id'); 687 | assert.strictEqual(environment.parallelTotalShards, null); 688 | }); 689 | 690 | context('in parallel build', function() { 691 | beforeEach(function() { 692 | environment._env.SYSTEM_PARALLELEXECUTIONTYPE = 'MultiMachine'; 693 | environment._env.SYSTEM_TOTALJOBSINPHASE = '5'; 694 | }); 695 | 696 | it('has the correct properties', function() { 697 | assert.strictEqual(environment.parallelNonce, 'azure-build-id'); 698 | assert.strictEqual(environment.parallelTotalShards, 5); 699 | }); 700 | }); 701 | 702 | describe('Pull Request build', function() { 703 | context('in build triggered by Git', function() { 704 | beforeEach(function() { 705 | environment._env.SYSTEM_PULLREQUEST_PULLREQUESTID = '512'; 706 | environment._env.SYSTEM_PULLREQUEST_SOURCECOMMITID = 'azure-pr-commit-sha'; 707 | environment._env.SYSTEM_PULLREQUEST_SOURCEBRANCH = 'azure-pr-branch'; 708 | }); 709 | 710 | it('has the correct properties', function() { 711 | assert.strictEqual(environment.pullRequestNumber, '512'); 712 | assert.strictEqual(environment.branch, 'azure-pr-branch'); 713 | assert.strictEqual(environment.targetBranch, null); 714 | assert.strictEqual(environment.commitSha, 'azure-pr-commit-sha'); 715 | assert.strictEqual(environment.targetCommitSha, null); 716 | }); 717 | }); 718 | 719 | context('in build triggered by GitHub', function() { 720 | beforeEach(function() { 721 | environment._env.SYSTEM_PULLREQUEST_PULLREQUESTID = '512'; 722 | environment._env.SYSTEM_PULLREQUEST_PULLREQUESTNUMBER = '524'; 723 | environment._env.SYSTEM_PULLREQUEST_SOURCECOMMITID = 'azure-pr-commit-sha'; 724 | environment._env.SYSTEM_PULLREQUEST_SOURCEBRANCH = 'azure-pr-branch'; 725 | }); 726 | 727 | it('has the correct properties', function() { 728 | assert.strictEqual(environment.pullRequestNumber, '512'); 729 | assert.strictEqual(environment.branch, 'azure-pr-branch'); 730 | assert.strictEqual(environment.targetBranch, null); 731 | assert.strictEqual(environment.commitSha, 'azure-pr-commit-sha'); 732 | assert.strictEqual(environment.targetCommitSha, null); 733 | }); 734 | }); 735 | }); 736 | }); 737 | 738 | context('in Appveyor', function() { 739 | beforeEach(function() { 740 | environment = new Environment({ 741 | APPVEYOR: 'True', 742 | APPVEYOR_BUILD_ID: 'appveyor-build-id', 743 | APPVEYOR_REPO_COMMIT: 'appveyor-commit-sha', 744 | APPVEYOR_REPO_BRANCH: 'appveyor-branch', 745 | }); 746 | }); 747 | 748 | it('has the correct properties', function() { 749 | assert.strictEqual(environment.ci, 'appveyor'); 750 | assert.strictEqual(environment.commitSha, 'appveyor-commit-sha'); 751 | assert.strictEqual(environment.targetCommitSha, null); 752 | assert.strictEqual(environment.branch, 'appveyor-branch'); 753 | assert.strictEqual(environment.targetBranch, null); 754 | assert.strictEqual(environment.pullRequestNumber, null); 755 | assert.strictEqual(environment.parallelNonce, 'appveyor-build-id'); 756 | assert.strictEqual(environment.parallelTotalShards, null); 757 | }); 758 | 759 | context('in Pull Request build', function() { 760 | beforeEach(function() { 761 | environment._env.APPVEYOR_PULL_REQUEST_NUMBER = '512'; 762 | environment._env.APPVEYOR_PULL_REQUEST_HEAD_COMMIT = 'appveyor-pr-commit-sha'; 763 | environment._env.APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH = 'appveyor-pr-branch'; 764 | }); 765 | 766 | it('has the correct properties', function() { 767 | assert.strictEqual(environment.pullRequestNumber, '512'); 768 | assert.strictEqual(environment.branch, 'appveyor-pr-branch'); 769 | assert.strictEqual(environment.targetBranch, null); 770 | assert.strictEqual(environment.commitSha, 'appveyor-pr-commit-sha'); 771 | assert.strictEqual(environment.targetCommitSha, null); 772 | }); 773 | }); 774 | }); 775 | 776 | context('in Probo', function() { 777 | beforeEach(function() { 778 | environment = new Environment({ 779 | PROBO_ENVIRONMENT: 'TRUE', 780 | BUILD_ID: 'probo-build-id', 781 | COMMIT_REF: 'probo-commit-sha', 782 | BRANCH_NAME: 'probo-branch', 783 | PULL_REQUEST_LINK: 'https://github.com/owner/repo-name/pull/123', 784 | }); 785 | }); 786 | 787 | it('has the correct properties', function() { 788 | assert.strictEqual(environment.ci, 'probo'); 789 | assert.strictEqual(environment.commitSha, 'probo-commit-sha'); 790 | assert.strictEqual(environment.targetCommitSha, null); 791 | assert.strictEqual(environment.branch, 'probo-branch'); 792 | assert.strictEqual(environment.targetBranch, null); 793 | assert.strictEqual(environment.parallelNonce, 'probo-build-id'); 794 | assert.strictEqual(environment.parallelTotalShards, null); 795 | assert.strictEqual(environment.pullRequestNumber, '123'); 796 | }); 797 | }); 798 | 799 | context('in Bitbucket Pipelines', function() { 800 | beforeEach(function() { 801 | environment = new Environment({ 802 | BITBUCKET_BUILD_NUMBER: 'bitbucket-build-number', 803 | BITBUCKET_COMMIT: 'bitbucket-commit-sha', 804 | BITBUCKET_BRANCH: 'bitbucket-branch', 805 | }); 806 | }); 807 | 808 | it('has the correct properties', function() { 809 | assert.strictEqual(environment.ci, 'bitbucket'); 810 | assert.strictEqual(environment.commitSha, 'bitbucket-commit-sha'); 811 | assert.strictEqual(environment.targetCommitSha, null); 812 | assert.strictEqual(environment.branch, 'bitbucket-branch'); 813 | assert.strictEqual(environment.targetBranch, null); 814 | assert.strictEqual(environment.pullRequestNumber, null); 815 | assert.strictEqual(environment.parallelNonce, 'bitbucket-build-number'); 816 | assert.strictEqual(environment.parallelTotalShards, null); 817 | }); 818 | 819 | context('in Pull Request build', function() { 820 | beforeEach(function() { 821 | environment._env.BITBUCKET_PR_ID = '981'; 822 | }); 823 | 824 | it('has the correct properties', function() { 825 | assert.strictEqual(environment.pullRequestNumber, '981'); 826 | assert.strictEqual(environment.branch, 'bitbucket-branch'); 827 | assert.strictEqual(environment.targetBranch, null); 828 | assert.strictEqual(environment.commitSha, 'bitbucket-commit-sha'); 829 | assert.strictEqual(environment.targetCommitSha, null); 830 | }); 831 | }); 832 | }); 833 | 834 | context('in Netlify builds', function() { 835 | beforeEach(function() { 836 | environment = new Environment({ 837 | NETLIFY: 'true', 838 | COMMIT_REF: 'netlify-commit-sha', 839 | HEAD: 'netlify-branch', 840 | PULL_REQUEST: 'true', 841 | REVIEW_ID: '123', 842 | }); 843 | }); 844 | 845 | it('has the correct properties', function() { 846 | assert.strictEqual(environment.ci, 'netlify'); 847 | assert.strictEqual(environment.commitSha, 'netlify-commit-sha'); 848 | assert.strictEqual(environment.targetCommitSha, null); 849 | assert.strictEqual(environment.branch, 'netlify-branch'); 850 | assert.strictEqual(environment.targetBranch, null); 851 | assert.strictEqual(environment.pullRequestNumber, '123'); 852 | assert.strictEqual(environment.parallelNonce, null); 853 | assert.strictEqual(environment.parallelTotalShards, null); 854 | }); 855 | }); 856 | }); 857 | -------------------------------------------------------------------------------- /test/main-test.js: -------------------------------------------------------------------------------- 1 | let path = require('path'); 2 | let assert = require('assert'); 3 | let utils = require(path.join(__dirname, '..', 'src', 'utils')); 4 | let PercyClient = require(path.join(__dirname, '..', 'src', 'main')); 5 | let UserAgent = require(path.join(__dirname, '..', 'src', 'user-agent')); 6 | let nock = require('nock'); 7 | let fs = require('fs'); 8 | 9 | describe('PercyClient', function() { 10 | let percyClient; 11 | 12 | beforeEach(function() { 13 | percyClient = new PercyClient({token: 'test-token'}); 14 | nock.disableNetConnect(); 15 | }); 16 | 17 | afterEach(function() { 18 | nock.cleanAll(); 19 | }); 20 | 21 | describe('_httpGet', function() { 22 | it('sends a GET request', function(done) { 23 | let responseMock = function() { 24 | // Verify some request states. 25 | assert.equal(this.req.headers['authorization'], 'Token token=test-token'); 26 | let responseBody = {success: true}; 27 | return [200, responseBody]; 28 | }; 29 | nock('https://localhost') 30 | .get('/foo?bar') 31 | .reply(200, responseMock); 32 | let request = percyClient._httpGet('https://localhost/foo?bar'); 33 | 34 | request 35 | .then(function(response) { 36 | assert.equal(response.statusCode, 200); 37 | assert.deepEqual(response.body, {success: true}); 38 | done(); 39 | }) 40 | .catch(err => { 41 | done(err); 42 | }); 43 | }); 44 | 45 | it('retries on 500s', async () => { 46 | let mock = nock('https://localhost') 47 | .get('/foo') 48 | .reply(500, {success: false}) 49 | .get('/foo') 50 | .reply(201, {success: true}); 51 | 52 | let response = await percyClient._httpGet('https://localhost/foo'); 53 | assert.equal(response.statusCode, 201); 54 | assert.deepEqual(response.body, {success: true}); 55 | mock.done(); 56 | }); 57 | 58 | ['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'EHOSTUNREACH', 'EAI_AGAIN'].forEach(code => { 59 | it(`retries on ${code}`, async () => { 60 | let mock = nock('https://localhost') 61 | .get('/foo') 62 | .replyWithError({code}) 63 | .get('/foo') 64 | .reply(201, {success: true}); 65 | 66 | let response = await percyClient._httpGet('https://localhost/foo'); 67 | assert.equal(response.statusCode, 201); 68 | assert.deepEqual(response.body, {success: true}); 69 | mock.done(); 70 | }); 71 | }); 72 | }); 73 | 74 | describe('_httpPost', function() { 75 | it('sends a POST request', function(done) { 76 | let requestData = {foo: 123}; 77 | let responseMock = function(url, requestBody) { 78 | // Verify some request states. 79 | assert.equal(this.req.headers['content-type'], 'application/vnd.api+json'); 80 | assert.equal(this.req.headers['authorization'], `Token token=test-token`); 81 | assert.equal(requestBody, JSON.stringify(requestData)); 82 | let responseBody = {success: true}; 83 | return responseBody; 84 | }; 85 | nock('https://localhost') 86 | .post('/foo') 87 | .reply(201, responseMock); 88 | let request = percyClient._httpPost('https://localhost/foo', requestData); 89 | 90 | request 91 | .then(response => { 92 | assert.equal(response.statusCode, 201); 93 | assert.deepEqual(response.body, {success: true}); 94 | done(); 95 | }) 96 | .catch(err => { 97 | done(err); 98 | }); 99 | }); 100 | 101 | it('retries on 500s', async () => { 102 | let mock = nock('https://localhost') 103 | .post('/foo') 104 | .reply(500, {success: false}) 105 | .post('/foo') 106 | .reply(201, {success: true}); 107 | 108 | let response = await percyClient._httpPost('https://localhost/foo', {foo: 123}); 109 | assert.equal(response.statusCode, 201); 110 | assert.deepEqual(response.body, {success: true}); 111 | mock.done(); 112 | }); 113 | 114 | ['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'EHOSTUNREACH', 'EAI_AGAIN'].forEach(code => { 115 | it(`retries on ${code}`, async () => { 116 | let mock = nock('https://localhost') 117 | .post('/foo') 118 | .replyWithError({code}) 119 | .post('/foo') 120 | .reply(201, {success: true}); 121 | 122 | let response = await percyClient._httpPost('https://localhost/foo', {foo: 123}); 123 | assert.equal(response.statusCode, 201); 124 | assert.deepEqual(response.body, {success: true}); 125 | mock.done(); 126 | }); 127 | }); 128 | }); 129 | 130 | describe('token', function() { 131 | it('returns the token', function() { 132 | assert.equal(percyClient.token, 'test-token'); 133 | }); 134 | }); 135 | 136 | describe('createBuild', function() { 137 | let resources, responseMock; 138 | 139 | beforeEach(function() { 140 | percyClient = new PercyClient({token: 'test-token'}); 141 | nock.disableNetConnect(); 142 | 143 | resources = [percyClient.makeResource({resourceUrl: '/foo%20bar', sha: 'fake-sha'})]; 144 | const commitData = percyClient.environment.commitData; 145 | 146 | const expectedRequestData = { 147 | data: { 148 | type: 'builds', 149 | attributes: { 150 | branch: commitData.branch, 151 | 'target-branch': percyClient.environment.targetBranch, 152 | 'target-commit-sha': percyClient.environment.targetCommitSha, 153 | 'commit-sha': commitData.sha, 154 | 'commit-committed-at': commitData.committedAt, 155 | 'commit-author-name': commitData.authorName, 156 | 'commit-author-email': commitData.authorEmail, 157 | 'commit-committer-name': commitData.committerName, 158 | 'commit-committer-email': commitData.committerEmail, 159 | 'commit-message': commitData.message, 160 | 'pull-request-number': percyClient.environment.pullRequestNumber, 161 | 'parallel-nonce': null, 162 | 'parallel-total-shards': null, 163 | partial: percyClient.environment.partialBuild, 164 | }, 165 | relationships: { 166 | resources: { 167 | data: [ 168 | { 169 | type: 'resources', 170 | id: 'fake-sha', 171 | attributes: { 172 | 'resource-url': '/foo%20bar', 173 | mimetype: null, 174 | 'is-root': null, 175 | }, 176 | }, 177 | ], 178 | }, 179 | }, 180 | }, 181 | }; 182 | 183 | responseMock = function(url, requestBody) { 184 | // Verify request data. 185 | assert.equal(requestBody, JSON.stringify(expectedRequestData)); 186 | let responseBody = {foo: 123}; 187 | return [201, responseBody]; 188 | }; 189 | }); 190 | 191 | afterEach(function() { 192 | nock.cleanAll(); 193 | }); 194 | 195 | it('returns build data', function(done) { 196 | nock('https://percy.io') 197 | .post('/api/v1/builds/') 198 | .reply(201, responseMock); 199 | 200 | let request = percyClient.createBuild({resources: resources}); 201 | 202 | request 203 | .then(response => { 204 | assert.equal(response.statusCode, 201); 205 | assert.deepEqual(response.body, {foo: 123}); 206 | done(); 207 | }) 208 | .catch(err => { 209 | done(err); 210 | }); 211 | }); 212 | }); 213 | 214 | describe('getBuild', function() { 215 | it('returns the response body', function(done) { 216 | let responseMock = function(url, requestBody) { 217 | // Verify request data. 218 | assert.equal(requestBody, ''); 219 | let responseBody = {foo: 123}; 220 | return [201, responseBody]; 221 | }; 222 | 223 | nock('https://percy.io') 224 | .get('/api/v1/builds/100') 225 | .reply(201, responseMock); 226 | 227 | let request = percyClient.getBuild('100'); 228 | 229 | request 230 | .then(response => { 231 | assert.equal(response.statusCode, 201); 232 | assert.deepEqual(response.body, {foo: 123}); 233 | done(); 234 | }) 235 | .catch(err => { 236 | done(err); 237 | }); 238 | }); 239 | }); 240 | 241 | describe('getBuilds', function() { 242 | it('returns the response body', function(done) { 243 | let responseMock = function(url, requestBody) { 244 | // Verify request data. 245 | assert.equal(requestBody, ''); 246 | let responseBody = {foo: 123}; 247 | return [201, responseBody]; 248 | }; 249 | 250 | nock('https://percy.io') 251 | .get('/api/v1/projects/my_project/builds') 252 | .reply(201, responseMock); 253 | 254 | let request = percyClient.getBuilds('my_project'); 255 | 256 | request 257 | .then(response => { 258 | assert.equal(response.statusCode, 201); 259 | assert.deepEqual(response.body, {foo: 123}); 260 | done(); 261 | }) 262 | .catch(err => { 263 | done(err); 264 | }); 265 | }); 266 | 267 | describe('filtered by SHA', function() { 268 | it('returns the response body', function(done) { 269 | let responseMock = function(url, requestBody) { 270 | // Verify request data. 271 | assert.equal(requestBody, ''); 272 | let responseBody = {foo: 123}; 273 | return [201, responseBody]; 274 | }; 275 | 276 | nock('https://percy.io') 277 | .get('/api/v1/projects/my_project/builds?filter[sha]=my_sha') 278 | .reply(201, responseMock); 279 | 280 | let request = percyClient.getBuilds('my_project', {sha: 'my_sha'}); 281 | 282 | request 283 | .then(response => { 284 | assert.equal(response.statusCode, 201); 285 | assert.deepEqual(response.body, {foo: 123}); 286 | done(); 287 | }) 288 | .catch(err => { 289 | done(err); 290 | }); 291 | }); 292 | }); 293 | 294 | describe('filtered by state, branch, and shas', function() { 295 | it('returns the response body', function(done) { 296 | let responseMock = function(url, requestBody) { 297 | // Verify request data. 298 | assert.equal(requestBody, ''); 299 | let responseBody = {foo: 123}; 300 | return [201, responseBody]; 301 | }; 302 | 303 | nock('https://percy.io') 304 | .get( 305 | '/api/v1/projects/my_project/builds?filter[branch]=master&filter[state]=finished' + 306 | '&filter[shas][]=my_sha&filter[shas][]=my_other_sha', 307 | ) 308 | .reply(201, responseMock); 309 | 310 | let request = percyClient.getBuilds('my_project', { 311 | branch: 'master', 312 | state: 'finished', 313 | shas: ['my_sha', 'my_other_sha'], 314 | }); 315 | 316 | request 317 | .then(response => { 318 | assert.equal(response.statusCode, 201); 319 | assert.deepEqual(response.body, {foo: 123}); 320 | done(); 321 | }) 322 | .catch(err => { 323 | done(err); 324 | }); 325 | }); 326 | }); 327 | }); 328 | 329 | describe('makeResource', function() { 330 | it('returns a Resource object with defaults', function() { 331 | let resource = percyClient.makeResource({ 332 | resourceUrl: '/foo', 333 | sha: 'fake-sha', 334 | }); 335 | let expected = { 336 | type: 'resources', 337 | id: 'fake-sha', 338 | attributes: { 339 | 'resource-url': '/foo', 340 | mimetype: null, 341 | 'is-root': null, 342 | }, 343 | }; 344 | 345 | assert.deepEqual(resource.serialize(), expected); 346 | }); 347 | 348 | it('handles arguments correctly', function() { 349 | let content = 'foo'; 350 | let resource = percyClient.makeResource({ 351 | resourceUrl: '/foo', 352 | isRoot: true, 353 | mimetype: 'text/plain', 354 | content: content, 355 | localPath: '/absolute/path/foo', 356 | }); 357 | 358 | assert.equal(resource.resourceUrl, '/foo'); 359 | assert.equal(resource.sha, utils.sha256hash(content)); 360 | assert.equal(resource.content, content); 361 | assert.equal(resource.isRoot, true); 362 | assert.equal(resource.mimetype, 'text/plain'); 363 | assert.equal(resource.localPath, '/absolute/path/foo'); 364 | 365 | let expected = { 366 | type: 'resources', 367 | id: utils.sha256hash(content), 368 | attributes: { 369 | 'resource-url': '/foo', 370 | mimetype: 'text/plain', 371 | 'is-root': true, 372 | }, 373 | }; 374 | 375 | assert.deepEqual(resource.serialize(), expected); 376 | }); 377 | 378 | it('throws an error if resourceUrl is not given', function() { 379 | assert.throws(() => { 380 | percyClient.makeResource({content: 'foo'}); 381 | }, Error); 382 | }); 383 | 384 | it('throws an error if resourceUrl contains a space', function() { 385 | assert.throws(() => { 386 | percyClient.makeResource({resourceUrl: 'foo bar'}); 387 | }, Error); 388 | }); 389 | 390 | it('throws an error if neither sha nor content is not given', function() { 391 | assert.throws(() => { 392 | percyClient.makeResource({resourceUrl: '/foo'}); 393 | }, Error); 394 | }); 395 | }); 396 | 397 | describe('gatherBuildResources', function() { 398 | it('returns an array of Resource objects', function() { 399 | let rootDir = path.join(__dirname, 'data'); 400 | let resources = percyClient.gatherBuildResources(rootDir, {}); 401 | 402 | let expectedResources = [ 403 | percyClient.makeResource({ 404 | resourceUrl: '/test-resource.css', 405 | sha: '237885ebb7b7a42f90d49dbe8767c2aebbf71b2c0f72581df38e3212a25eaf1d', 406 | localPath: path.join(rootDir, 'test-resource.css'), 407 | }), 408 | percyClient.makeResource({ 409 | resourceUrl: '/test-resource.js', 410 | sha: '15ffc75aa3746518346cd1c529575a64bd24f4051425b9fe4425bedeec865a6e', 411 | localPath: path.join(rootDir, 'test-resource.js'), 412 | }), 413 | ]; 414 | assert.deepEqual(resources, expectedResources); 415 | }); 416 | 417 | it('handles extra options correctly', function() { 418 | let rootDir = path.join(__dirname, 'data'); 419 | let options = { 420 | // Note: intentionally added trailing slash to be sure it is stripped before prepend. 421 | baseUrlPath: '/assets/', 422 | skippedPathRegexes: [/\.css$/], 423 | followLinks: false, 424 | }; 425 | let resources = percyClient.gatherBuildResources(rootDir, options); 426 | 427 | let expectedResources = [ 428 | percyClient.makeResource({ 429 | resourceUrl: '/assets/test-resource.js', 430 | sha: '15ffc75aa3746518346cd1c529575a64bd24f4051425b9fe4425bedeec865a6e', 431 | localPath: path.join(rootDir, 'test-resource.js'), 432 | }), 433 | ]; 434 | assert.deepEqual(resources, expectedResources); 435 | }); 436 | }); 437 | 438 | describe('uploadResource', function() { 439 | it('uploads a resource', function(done) { 440 | let content = 'foo'; 441 | let expectedRequestData = { 442 | data: { 443 | type: 'resources', 444 | id: utils.sha256hash(content), 445 | attributes: { 446 | 'base64-content': utils.base64encode(content), 447 | }, 448 | }, 449 | }; 450 | let responseMock = function(url, requestBody) { 451 | // Verify some request states. 452 | assert.equal(requestBody, JSON.stringify(expectedRequestData)); 453 | let responseBody = {success: true}; 454 | return [201, responseBody]; 455 | }; 456 | 457 | nock('https://percy.io') 458 | .post('/api/v1/builds/123/resources/') 459 | .reply(201, responseMock); 460 | let request = percyClient.uploadResource(123, content); 461 | 462 | request 463 | .then(response => { 464 | assert.equal(response.statusCode, 201); 465 | assert.deepEqual(response.body, {success: true}); 466 | done(); 467 | }) 468 | .catch(err => { 469 | done(err); 470 | }); 471 | }); 472 | }); 473 | 474 | describe('uploadResources', function() { 475 | it('uploads resources with content', function(done) { 476 | const resources = [ 477 | { 478 | content: 'foo', 479 | }, 480 | { 481 | content: 'bar', 482 | }, 483 | ]; 484 | 485 | const expectedRequestData1 = { 486 | data: { 487 | type: 'resources', 488 | id: utils.sha256hash(resources[0].content), 489 | attributes: { 490 | 'base64-content': utils.base64encode(resources[0].content), 491 | }, 492 | }, 493 | }; 494 | const expectedRequestData2 = { 495 | data: { 496 | type: 'resources', 497 | id: utils.sha256hash(resources[1].content), 498 | attributes: { 499 | 'base64-content': utils.base64encode(resources[1].content), 500 | }, 501 | }, 502 | }; 503 | 504 | const responseMock = function(url, requestBody) { 505 | const requestJson = JSON.parse(requestBody); 506 | if (requestJson.data.id === expectedRequestData1.data.id) { 507 | assert.equal(requestBody, JSON.stringify(expectedRequestData1)); 508 | } else if (requestJson.data.id === expectedRequestData2.data.id) { 509 | assert.equal(requestBody, JSON.stringify(expectedRequestData2)); 510 | } else { 511 | assert.fail('Invalid resource uploaded'); 512 | } 513 | const responseBody = {success: true}; 514 | return [201, responseBody]; 515 | }; 516 | 517 | nock('https://percy.io') 518 | .post('/api/v1/builds/123/resources/') 519 | .times(2) 520 | .reply(201, responseMock); 521 | let request = percyClient.uploadResources(123, resources); 522 | 523 | request 524 | .then(() => { 525 | done(); 526 | }) 527 | .catch(err => { 528 | done(err); 529 | }); 530 | }); 531 | 532 | it('uploads resources with local path', function(done) { 533 | const resources = [ 534 | { 535 | localPath: path.join(__dirname, 'data', 'test-resource.css'), 536 | }, 537 | { 538 | localPath: path.join(__dirname, 'data', 'test-resource.js'), 539 | }, 540 | ]; 541 | 542 | const cssContent = fs.readFileSync(resources[0].localPath); 543 | const expectedRequestData1 = { 544 | data: { 545 | type: 'resources', 546 | id: utils.sha256hash(cssContent), 547 | attributes: { 548 | 'base64-content': utils.base64encode(cssContent), 549 | }, 550 | }, 551 | }; 552 | 553 | const jsContent = fs.readFileSync(resources[1].localPath); 554 | const expectedRequestData2 = { 555 | data: { 556 | type: 'resources', 557 | id: utils.sha256hash(jsContent), 558 | attributes: { 559 | 'base64-content': utils.base64encode(jsContent), 560 | }, 561 | }, 562 | }; 563 | 564 | const responseMock = function(url, requestBody) { 565 | const requestJson = JSON.parse(requestBody); 566 | if (requestJson.data.id === expectedRequestData1.data.id) { 567 | assert.equal(requestBody, JSON.stringify(expectedRequestData1)); 568 | } else if (requestJson.data.id === expectedRequestData2.data.id) { 569 | assert.equal(requestBody, JSON.stringify(expectedRequestData2)); 570 | } else { 571 | assert.fail('Invalid resource uploaded'); 572 | } 573 | const responseBody = {success: true}; 574 | return [201, responseBody]; 575 | }; 576 | 577 | nock('https://percy.io') 578 | .post('/api/v1/builds/123/resources/') 579 | .times(2) 580 | .reply(201, responseMock); 581 | let request = percyClient.uploadResources(123, resources); 582 | 583 | request 584 | .then(() => { 585 | done(); 586 | }) 587 | .catch(err => { 588 | done(err); 589 | }); 590 | }); 591 | }); 592 | 593 | describe('uploadMissingResources', function() { 594 | it('does nothing when there are no missing resources', function(done) { 595 | const response = { 596 | body: { 597 | data: { 598 | relationships: { 599 | 'missing-resources': { 600 | data: [], 601 | }, 602 | }, 603 | }, 604 | }, 605 | }; 606 | const resources = [ 607 | { 608 | sha: '123', 609 | }, 610 | { 611 | sha: '456', 612 | }, 613 | ]; 614 | 615 | const responseMock = function() { 616 | assert.fail('Should not be uploading any resources'); 617 | return [500]; 618 | }; 619 | 620 | nock('https://percy.io') 621 | .post('/api/v1/builds/123/resources/') 622 | .reply(500, responseMock); 623 | const request = percyClient.uploadMissingResources(123, response, resources); 624 | 625 | request 626 | .then(() => { 627 | done(); 628 | }) 629 | .catch(err => { 630 | done(err); 631 | }); 632 | }); 633 | 634 | it('uploads the missing resources', function(done) { 635 | const response = { 636 | body: { 637 | data: { 638 | relationships: { 639 | 'missing-resources': { 640 | data: [ 641 | { 642 | id: '456', 643 | }, 644 | ], 645 | }, 646 | }, 647 | }, 648 | }, 649 | }; 650 | const resources = [ 651 | { 652 | sha: '123', 653 | content: 'Foo', 654 | }, 655 | { 656 | sha: '456', 657 | content: 'Bar', 658 | }, 659 | ]; 660 | 661 | const expectedRequestData = { 662 | data: { 663 | type: 'resources', 664 | id: utils.sha256hash(resources[1].content), 665 | attributes: { 666 | 'base64-content': utils.base64encode(resources[1].content), 667 | }, 668 | }, 669 | }; 670 | 671 | const responseMock = function(url, requestBody) { 672 | assert.equal(requestBody, JSON.stringify(expectedRequestData)); 673 | const responseBody = {success: true}; 674 | return [201, responseBody]; 675 | }; 676 | 677 | // Add a 520 to test retries 678 | nock('https://percy.io') 679 | .post('/api/v1/builds/123/resources/') 680 | .reply(502, {success: false}); 681 | 682 | nock('https://percy.io') 683 | .post('/api/v1/builds/123/resources/') 684 | .reply(201, responseMock); 685 | const request = percyClient.uploadMissingResources(123, response, resources); 686 | 687 | request 688 | .then(() => { 689 | done(); 690 | }) 691 | .catch(err => { 692 | done(err); 693 | }); 694 | }); 695 | }); 696 | 697 | describe('createSnapshot', function() { 698 | it('creates a snapshot', function(done) { 699 | let content = 'foo'; 700 | let expectedRequestData = { 701 | data: { 702 | type: 'snapshots', 703 | attributes: { 704 | name: 'foo', 705 | 'enable-javascript': true, 706 | widths: [1000], 707 | 'minimum-height': 100, 708 | }, 709 | relationships: { 710 | resources: { 711 | data: [ 712 | { 713 | type: 'resources', 714 | id: utils.sha256hash(content), 715 | attributes: { 716 | 'resource-url': '/foo', 717 | mimetype: null, 718 | 'is-root': true, 719 | }, 720 | }, 721 | ], 722 | }, 723 | }, 724 | }, 725 | }; 726 | 727 | let responseMock = function(url, requestBody) { 728 | // Verify some request states. 729 | assert.equal(requestBody, JSON.stringify(expectedRequestData)); 730 | let responseBody = {success: true}; 731 | return [201, responseBody]; 732 | }; 733 | nock('https://percy.io') 734 | .post('/api/v1/builds/123/snapshots/') 735 | .reply(201, responseMock); 736 | 737 | let options = { 738 | name: 'foo', 739 | enableJavaScript: true, 740 | widths: [1000], 741 | minimumHeight: 100, 742 | clientInfo: '@percy/cypress/0.2.0', 743 | environmentInfo: 'cypress/3.1.0', 744 | }; 745 | let resource = percyClient.makeResource({ 746 | resourceUrl: '/foo', 747 | content: content, 748 | isRoot: true, 749 | }); 750 | let resources = [resource]; 751 | let request = percyClient.createSnapshot(123, resources, options); 752 | 753 | request 754 | .then(response => { 755 | assert.equal(response.statusCode, 201); 756 | // This is not the actual API response, we just mocked it above. 757 | assert.deepEqual(response.body, {success: true}); 758 | 759 | // Ensure that data from createSnapshot gets through to userAgent 760 | const userAgent = new UserAgent(percyClient); 761 | assert(userAgent.toString().includes('@percy/cypress/0.2.0')); 762 | assert(userAgent.toString().includes('cypress/3.1.0')); 763 | 764 | done(); 765 | }) 766 | .catch(err => { 767 | done(err); 768 | }); 769 | }); 770 | }); 771 | 772 | describe('finalizeSnapshot', function() { 773 | it('finalizes the snapshot', function(done) { 774 | let responseData = {success: true}; 775 | nock('https://percy.io') 776 | .post('/api/v1/snapshots/123/finalize') 777 | .reply(201, responseData); 778 | 779 | let request = percyClient.finalizeSnapshot(123); 780 | 781 | request 782 | .then(response => { 783 | assert.equal(response.statusCode, 201); 784 | assert.deepEqual(response.body, {success: true}); 785 | done(); 786 | }) 787 | .catch(err => { 788 | done(err); 789 | }); 790 | }); 791 | 792 | it('finalizes the snapshot with 3 retries', function(done) { 793 | nock('https://percy.io') 794 | .post('/api/v1/snapshots/123/finalize') 795 | .reply(502, {success: false}); 796 | nock('https://percy.io') 797 | .post('/api/v1/snapshots/123/finalize') 798 | .reply(503, {success: false}); 799 | nock('https://percy.io') 800 | .post('/api/v1/snapshots/123/finalize') 801 | .reply(520, {success: false}); 802 | 803 | let responseData = {success: true}; 804 | nock('https://percy.io') 805 | .post('/api/v1/snapshots/123/finalize') 806 | .reply(201, responseData); 807 | 808 | let request = percyClient.finalizeSnapshot(123); 809 | 810 | request 811 | .then(response => { 812 | assert.equal(response.statusCode, 201); 813 | assert.deepEqual(response.body, {success: true}); 814 | done(); 815 | }) 816 | .catch(err => { 817 | done(err); 818 | }); 819 | }); 820 | 821 | it('finalize fails with status after 5 retries', function(done) { 822 | nock('https://percy.io') 823 | .post('/api/v1/snapshots/123/finalize') 824 | .reply(502, {success: false}); 825 | nock('https://percy.io') 826 | .post('/api/v1/snapshots/123/finalize') 827 | .reply(502, {success: false}); 828 | nock('https://percy.io') 829 | .post('/api/v1/snapshots/123/finalize') 830 | .reply(502, {success: false}); 831 | nock('https://percy.io') 832 | .post('/api/v1/snapshots/123/finalize') 833 | .reply(502, {success: false}); 834 | nock('https://percy.io') 835 | .post('/api/v1/snapshots/123/finalize') 836 | .reply(502, {success: false}); 837 | 838 | let request = percyClient.finalizeSnapshot(123); 839 | 840 | request.catch(err => { 841 | assert.equal(err.message, '502 - {"success":false}'); 842 | assert.equal(err.statusCode, 502); 843 | assert.deepEqual(err.response.body, {success: false}); 844 | done(); 845 | }); 846 | }); 847 | 848 | it('finalize fails with 400 and returns error without retries', function(done) { 849 | nock('https://percy.io') 850 | .post('/api/v1/snapshots/123/finalize') 851 | .reply(400, {success: false}); 852 | 853 | let request = percyClient.finalizeSnapshot(123); 854 | 855 | request.catch(err => { 856 | assert.equal(err.message, '400 - {"success":false}'); 857 | assert.equal(err.statusCode, 400); 858 | assert.deepEqual(err.response.body, {success: false}); 859 | done(); 860 | }); 861 | }); 862 | }); 863 | 864 | describe('finalizeBuild', function() { 865 | it('finalizes the build', function(done) { 866 | let responseData = {success: true}; 867 | nock('https://percy.io') 868 | .post('/api/v1/builds/123/finalize') 869 | .reply(201, responseData); 870 | let request = percyClient.finalizeBuild(123); 871 | 872 | request 873 | .then(response => { 874 | assert.equal(response.statusCode, 201); 875 | assert.deepEqual(response.body, {success: true}); 876 | done(); 877 | }) 878 | .catch(err => { 879 | done(err); 880 | }); 881 | }); 882 | 883 | it('accepts allShards argument', function(done) { 884 | let responseData = {success: true}; 885 | nock('https://percy.io') 886 | .post('/api/v1/builds/123/finalize?all-shards=true') 887 | .reply(201, responseData); 888 | let request = percyClient.finalizeBuild(123, {allShards: true}); 889 | 890 | request 891 | .then(response => { 892 | assert.equal(response.statusCode, 201); 893 | assert.deepEqual(response.body, {success: true}); 894 | done(); 895 | }) 896 | .catch(err => { 897 | done(err); 898 | }); 899 | }); 900 | }); 901 | }); 902 | -------------------------------------------------------------------------------- /test/user-agent-test.js: -------------------------------------------------------------------------------- 1 | let path = require('path'); 2 | let UserAgent = require(path.join(__dirname, '..', 'src', 'user-agent')); 3 | let PercyClient = require(path.join(__dirname, '..', 'src', 'main')); 4 | let Environment = require(path.join(__dirname, '..', 'src', 'environment')); 5 | let assert = require('assert'); 6 | 7 | import {version} from '../package.json'; 8 | 9 | // Regex is used to check matching in this file so we can have the tests pass both locally 10 | // and remotely (when `travis` is included in the environment string) 11 | describe('UserAgent', function() { 12 | let userAgent; 13 | let percyClient; 14 | 15 | afterEach(function() { 16 | userAgent = null; 17 | percyClient = null; 18 | }); 19 | 20 | context('no client or environment info', function() { 21 | beforeEach(function() { 22 | percyClient = new PercyClient(); 23 | userAgent = new UserAgent(percyClient); 24 | }); 25 | 26 | describe('userAgent', function() { 27 | it('is correct', function() { 28 | let regex = new RegExp( 29 | `Percy/v1 percy-js/${version} ` + 30 | `\\(node/${process.version}(; ${percyClient.environment.ciVersion})?\\)`, 31 | ); 32 | 33 | assert( 34 | userAgent.toString().match(regex), 35 | `"${userAgent.toString()}" user agent does not match ${regex}`, 36 | ); 37 | }); 38 | }); 39 | }); 40 | 41 | context('with a gitlab version present', function() { 42 | let environment; 43 | 44 | beforeEach(function() { 45 | environment = new Environment({ 46 | GITLAB_CI: 'true', 47 | CI_SERVER_VERSION: '8.14.3-ee', 48 | }); 49 | percyClient = new PercyClient({environment: environment}); 50 | userAgent = new UserAgent(percyClient); 51 | }); 52 | 53 | afterEach(function() { 54 | environment = null; 55 | }); 56 | 57 | describe('userAgent', function() { 58 | it('is correct', function() { 59 | let regex = new RegExp( 60 | `Percy/v1 percy-js/${version} ` + `\\(node/${process.version}; gitlab/8.14.3-ee\\)`, 61 | ); 62 | 63 | assert( 64 | userAgent.toString().match(regex), 65 | `"${userAgent.toString()}" user agent does not match ${regex}`, 66 | ); 67 | }); 68 | }); 69 | }); 70 | 71 | context('client and environment info set from a higher level client', function() { 72 | let clientInfo = 'react-percy-storybook/1.0.0'; 73 | let environmentInfo = 'react/15.6.1'; 74 | 75 | beforeEach(function() { 76 | percyClient = new PercyClient({ 77 | clientInfo: clientInfo, 78 | environmentInfo: environmentInfo, 79 | }); 80 | userAgent = new UserAgent(percyClient); 81 | }); 82 | 83 | describe('userAgent', function() { 84 | it('has the correct client and environment info', function() { 85 | let regex = new RegExp( 86 | `Percy/v1 ${clientInfo} percy-js/${version} ` + 87 | `\\(${environmentInfo}; node/${process.version}` + 88 | `(; ${percyClient.environment.ciVersion})?\\)`, 89 | ); 90 | 91 | assert( 92 | userAgent.toString().match(regex), 93 | `"${userAgent.toString()}" user agent does not match ${regex}`, 94 | ); 95 | }); 96 | }); 97 | }); 98 | 99 | context('sdkClient and sdkEnvironment info sent from percy-agent', function() { 100 | const clientInfo = 'react-percy-storybook/1.0.0'; 101 | const environmentInfo = 'react/15.6.1'; 102 | const sdkClientInfo = '@percy/cypress/0.2.0'; 103 | const sdkEnvironmentInfo = 'cypress/3.1.0'; 104 | 105 | describe('userAgent', function() { 106 | beforeEach(function() { 107 | percyClient = new PercyClient({ 108 | clientInfo: clientInfo, 109 | environmentInfo: environmentInfo, 110 | }); 111 | percyClient._sdkClientInfo = sdkClientInfo; 112 | percyClient._sdkEnvironmentInfo = sdkEnvironmentInfo; 113 | userAgent = new UserAgent(percyClient); 114 | }); 115 | it('has the correct client and environment info', function() { 116 | let regex = new RegExp( 117 | `Percy/v1 ${sdkClientInfo} ${clientInfo} percy-js/${version} ` + 118 | `\\(${sdkEnvironmentInfo}; ${environmentInfo}; node/${process.version}` + 119 | `(; ${percyClient.environment.ciVersion})?\\)`, 120 | ); 121 | 122 | assert( 123 | userAgent.toString().match(regex), 124 | `"${userAgent.toString()}" user agent does not match ${regex}`, 125 | ); 126 | }); 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /test/util-test.js: -------------------------------------------------------------------------------- 1 | let path = require('path'); 2 | let utils = require(path.join(__dirname, '..', 'src', 'utils')); 3 | let assert = require('assert'); 4 | 5 | describe('sha256hash', function() { 6 | it('returns a SHA256 hash of the content', function() { 7 | let hash = utils.sha256hash('foo'); 8 | assert.equal(hash.length, 64); 9 | assert.equal(hash, '2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae'); 10 | }); 11 | 12 | it('correctly handles unicode', function() { 13 | let hash = utils.sha256hash('I ♡ JavaScript!'); 14 | assert.equal(hash, '67e714147fe88f73b000da2f0447d16083801ba3ac9c31f607cf8cbaf994aa09'); 15 | }); 16 | 17 | it('correctly handles binary data', function() { 18 | let hash = utils.sha256hash('\x01\x02\x99'); 19 | assert.equal(hash, '46e9b4475a55f86f185cc978fdaef90d4a2ef6ba66d77cecb8763a60999a41c3'); 20 | }); 21 | }); 22 | 23 | describe('base64encode', function() { 24 | it('returns Base 64 encoded content', function() { 25 | assert.equal(utils.base64encode('foo'), 'Zm9v'); 26 | }); 27 | 28 | it('correctly handles unicode', function() { 29 | assert.equal(utils.base64encode('I ♡ \nJavaScript!'), 'SSDimaEgCkphdmFTY3JpcHQh'); 30 | }); 31 | 32 | it('correctly handles binary data', function() { 33 | assert.equal(utils.base64encode('\x01\x02\x99'), 'AQLCmQ=='); 34 | }); 35 | }); 36 | 37 | describe('getMissingResources', function() { 38 | it('returns an empty list given no response', function() { 39 | assert.deepEqual(utils.getMissingResources(undefined), []); 40 | }); 41 | 42 | it('returns an empty list given no response body', function() { 43 | assert.deepEqual(utils.getMissingResources({}), []); 44 | }); 45 | 46 | it('returns an empty list given no response body data', function() { 47 | assert.deepEqual(utils.getMissingResources({body: {}}), []); 48 | }); 49 | 50 | it('returns an empty list given no response body data relationship', function() { 51 | assert.deepEqual(utils.getMissingResources({body: {data: {}}}), []); 52 | }); 53 | 54 | it('returns an empty list given no missing resources', function() { 55 | assert.deepEqual(utils.getMissingResources({body: {data: {relationships: {}}}}), []); 56 | }); 57 | 58 | it('returns an empty list given no missing resources data', function() { 59 | assert.deepEqual( 60 | utils.getMissingResources({ 61 | body: { 62 | data: { 63 | relationships: { 64 | 'missing-resources': {}, 65 | }, 66 | }, 67 | }, 68 | }), 69 | [], 70 | ); 71 | }); 72 | 73 | it('returns the missing resources', function() { 74 | assert.deepEqual( 75 | utils.getMissingResources({ 76 | body: { 77 | data: { 78 | relationships: { 79 | 'missing-resources': { 80 | data: [ 81 | { 82 | id: '123', 83 | }, 84 | { 85 | id: '456', 86 | }, 87 | ], 88 | }, 89 | }, 90 | }, 91 | }, 92 | }), 93 | [ 94 | { 95 | id: '123', 96 | }, 97 | { 98 | id: '456', 99 | }, 100 | ], 101 | ); 102 | }); 103 | }); 104 | --------------------------------------------------------------------------------