├── .editorconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── RELEASING.md ├── appveyor.yml ├── percy ├── __init__.py ├── client.py ├── config.py ├── connection.py ├── environment.py ├── errors.py ├── resource.py ├── resource_loader.py ├── runner.py ├── user_agent.py └── utils.py ├── requirements_dev.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── fixtures │ ├── build_response.json │ └── snapshot_response.json ├── test_client.py ├── test_config.py ├── test_connection.py ├── test_environment.py ├── test_resource_loader.py ├── test_runner.py ├── test_user_agent.py ├── test_utils.py └── testdata │ └── static │ ├── app.js │ ├── images │ ├── jellybeans.png │ ├── large-file-skipped.png │ └── logo.png │ └── styles.css └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .ropeproject/ 25 | .installed.cfg 26 | *.egg 27 | .DS_Store 28 | .pytest_cache/ 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | 58 | # Sphinx documentation 59 | docs/_build/ 60 | 61 | # PyBuilder 62 | target/ 63 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.org 2 | # This file will be regenerated if you run travis_pypi_setup.py 3 | 4 | language: python 5 | python: 3.5 6 | 7 | env: 8 | - TOXENV=py35 9 | - TOXENV=py34 10 | - TOXENV=py27 11 | # - TOXENV=py26 12 | # - TOXENV=pypy 13 | 14 | # command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 15 | install: pip install -U tox 16 | 17 | # command to run tests, e.g. python setup.py test 18 | script: tox 19 | 20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | 4 | recursive-include tests * 5 | recursive-exclude * __pycache__ 6 | recursive-exclude * *.py[co] 7 | 8 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build clean develop tdd 2 | define BROWSER_PYSCRIPT 3 | import os, webbrowser, sys 4 | try: 5 | from urllib import pathname2url 6 | except: 7 | from urllib.request import pathname2url 8 | 9 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 10 | endef 11 | export BROWSER_PYSCRIPT 12 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 13 | 14 | help: 15 | @echo "clean - remove all build, test, coverage and Python artifacts" 16 | @echo "clean-build - remove build artifacts" 17 | @echo "clean-pyc - remove Python file artifacts" 18 | @echo "clean-test - remove test and coverage artifacts" 19 | @echo "lint - check style with flake8" 20 | @echo "test - run tests quickly with the default Python" 21 | @echo "test-all - run tests on every Python version with tox" 22 | @echo "coverage - check code coverage quickly with the default Python" 23 | @echo "release - package and upload a release" 24 | @echo "dist - package" 25 | @echo "develop - install development dependencies" 26 | @echo "install - install the package to the active Python's site-packages" 27 | 28 | clean: clean-build clean-pyc clean-test 29 | 30 | clean-build: 31 | rm -fr build/ 32 | rm -fr dist/ 33 | rm -fr .eggs/ 34 | find . -name '*.egg-info' -exec rm -fr {} + 35 | find . -name '*.egg' -exec rm -rf {} + 36 | 37 | clean-pyc: 38 | find . -name '*.pyc' -exec rm -f {} + 39 | find . -name '*.pyo' -exec rm -f {} + 40 | find . -name '*~' -exec rm -f {} + 41 | find . -name '__pycache__' -exec rm -fr {} + 42 | 43 | clean-test: 44 | rm -fr .tox/ 45 | rm -f .coverage 46 | rm -fr htmlcov/ 47 | 48 | lint: 49 | flake8 percy tests 50 | 51 | test: 52 | py.test tests/ 53 | 54 | tdd: 55 | pip install pytest-xdist 56 | py.test -f -v tests 57 | 58 | test-all: 59 | tox 60 | 61 | coverage: 62 | coverage run --source percy py.test 63 | 64 | coverage report -m 65 | coverage html 66 | $(BROWSER) htmlcov/index.html 67 | 68 | bumpversion_patch: clean 69 | bumpversion patch 70 | 71 | bumpversion_minor: clean 72 | bumpversion minor 73 | 74 | bumpversion_major: clean 75 | bumpversion major 76 | 77 | release: clean 78 | git push 79 | git push --tags 80 | python setup.py register 81 | python setup.py sdist upload 82 | python setup.py bdist_wheel upload 83 | 84 | dist: clean 85 | python setup.py sdist 86 | python setup.py bdist_wheel 87 | ls -l dist 88 | 89 | develop: clean 90 | pip install -r requirements_dev.txt 91 | 92 | install: clean 93 | python setup.py install 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-percy-client 2 | 3 | [![Package Status](https://img.shields.io/pypi/v/percy.svg)](https://pypi.python.org/pypi/percy) 4 | [![Build Status](https://travis-ci.org/percy/python-percy-client.svg?branch=master)](https://travis-ci.org/percy/python-percy-client) 5 | 6 | Python client library for visual regression testing with [Percy](https://percy.io). 7 | 8 | **Note**: This SDK has been deprecated in favor of our new Python SDK (which matches the API of all our other SDKS): https://github.com/percy/percy-python-selenium 9 | 10 | ## Usage 11 | 12 | #### Docs here: [https://percy.io/docs/clients/python/selenium](https://percy.io/docs/clients/python/selenium) 13 | 14 | ## Contributing 15 | 16 | ### Setup for development 17 | 18 | ```bash 19 | $ sudo easy_install pip 20 | $ pip install virtualenv 21 | $ virtualenv env 22 | $ source env/bin/activate 23 | $ make develop 24 | ``` 25 | 26 | Development: 27 | 28 | ```bash 29 | source env/bin/activate 30 | make test 31 | make tdd 32 | ``` 33 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | 1. Make sure you've run the dev environment setup instructions in the README. 4 | 1. Run `source env/bin/activate` 5 | 1. Bump the version using: `make bumpversion_patch` or `make bumpversion_minor` or `bumpversion_major`. 6 | 1. Build and publish: `make release`. This will publish the package, and push the commit and tag to GitHub. 7 | 1. Draft and publish a [new release on github](https://github.com/percy/python-percy-client/releases) 8 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | build: off 2 | environment: 3 | # global: 4 | # WITH_COMPILER: 'cmd /E:ON /V:ON /C .\ci\appveyor-with-compiler.cmd' 5 | matrix: 6 | - TOXENV: 'py27' 7 | TOXPYTHON: C:\Python27\python.exe 8 | PYTHON_HOME: C:\Python27 9 | PYTHON_VERSION: '2.7' 10 | PYTHON_ARCH: '32' 11 | 12 | - TOXENV: 'py27' 13 | TOXPYTHON: C:\Python27-x64\python.exe 14 | WINDOWS_SDK_VERSION: v7.0 15 | PYTHON_HOME: C:\Python27-x64 16 | PYTHON_VERSION: '2.7' 17 | PYTHON_ARCH: '64' 18 | 19 | - TOXENV: 'py34' 20 | TOXPYTHON: C:\Python34\python.exe 21 | PYTHON_HOME: C:\Python34 22 | PYTHON_VERSION: '3.4' 23 | PYTHON_ARCH: '32' 24 | 25 | - TOXENV: 'py34' 26 | TOXPYTHON: C:\Python34-x64\python.exe 27 | WINDOWS_SDK_VERSION: v7.1 28 | PYTHON_HOME: C:\Python34-x64 29 | PYTHON_VERSION: '3.4' 30 | PYTHON_ARCH: '64' 31 | 32 | - TOXENV: 'py35' 33 | TOXPYTHON: C:\Python35\python.exe 34 | PYTHON_HOME: C:\Python35 35 | PYTHON_VERSION: '3.5' 36 | PYTHON_ARCH: '32' 37 | 38 | - TOXENV: 'py35' 39 | TOXPYTHON: C:\Python35-x64\python.exe 40 | PYTHON_HOME: C:\Python35-x64 41 | PYTHON_VERSION: '3.5' 42 | PYTHON_ARCH: '64' 43 | 44 | # Python 3.6 fails with: "Failed to build cryptography" 45 | # - TOXENV: 'py36' 46 | # TOXPYTHON: C:\Python36\python.exe 47 | # PYTHON_HOME: C:\Python36 48 | # PYTHON_VERSION: '3.6' 49 | # PYTHON_ARCH: '32' 50 | # 51 | # - TOXENV: 'py36' 52 | # TOXPYTHON: C:\Python36-x64\python.exe 53 | # PYTHON_HOME: C:\Python36-x64 54 | # PYTHON_VERSION: '3.6' 55 | # PYTHON_ARCH: '64' 56 | 57 | init: 58 | - ps: echo $env:TOXENV 59 | - ps: ls C:\Python* 60 | install: 61 | - '%PYTHON_HOME%\Scripts\virtualenv --version' 62 | - '%PYTHON_HOME%\Scripts\easy_install --version' 63 | - '%PYTHON_HOME%\Scripts\pip --version' 64 | - '%PYTHON_HOME%\Scripts\pip install -U tox' 65 | - '%PYTHON_HOME%\Scripts\tox --version' 66 | 67 | test_script: 68 | - '%WITH_COMPILER% %PYTHON_HOME%\Scripts\tox' 69 | 70 | on_failure: 71 | - ps: dir "env:" 72 | - ps: get-content .tox\*\log\* 73 | -------------------------------------------------------------------------------- /percy/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = 'Perceptual Inc.' 4 | __email__ = 'team@percy.io' 5 | __version__ = '2.0.2' 6 | 7 | from percy.client import * 8 | from percy.config import * 9 | from percy.environment import * 10 | from percy.resource import * 11 | from percy.resource_loader import * 12 | from percy.runner import * 13 | -------------------------------------------------------------------------------- /percy/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from percy.connection import Connection 4 | from percy.environment import Environment 5 | from percy.config import Config 6 | from percy import utils 7 | 8 | __all__ = ['Client'] 9 | 10 | 11 | class Client(object): 12 | 13 | def __init__(self, connection=None, config=None, environment=None): 14 | self._environment = environment if environment else Environment() 15 | self._config = config if config else Config() 16 | self._connection = connection if connection else Connection(self._config, self._environment) 17 | 18 | @property 19 | def connection(self): 20 | return self._connection 21 | 22 | @property 23 | def config(self): 24 | return self._config 25 | 26 | @property 27 | def environment(self): 28 | return self._environment 29 | 30 | def create_build(self, **kwargs): 31 | branch = kwargs.get('branch') or self.environment.branch 32 | pull_request_number = kwargs.get('pull_request_number') \ 33 | or self.environment.pull_request_number 34 | resources = kwargs.get('resources') 35 | parallel_nonce = kwargs.get('parallel_nonce') or self.environment.parallel_nonce 36 | parallel_total_shards = kwargs.get('parallel_total_shards') \ 37 | or self.environment.parallel_total_shards 38 | 39 | # Only pass parallelism data if it all exists. 40 | if not parallel_nonce or not parallel_total_shards: 41 | parallel_nonce = None 42 | parallel_total_shards = None 43 | 44 | commit_data = kwargs.get('commit_data') or self.environment.commit_data; 45 | 46 | data = { 47 | 'data': { 48 | 'type': 'builds', 49 | 'attributes': { 50 | 'branch': branch, 51 | 'target-branch': self.environment.target_branch, 52 | 'target-commit-sha': self.environment.target_commit_sha, 53 | 'commit-sha': commit_data['sha'], 54 | 'commit-committed-at': commit_data['committed_at'], 55 | 'commit-author-name': commit_data['author_name'], 56 | 'commit-author-email': commit_data['author_email'], 57 | 'commit-committer-name': commit_data['committer_name'], 58 | 'commit-committer-email': commit_data['committer_email'], 59 | 'commit-message': commit_data['message'], 60 | 'pull-request-number': pull_request_number, 61 | 'parallel-nonce': parallel_nonce, 62 | 'parallel-total-shards': parallel_total_shards, 63 | } 64 | } 65 | } 66 | 67 | if resources: 68 | data['data']['relationships'] = { 69 | 'resources': { 70 | 'data': [r.serialize() for r in resources], 71 | } 72 | } 73 | 74 | path = "{base_url}/builds/".format(base_url=self.config.api_url) 75 | 76 | return self._connection.post(path=path, data=data) 77 | 78 | def finalize_build(self, build_id): 79 | path = "{base_url}/builds/{build_id}/finalize".format( 80 | base_url=self.config.api_url, 81 | build_id=build_id, 82 | ) 83 | return self._connection.post(path=path, data={}) 84 | 85 | def create_snapshot(self, build_id, resources, **kwargs): 86 | if not resources or len(resources) <= 0: 87 | raise ValueError( 88 | 'resources should be an array of Percy.Resource objects' 89 | ) 90 | widths = kwargs.get('widths', self.config.default_widths) 91 | data = { 92 | 'data': { 93 | 'type': 'snapshots', 94 | 'attributes': { 95 | 'name': kwargs.get('name'), 96 | 'enable-javascript': kwargs.get('enable_javascript'), 97 | 'widths': widths, 98 | }, 99 | 'relationships': { 100 | 'resources': { 101 | 'data': [r.serialize() for r in resources], 102 | } 103 | } 104 | } 105 | } 106 | path = "{base_url}/builds/{build_id}/snapshots/".format( 107 | base_url=self.config.api_url, 108 | build_id=build_id 109 | ) 110 | return self._connection.post(path=path, data=data) 111 | 112 | def finalize_snapshot(self, snapshot_id): 113 | path = "{base_url}/snapshots/{snapshot_id}/finalize".format( 114 | base_url=self.config.api_url, 115 | snapshot_id=snapshot_id 116 | ) 117 | return self._connection.post(path=path, data={}) 118 | 119 | def upload_resource(self, build_id, content): 120 | sha = utils.sha256hash(content) 121 | data = { 122 | 'data': { 123 | 'type': 'resources', 124 | 'id': sha, 125 | 'attributes': { 126 | 'base64-content': utils.base64encode(content), 127 | } 128 | 129 | } 130 | } 131 | path = "{base_url}/builds/{build_id}/resources/".format( 132 | base_url=self.config.api_url, 133 | build_id=build_id 134 | ) 135 | return self._connection.post(path=path, data=data) 136 | -------------------------------------------------------------------------------- /percy/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from percy import errors 3 | 4 | __all__ = ['Config'] 5 | 6 | 7 | class Config(object): 8 | 9 | def __init__(self, api_url=None, default_widths=None, access_token=None): 10 | self._api_url = os.getenv('PERCY_API', api_url or 'https://percy.io/api/v1') 11 | self._default_widths = default_widths or [] 12 | self._access_token = os.getenv('PERCY_TOKEN', access_token) 13 | 14 | @property 15 | def api_url(self): 16 | return self._api_url 17 | 18 | @api_url.setter 19 | def api_url(self, value): 20 | self._api_url = value 21 | 22 | @property 23 | def default_widths(self): 24 | return self._default_widths 25 | 26 | @default_widths.setter 27 | def default_widths(self, value): 28 | self._default_widths = value 29 | 30 | @property 31 | def access_token(self): 32 | if not self._access_token: 33 | raise errors.AuthError('You must set PERCY_TOKEN to authenticate.') 34 | return self._access_token 35 | 36 | @access_token.setter 37 | def access_token(self, value): 38 | self._access_token = value 39 | -------------------------------------------------------------------------------- /percy/connection.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from requests.adapters import HTTPAdapter 3 | from requests.packages.urllib3.util.retry import Retry 4 | from percy.user_agent import UserAgent 5 | from percy import utils 6 | 7 | class Connection(object): 8 | def __init__(self, config, environment): 9 | self.config = config 10 | self.user_agent = str(UserAgent(config, environment)) 11 | 12 | def _requests_retry_session( 13 | self, 14 | retries=3, 15 | backoff_factor=0.3, 16 | method_whitelist=['HEAD', 'GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'TRACE'], 17 | status_forcelist=(500, 502, 503, 504, 520, 524), 18 | session=None, 19 | ): 20 | session = session or requests.Session() 21 | retry = Retry( 22 | total=retries, 23 | read=retries, 24 | connect=retries, 25 | status=retries, 26 | method_whitelist=method_whitelist, 27 | backoff_factor=backoff_factor, 28 | status_forcelist=status_forcelist, 29 | ) 30 | adapter = HTTPAdapter(max_retries=retry) 31 | session.mount('http://', adapter) 32 | session.mount('https://', adapter) 33 | return session 34 | 35 | def _token_header(self): 36 | return "Token token={0}".format(self.config.access_token) 37 | 38 | def get(self, path, options={}): 39 | headers = { 40 | 'Authorization': self._token_header(), 41 | 'User-Agent': self.user_agent, 42 | } 43 | response = self._requests_retry_session().get(path, headers=headers) 44 | try: 45 | response.raise_for_status() 46 | except requests.exceptions.HTTPError as e: 47 | utils.print_error('Received a {} error requesting: {}'.format(response.status_code, path)) 48 | utils.print_error(response.content) 49 | raise e 50 | return response.json() 51 | 52 | def post(self, path, data, options={}): 53 | headers = { 54 | 'Content-Type': 'application/vnd.api+json', 55 | 'Authorization': self._token_header(), 56 | 'User-Agent': self.user_agent, 57 | } 58 | response = self._requests_retry_session().post(path, json=data, headers=headers) 59 | try: 60 | response.raise_for_status() 61 | except requests.exceptions.HTTPError as e: 62 | utils.print_error('Received a {} error posting to: {}.'.format(response.status_code, path)) 63 | utils.print_error(response.content) 64 | raise e 65 | return response.json() 66 | -------------------------------------------------------------------------------- /percy/environment.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess 4 | 5 | from percy import errors 6 | from percy import utils 7 | 8 | __all__ = ['Environment'] 9 | 10 | GIT_COMMIT_FORMAT = '%n'.join([ 11 | 'COMMIT_SHA:%H', 12 | 'AUTHOR_NAME:%an', 13 | 'AUTHOR_EMAIL:%ae', 14 | 'COMMITTER_NAME:%cn', 15 | 'COMMITTER_EMAIL:%ce', 16 | 'COMMITTED_DATE:%ai', 17 | # Note: order is important, this must come last because the regex is a multiline match. 18 | 'COMMIT_MESSAGE:%B', 19 | ]); # git show format uses %n for newlines. 20 | 21 | 22 | class Environment(object): 23 | def __init__(self): 24 | self._real_env = None 25 | if os.getenv('TRAVIS_BUILD_ID'): 26 | self._real_env = TravisEnvironment() 27 | elif os.getenv('JENKINS_URL'): 28 | self._real_env = JenkinsEnvironment() 29 | elif os.getenv('CIRCLECI'): 30 | self._real_env = CircleEnvironment() 31 | elif os.getenv('CI_NAME') == 'codeship': 32 | self._real_env = CodeshipEnvironment() 33 | elif os.getenv('DRONE') == 'true': 34 | self._real_env = DroneEnvironment() 35 | elif os.getenv('SEMAPHORE') == 'true': 36 | self._real_env = SemaphoreEnvironment() 37 | elif os.getenv('BUILDKITE') == 'true': 38 | self._real_env = BuildkiteEnvironment() 39 | elif os.getenv('GITLAB_CI') == 'true': 40 | self._real_env = GitlabEnvironment() 41 | 42 | @property 43 | def current_ci(self): 44 | if self._real_env: 45 | return self._real_env.current_ci 46 | 47 | @property 48 | def pull_request_number(self): 49 | if os.getenv('PERCY_PULL_REQUEST'): 50 | return os.getenv('PERCY_PULL_REQUEST') 51 | if self._real_env and hasattr(self._real_env, 'pull_request_number'): 52 | return self._real_env.pull_request_number 53 | 54 | @property 55 | def branch(self): 56 | # First, percy env var. 57 | if os.getenv('PERCY_BRANCH'): 58 | return os.getenv('PERCY_BRANCH') 59 | # Second, from the CI environment. 60 | if self._real_env and hasattr(self._real_env, 'branch'): 61 | return self._real_env.branch 62 | # Third, from the local git repo. 63 | raw_branch_output = self._raw_branch_output() 64 | if raw_branch_output: 65 | return raw_branch_output 66 | # Fourth, fallback to NONE. 67 | utils.print_error('[percy] Warning: unknown git repo, branch not detected.') 68 | return None 69 | 70 | @property 71 | def target_branch(self): 72 | if os.getenv('PERCY_TARGET_BRANCH'): 73 | return os.getenv('PERCY_TARGET_BRANCH') 74 | return None 75 | 76 | @property 77 | def commit_data(self): 78 | 79 | # Try getting data from git 80 | # If this has a result, it means git is present in the system. 81 | raw_git_output = self._git_commit_output() 82 | 83 | # If not running in a git repo, allow undefined for certain commit attributes. 84 | def parse(regex): 85 | if not raw_git_output: 86 | return None 87 | match = regex.search(raw_git_output) 88 | if match: 89 | return match.group(1) 90 | else: 91 | return None 92 | 93 | return { 94 | # The only required attribute: 95 | 'branch': self.branch, 96 | # An optional but important attribute: 97 | 'sha': self.commit_sha or parse(re.compile("COMMIT_SHA:(.*)")), 98 | 99 | # Optional attributes: 100 | # If we have the git information, read from those rather than env vars. 101 | # The GIT_ environment vars are from the Jenkins Git Plugin, but could be 102 | # used generically. This behavior may change in the future. 103 | 'message': parse(re.compile("COMMIT_MESSAGE:(.*)", flags=re.MULTILINE)), 104 | 'committed_at': parse(re.compile("COMMITTED_DATE:(.*)")), 105 | 'author_name': parse(re.compile("AUTHOR_NAME:(.*)")) or os.getenv('GIT_AUTHOR_NAME'), 106 | 'author_email': parse(re.compile("AUTHOR_EMAIL:(.*)")) or os.getenv('GIT_AUTHOR_EMAIL'), 107 | 'committer_name': parse(re.compile("COMMITTER_NAME:(.*)")) or os.getenv('GIT_COMMITTER_NAME'), 108 | 'committer_email': parse(re.compile("COMMITTER_EMAIL:(.*)")) or os.getenv('GIT_COMMITTER_EMAIL'), 109 | } 110 | 111 | @property 112 | def commit_sha(self): 113 | # First, percy env var. 114 | if os.getenv('PERCY_COMMIT'): 115 | return os.getenv('PERCY_COMMIT') 116 | # Second, from the CI environment. 117 | if self._real_env and hasattr(self._real_env, 'commit_sha'): 118 | return self._real_env.commit_sha 119 | 120 | @property 121 | def target_commit_sha(self): 122 | if os.getenv('PERCY_TARGET_COMMIT'): 123 | return os.getenv('PERCY_TARGET_COMMIT') 124 | return None 125 | 126 | @property 127 | def parallel_nonce(self): 128 | if os.getenv('PERCY_PARALLEL_NONCE'): 129 | return os.getenv('PERCY_PARALLEL_NONCE') 130 | if self._real_env and hasattr(self._real_env, 'parallel_nonce'): 131 | return self._real_env.parallel_nonce 132 | 133 | @property 134 | def parallel_total_shards(self): 135 | if os.getenv('PERCY_PARALLEL_TOTAL'): 136 | return int(os.getenv('PERCY_PARALLEL_TOTAL')) 137 | if self._real_env and hasattr(self._real_env, 'parallel_total_shards'): 138 | return self._real_env.parallel_total_shards 139 | 140 | def _raw_git_output(self, args): 141 | process = subprocess.Popen( 142 | ['git'] + args, 143 | stdout=subprocess.PIPE, 144 | stderr=subprocess.PIPE, 145 | shell=False 146 | ) 147 | return process.stdout.read().strip().decode('utf-8') 148 | 149 | def _raw_commit_output(self, commit_sha): 150 | # Make sure commit_sha is only alphanumeric characters to prevent command injection. 151 | if not commit_sha or len(commit_sha) > 100 or not commit_sha.isalnum(): 152 | return '' 153 | 154 | args = ['show', commit_sha, '--quiet', '--format="' + GIT_COMMIT_FORMAT + '"']; 155 | return self._raw_git_output(args) 156 | 157 | def _git_commit_output(self): 158 | raw_git_output = '' 159 | # Try getting commit data from commit_sha set by environment variables 160 | if self.commit_sha: 161 | raw_git_output = self._raw_commit_output(self.commit_sha) 162 | 163 | # If there's no raw_git_output, it probably means there's not a sha, so try `HEAD` 164 | if not raw_git_output: 165 | raw_git_output = self._raw_commit_output('HEAD') 166 | 167 | return raw_git_output 168 | 169 | def _raw_branch_output(self): 170 | return self._raw_git_output(['rev-parse', '--abbrev-ref', 'HEAD']) 171 | 172 | def _get_origin_url(self): 173 | process = subprocess.Popen( 174 | 'git config --get remote.origin.url', stdout=subprocess.PIPE, shell=True) 175 | return process.stdout.read().strip().decode('utf-8') 176 | 177 | 178 | 179 | class TravisEnvironment(object): 180 | @property 181 | def current_ci(self): 182 | return 'travis' 183 | 184 | @property 185 | def pull_request_number(self): 186 | pr_num = os.getenv('TRAVIS_PULL_REQUEST') 187 | if pr_num != 'false': 188 | return pr_num 189 | 190 | @property 191 | def branch(self): 192 | if self.pull_request_number and os.getenv('TRAVIS_PULL_REQUEST_BRANCH'): 193 | return os.getenv('TRAVIS_PULL_REQUEST_BRANCH') 194 | return os.getenv('TRAVIS_BRANCH') 195 | 196 | @property 197 | def commit_sha(self): 198 | return os.getenv('TRAVIS_COMMIT') 199 | 200 | @property 201 | def parallel_nonce(self): 202 | return os.getenv('TRAVIS_BUILD_NUMBER') 203 | 204 | @property 205 | def parallel_total_shards(self): 206 | if os.getenv('CI_NODE_TOTAL', '').isdigit(): 207 | return int(os.getenv('CI_NODE_TOTAL')) 208 | 209 | 210 | class JenkinsEnvironment(object): 211 | @property 212 | def current_ci(self): 213 | return 'jenkins' 214 | 215 | @property 216 | def pull_request_number(self): 217 | # GitHub Pull Request Builder plugin. 218 | return os.getenv('ghprbPullId') 219 | 220 | @property 221 | def branch(self): 222 | return os.getenv('ghprbSourceBranch') 223 | 224 | @property 225 | def commit_sha(self): 226 | return os.getenv('ghprbActualCommit') or os.getenv('GIT_COMMIT') 227 | 228 | @property 229 | def parallel_nonce(self): 230 | return os.getenv('BUILD_NUMBER') 231 | 232 | 233 | class CircleEnvironment(object): 234 | @property 235 | def current_ci(self): 236 | return 'circle' 237 | 238 | @property 239 | def pull_request_number(self): 240 | pr_url = os.getenv('CI_PULL_REQUEST') 241 | if pr_url: 242 | return os.getenv('CI_PULL_REQUEST').split('/')[-1] 243 | 244 | @property 245 | def branch(self): 246 | return os.getenv('CIRCLE_BRANCH') 247 | 248 | @property 249 | def commit_sha(self): 250 | return os.getenv('CIRCLE_SHA1') 251 | 252 | @property 253 | def parallel_nonce(self): 254 | return os.getenv('CIRCLE_WORKFLOW_WORKSPACE_ID') or os.getenv('CIRCLE_BUILD_NUM') 255 | 256 | @property 257 | def parallel_total_shards(self): 258 | if os.getenv('CIRCLE_NODE_TOTAL', '').isdigit(): 259 | return int(os.getenv('CIRCLE_NODE_TOTAL')) 260 | 261 | 262 | class CodeshipEnvironment(object): 263 | @property 264 | def current_ci(self): 265 | return 'codeship' 266 | 267 | @property 268 | def pull_request_number(self): 269 | pr_num = os.getenv('CI_PULL_REQUEST') 270 | # Unfortunately, codeship seems to always returns 'false', so let this be null. 271 | if pr_num != 'false': 272 | return pr_num 273 | 274 | @property 275 | def branch(self): 276 | return os.getenv('CI_BRANCH') 277 | 278 | @property 279 | def commit_sha(self): 280 | return os.getenv('CI_COMMIT_ID') 281 | 282 | @property 283 | def parallel_nonce(self): 284 | return os.getenv('CI_BUILD_NUMBER') or os.getenv('CI_BUILD_ID') 285 | 286 | @property 287 | def parallel_total_shards(self): 288 | if os.getenv('CI_NODE_TOTAL', '').isdigit(): 289 | return int(os.getenv('CI_NODE_TOTAL')) 290 | 291 | 292 | class DroneEnvironment(object): 293 | @property 294 | def current_ci(self): 295 | return 'drone' 296 | 297 | @property 298 | def pull_request_number(self): 299 | return os.getenv('CI_PULL_REQUEST') 300 | 301 | @property 302 | def branch(self): 303 | return os.getenv('DRONE_BRANCH') 304 | 305 | @property 306 | def commit_sha(self): 307 | return os.getenv('DRONE_COMMIT') 308 | 309 | 310 | class SemaphoreEnvironment(object): 311 | @property 312 | def current_ci(self): 313 | return 'semaphore' 314 | 315 | @property 316 | def pull_request_number(self): 317 | return os.getenv('PULL_REQUEST_NUMBER') 318 | 319 | @property 320 | def branch(self): 321 | return os.getenv('BRANCH_NAME') 322 | 323 | @property 324 | def commit_sha(self): 325 | return os.getenv('REVISION') 326 | 327 | @property 328 | def parallel_nonce(self): 329 | return '%s/%s' % ( 330 | os.getenv('SEMAPHORE_BRANCH_ID'), 331 | os.getenv('SEMAPHORE_BUILD_NUMBER') 332 | ) 333 | 334 | @property 335 | def parallel_total_shards(self): 336 | if os.getenv('SEMAPHORE_THREAD_COUNT', '').isdigit(): 337 | return int(os.getenv('SEMAPHORE_THREAD_COUNT')) 338 | 339 | 340 | class BuildkiteEnvironment(object): 341 | @property 342 | def current_ci(self): 343 | return 'buildkite' 344 | 345 | @property 346 | def pull_request_number(self): 347 | return os.getenv('BUILDKITE_PULL_REQUEST') 348 | 349 | @property 350 | def branch(self): 351 | return os.getenv('BUILDKITE_BRANCH') 352 | 353 | @property 354 | def commit_sha(self): 355 | if os.getenv('BUILDKITE_COMMIT') == 'HEAD': 356 | # Buildkite mixes SHAs and non-SHAs in BUILDKITE_COMMIT, so we return null if non-SHA. 357 | return 358 | return os.getenv('BUILDKITE_COMMIT') 359 | 360 | @property 361 | def parallel_nonce(self): 362 | return os.getenv('BUILDKITE_BUILD_ID') 363 | 364 | @property 365 | def parallel_total_shards(self): 366 | if os.getenv('BUILDKITE_PARALLEL_JOB_COUNT', '').isdigit(): 367 | return int(os.getenv('BUILDKITE_PARALLEL_JOB_COUNT')) 368 | 369 | 370 | class GitlabEnvironment(object): 371 | @property 372 | def current_ci(self): 373 | return 'gitlab' 374 | 375 | @property 376 | def pull_request_number(self): 377 | return os.getenv('PERCY_PULL_REQUEST') 378 | 379 | @property 380 | def branch(self): 381 | return os.getenv('CI_COMMIT_REF_NAME') 382 | 383 | @property 384 | def commit_sha(self): 385 | return os.getenv('CI_COMMIT_SHA') 386 | 387 | @property 388 | def parallel_nonce(self): 389 | return '%s/%s' % ( 390 | os.getenv('CI_COMMIT_REF_NAME'), 391 | os.getenv('CI_JOB_ID') 392 | ) 393 | -------------------------------------------------------------------------------- /percy/errors.py: -------------------------------------------------------------------------------- 1 | class Error(Exception): 2 | pass 3 | 4 | class AuthError(Error): 5 | pass 6 | 7 | class RepoNotFoundError(Error): 8 | pass 9 | 10 | class UninitializedBuildError(Error): 11 | pass 12 | -------------------------------------------------------------------------------- /percy/resource.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from percy import utils 3 | 4 | __all__ = ['Resource'] 5 | 6 | 7 | class Resource(object): 8 | 9 | def __init__(self, resource_url, is_root=False, **kwargs): 10 | self.resource_url = resource_url 11 | if 'sha' in kwargs and 'content' in kwargs or not ('sha' in kwargs or 'content' in kwargs): 12 | raise ValueError('Exactly one of sha or content is required.') 13 | if 'sha' in kwargs and not 'content' in kwargs and not 'local_path' in kwargs: 14 | raise ValueError('If only "sha" is given, content or local_path is required.') 15 | self.content = kwargs.get('content') 16 | self.sha = kwargs.get('sha') or utils.sha256hash(self.content) 17 | self.is_root = is_root 18 | self.mimetype = kwargs.get('mimetype') 19 | 20 | # Convenience vars for optimizations, not included when serialized. 21 | self.local_path = kwargs.get('local_path') 22 | 23 | def __repr__(self): 24 | return ''.format(self.resource_url) 25 | 26 | def serialize(self): 27 | return { 28 | 'type': 'resources', 29 | 'id': self.sha, 30 | 'attributes': { 31 | 'resource-url': self.resource_url, 32 | 'mimetype': self.mimetype, 33 | 'is-root': self.is_root, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /percy/resource_loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import percy 3 | try: 4 | # Python 3's pathname2url 5 | from urllib.request import pathname2url 6 | except ImportError: 7 | # Python 2's pathname2url 8 | from urllib import pathname2url 9 | 10 | from percy import utils 11 | 12 | try: 13 | from urllib.parse import urlparse 14 | except ImportError: 15 | from urlparse import urlparse 16 | 17 | __all__ = ['ResourceLoader'] 18 | 19 | MAX_FILESIZE_BYTES = 15 * 1024**2 # 15 MiB. 20 | 21 | 22 | class BaseResourceLoader(object): 23 | @property 24 | def build_resources(self): 25 | raise NotImplementedError('subclass must implement abstract method') 26 | 27 | @property 28 | def snapshot_resources(self): 29 | raise NotImplementedError('subclass must implement abstract method') 30 | 31 | 32 | class ResourceLoader(BaseResourceLoader): 33 | def __init__(self, root_dir=None, base_url=None, webdriver=None): 34 | self.root_dir = root_dir 35 | self.base_url = base_url 36 | if self.base_url and self.base_url.endswith(os.path.sep): 37 | self.base_url = self.base_url[:-1] 38 | # TODO: more separate loader subclasses and pull out Selenium-specific logic? 39 | self.webdriver = webdriver 40 | 41 | @property 42 | def build_resources(self): 43 | resources = [] 44 | if not self.root_dir: 45 | return resources 46 | for root, dirs, files in os.walk(self.root_dir, followlinks=True): 47 | for file_name in files: 48 | path = os.path.join(root, file_name) 49 | if os.path.getsize(path) > MAX_FILESIZE_BYTES: 50 | continue 51 | with open(path, 'rb') as f: 52 | content = f.read() 53 | 54 | path_for_url = pathname2url(path.replace(self.root_dir, '', 1)) 55 | if self.base_url[-1] == '/' and path_for_url[0] == '/': 56 | path_for_url = path_for_url.replace('/', '' , 1) 57 | 58 | 59 | resource_url = "{0}{1}".format(self.base_url, path_for_url) 60 | resource = percy.Resource( 61 | resource_url=resource_url, 62 | sha=utils.sha256hash(content), 63 | local_path=os.path.abspath(path), 64 | ) 65 | resources.append(resource) 66 | return resources 67 | 68 | @property 69 | def snapshot_resources(self): 70 | # Only one snapshot resource, the root page HTML. 71 | return [ 72 | percy.Resource( 73 | # Assumes a Selenium webdriver interface. 74 | resource_url=urlparse(self.webdriver.current_url).path, 75 | is_root=True, 76 | mimetype='text/html', 77 | content=self.webdriver.page_source, 78 | ) 79 | ] 80 | -------------------------------------------------------------------------------- /percy/runner.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import os 4 | import percy 5 | from percy import errors 6 | from percy import utils 7 | 8 | __all__ = ['Runner'] 9 | 10 | 11 | class Runner(object): 12 | 13 | def __init__(self, loader=None, config=None, client=None): 14 | self.loader = loader 15 | self.config = config or percy.Config() 16 | self.client = client or percy.Client(config=self.config) 17 | self._current_build = None 18 | 19 | self._is_enabled = os.getenv('PERCY_ENABLE', '1') == '1' 20 | 21 | # Sanity check environment and auth setup. If in CI and Percy is disabled, print an error. 22 | if self._is_enabled: 23 | try: 24 | self.client.config.access_token 25 | except errors.AuthError: 26 | if self.client.environment.current_ci: 27 | utils.print_error('[percy] Warning: Percy is disabled, no PERCY_TOKEN set.') 28 | self._is_enabled = False 29 | 30 | def initialize_build(self, **kwargs): 31 | # Silently pass if Percy is disabled. 32 | if not self._is_enabled: 33 | return 34 | 35 | build_resources = [] 36 | build_resources = self.loader.build_resources if self.loader else [] 37 | sha_to_build_resource = {} 38 | for build_resource in build_resources: 39 | sha_to_build_resource[build_resource.sha] = build_resource 40 | 41 | self._current_build = self.client.create_build(resources=build_resources, **kwargs) 42 | 43 | missing_resources = self._current_build['data']['relationships']['missing-resources'] 44 | missing_resources = missing_resources.get('data', []) 45 | 46 | for missing_resource in missing_resources: 47 | sha = missing_resource['id'] 48 | resource = sha_to_build_resource.get(sha) 49 | # This resource should always exist, but if by chance it doesn't we make it safe here. 50 | # A nicer error will be raised by the finalize API when the resource is still missing. 51 | if resource: 52 | print('Uploading new build resource: {}'.format(resource.resource_url)) 53 | 54 | # Optimization: we don't hold all build resources in memory. Instead we store a 55 | # "local_path" variable that be used to read the file again if it is needed. 56 | if resource.local_path: 57 | with open(resource.local_path, 'rb') as f: 58 | content = f.read() 59 | else: 60 | content = resource.content 61 | self.client.upload_resource(self._current_build['data']['id'], content) 62 | 63 | @property 64 | def build_id(self): 65 | if not self._is_enabled: 66 | return 67 | if not self._current_build: 68 | raise errors.UninitializedBuildError('Cannot get current build id before build is initialized') 69 | return self._current_build['data']['id'] 70 | 71 | def snapshot(self, **kwargs): 72 | # Silently pass if Percy is disabled. 73 | if not self._is_enabled: 74 | return 75 | if not self._current_build: 76 | raise errors.UninitializedBuildError('Cannot call snapshot before build is initialized') 77 | 78 | root_resource = self.loader.snapshot_resources[0] 79 | build_id = self._current_build['data']['id'] 80 | snapshot_data = self.client.create_snapshot(build_id, [root_resource], **kwargs) 81 | 82 | missing_resources = snapshot_data['data']['relationships']['missing-resources'] 83 | missing_resources = missing_resources.get('data', []) 84 | 85 | if missing_resources: 86 | # There can only be one missing resource in this case, the root_resource. 87 | self.client.upload_resource(build_id, root_resource.content) 88 | 89 | self.client.finalize_snapshot(snapshot_data['data']['id']) 90 | 91 | def finalize_build(self): 92 | # Silently pass if Percy is disabled. 93 | if not self._is_enabled: 94 | return 95 | if not self._current_build: 96 | raise errors.UninitializedBuildError( 97 | 'Cannot finalize_build before build is initialized.') 98 | self.client.finalize_build(self._current_build['data']['id']) 99 | self._current_build = None 100 | -------------------------------------------------------------------------------- /percy/user_agent.py: -------------------------------------------------------------------------------- 1 | import re 2 | import percy 3 | 4 | class UserAgent(object): 5 | 6 | def __init__(self, config, environment): 7 | self.config = config 8 | self.environment = environment 9 | 10 | def __str__(self): 11 | client = ' '.join(filter(None, [ 12 | "Percy/%s" % self._api_version(), 13 | "python-percy-client/%s" % self._client_version(), 14 | ])) 15 | 16 | environment = '; '.join(filter(None, [ 17 | self._environment_info(), 18 | "python/%s" % self._python_version(), 19 | self.environment.current_ci, 20 | ])) 21 | 22 | return "%s (%s)" % (client, environment) 23 | 24 | def _client_version(self): 25 | return percy.__version__ 26 | 27 | def _python_version(self): 28 | try: 29 | from platform import python_version 30 | return python_version() 31 | except ImportError: 32 | return 'unknown' 33 | 34 | def _django_version(self): 35 | try: 36 | import django 37 | return "django/%s" % django.get_version() 38 | except ImportError: 39 | return None 40 | 41 | def _api_version(self): 42 | return re.search(r'\w+$', self.config.api_url).group(0) 43 | 44 | def _environment_info(self): 45 | # we only detect django right now others could be added 46 | return self._django_version() 47 | -------------------------------------------------------------------------------- /percy/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import sys 3 | import hashlib 4 | import base64 5 | 6 | # TODO: considering using the 'six' library here, but for now just do something simple. 7 | 8 | def print_error(*args, **kwargs): 9 | print(*args, file=sys.stderr, **kwargs) 10 | 11 | def sha256hash(content): 12 | if _is_unicode(content): 13 | content = content.encode('utf-8') 14 | return hashlib.sha256(content).hexdigest() 15 | 16 | def base64encode(content): 17 | if _is_unicode(content): 18 | content = content.encode('utf-8') 19 | return base64.b64encode(content).decode('utf-8') 20 | 21 | def _is_unicode(content): 22 | if (sys.version_info >= (3,0) and isinstance(content, str) 23 | or sys.version_info < (3,0) and isinstance(content, unicode)): 24 | return True 25 | return False 26 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | PyYAML==5.1 2 | Sphinx==1.3.1 3 | bumpversion==0.5.3 4 | coverage==4.5.3 5 | cryptography==1.0.1 6 | flake8==3.7.8 7 | pytest==2.8.3 8 | requests_mock==1.6.0 9 | tox==2.1.1 10 | watchdog==0.9.0 11 | wheel==0.33.4 12 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 2.0.2 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:percy/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [wheel] 15 | universal = 1 16 | 17 | [flake8] 18 | exclude = docs 19 | 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup 5 | 6 | 7 | requirements = [ 8 | 'requests>=2.14.0' 9 | ] 10 | 11 | test_requirements = [ 12 | 'requests-mock', 13 | ] 14 | 15 | setup( 16 | name='percy', 17 | version='2.0.2', 18 | description='Python client library for visual regression testing with Percy (https://percy.io).', 19 | author='Perceptual Inc.', 20 | author_email='team@percy.io', 21 | url='https://github.com/percy/python-percy-client', 22 | packages=[ 23 | 'percy', 24 | ], 25 | package_dir={'percy': 'percy'}, 26 | include_package_data=True, 27 | install_requires=requirements, 28 | license='MIT', 29 | zip_safe=False, 30 | keywords='percy', 31 | classifiers=[ 32 | 'Development Status :: 2 - Pre-Alpha', 33 | 'Intended Audience :: Developers', 34 | 'License :: OSI Approved :: MIT License', 35 | 'Natural Language :: English', 36 | 'Programming Language :: Python :: 2', 37 | 'Programming Language :: Python :: 2.7', 38 | 'Programming Language :: Python :: 3', 39 | 'Programming Language :: Python :: 3.3', 40 | 'Programming Language :: Python :: 3.4', 41 | 'Programming Language :: Python :: 3.5', 42 | ], 43 | test_suite='tests', 44 | tests_require=test_requirements 45 | ) 46 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /tests/fixtures/build_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "id": "31", 4 | "type": "builds", 5 | "attributes": { 6 | "build-number": 31, 7 | "state": "pending", 8 | "is-pull-request": true, 9 | "pull-request-number": 123, 10 | "pull-request-title": null, 11 | "approved-at": null, 12 | "created-at": "2015-06-11T00:20:38.926Z", 13 | "updated-at": "2015-06-11T00:20:38.926Z" 14 | }, 15 | "links": { 16 | "self": "/api/v1/builds/31" 17 | }, 18 | "relationships": { 19 | "commit": { 20 | "links": { 21 | "self": "/api/v1/builds/31/relationships/commit", 22 | "related": "/api/v1/builds/31/commit" 23 | }, 24 | "data": { 25 | "type": "commits", 26 | "id": "2" 27 | } 28 | }, 29 | "repo": { 30 | "links": { 31 | "self": "/api/v1/builds/31/relationships/repo", 32 | "related": "/api/v1/builds/31/repo" 33 | } 34 | }, 35 | "base-build": { 36 | "links": { 37 | "self": "/api/v1/builds/31/relationships/base-build", 38 | "related": "/api/v1/builds/31/base-build" 39 | }, 40 | "data": { 41 | "type": "builds", 42 | "id": "29" 43 | } 44 | }, 45 | "approved-by": { 46 | "links": { 47 | "self": "/api/v1/builds/31/relationships/approved-by", 48 | "related": "/api/v1/builds/31/approved-by" 49 | } 50 | }, 51 | "snapshots": { 52 | "links": { 53 | "self": "/api/v1/builds/31/relationships/snapshots", 54 | "related": "/api/v1/builds/31/snapshots" 55 | } 56 | }, 57 | "comparisons": { 58 | "links": { 59 | "self": "/api/v1/builds/31/relationships/comparisons", 60 | "related": "/api/v1/builds/31/comparisons" 61 | } 62 | }, 63 | "missing-resources": { 64 | "links": { 65 | "self": "/api/v1/builds/31/relationships/missing-resources", 66 | "related": "/api/v1/builds/31/missing-resources" 67 | } 68 | } 69 | }, 70 | "meta": { 71 | "finalize-link": "/api/v1/builds/31/finalize", 72 | "approve-link": "/api/v1/builds/31/approve" 73 | } 74 | }, 75 | "included": [ 76 | { 77 | "id": "2", 78 | "type": "commits", 79 | "attributes": { 80 | "sha": "fc4d2c2e6b55d995a005dba2d071e2f2fca5e04b", 81 | "branch": "master", 82 | "message": "Fix test environment issue.", 83 | "committed-at": "2015-06-09 23:22:31 -0700", 84 | "author-name": "", 85 | "committer-name": "", 86 | "created-at": "2015-06-11T00:15:24.000Z", 87 | "updated-at": "2015-06-11T00:15:24.000Z" 88 | }, 89 | "links": { 90 | "self": "/api/v1/commits/2" 91 | } 92 | }, 93 | { 94 | "id": "29", 95 | "type": "builds", 96 | "attributes": { 97 | "build-number": 29, 98 | "state": "finished", 99 | "is-pull-request": false, 100 | "pull-request-number": 0, 101 | "pull-request-title": null, 102 | "approved-at": null, 103 | "created-at": "2015-06-11T00:20:38.000Z", 104 | "updated-at": "2015-06-11T00:20:38.000Z" 105 | }, 106 | "links": { 107 | "self": "/api/v1/builds/29" 108 | }, 109 | "relationships": { 110 | "commit": { 111 | "links": { 112 | "self": "/api/v1/builds/29/relationships/commit", 113 | "related": "/api/v1/builds/29/commit" 114 | }, 115 | "data": { 116 | "type": "commits", 117 | "id": "2" 118 | } 119 | }, 120 | "repo": { 121 | "links": { 122 | "self": "/api/v1/builds/29/relationships/repo", 123 | "related": "/api/v1/builds/29/repo" 124 | } 125 | }, 126 | "base-build": { 127 | "links": { 128 | "self": "/api/v1/builds/29/relationships/base-build", 129 | "related": "/api/v1/builds/29/base-build" 130 | } 131 | }, 132 | "approved-by": { 133 | "links": { 134 | "self": "/api/v1/builds/29/relationships/approved-by", 135 | "related": "/api/v1/builds/29/approved-by" 136 | } 137 | }, 138 | "snapshots": { 139 | "links": { 140 | "self": "/api/v1/builds/29/relationships/snapshots", 141 | "related": "/api/v1/builds/29/snapshots" 142 | } 143 | }, 144 | "comparisons": { 145 | "links": { 146 | "self": "/api/v1/builds/29/relationships/comparisons", 147 | "related": "/api/v1/builds/29/comparisons" 148 | } 149 | }, 150 | "missing-resources": { 151 | "links": { 152 | "self": "/api/v1/builds/29/relationships/missing-resources", 153 | "related": "/api/v1/builds/29/missing-resources" 154 | } 155 | } 156 | }, 157 | "meta": { 158 | "finalize-link": "/api/v1/builds/29/finalize", 159 | "approve-link": "/api/v1/builds/29/approve" 160 | } 161 | } 162 | ] 163 | } -------------------------------------------------------------------------------- /tests/fixtures/snapshot_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "id": "1110", 4 | "type": "snapshots", 5 | "attributes": { 6 | "name": "homepage", 7 | "created-at": "2015-06-11T00:20:38.085Z", 8 | "updated-at": "2015-06-11T00:20:38.085Z", 9 | "finished-processing-at": null 10 | }, 11 | "links": { 12 | "self": "/api/v1/snapshots/1110" 13 | }, 14 | "relationships": { 15 | "build": { 16 | "links": { 17 | "self": "/api/v1/snapshots/1110/relationships/build", 18 | "related": "/api/v1/snapshots/1110/build" 19 | } 20 | }, 21 | "screenshots": { 22 | "links": { 23 | "self": "/api/v1/snapshots/1110/relationships/screenshots", 24 | "related": "/api/v1/snapshots/1110/screenshots" 25 | } 26 | }, 27 | "missing-resources": { 28 | "links": { 29 | "self": "/api/v1/snapshots/1110/relationships/missing-resources", 30 | "related": "/api/v1/snapshots/1110/missing-resources" 31 | }, 32 | "data": [ 33 | { 34 | "type": "resources", 35 | "id": "8d68af40ce7ee4591a7df49d1a40db8bf0b6535a37c896eda504f963c622535a" 36 | } 37 | ] 38 | } 39 | } 40 | }, 41 | "included": [ 42 | { 43 | "id": "8d68af40ce7ee4591a7df49d1a40db8bf0b6535a37c896eda504f963c622535a", 44 | "type": "resources", 45 | "attributes": {}, 46 | "links": { 47 | "self": "/api/v1/resources/8d68af40ce7ee4591a7df49d1a40db8bf0b6535a37c896eda504f963c622535a" 48 | } 49 | } 50 | ] 51 | } -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import json 4 | import os 5 | import unittest 6 | 7 | import requests_mock 8 | import percy 9 | from percy import utils 10 | 11 | FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixtures') 12 | 13 | 14 | class TestPercyClient(unittest.TestCase): 15 | 16 | def setUp(self): 17 | percy_config = percy.Config(access_token='abcd1234', default_widths=[1280, 375]) 18 | self.percy_client = percy.Client(config=percy_config) 19 | 20 | def test_defaults(self): 21 | self.assertNotEqual(self.percy_client.connection, None) 22 | self.assertNotEqual(self.percy_client.config, None) 23 | self.assertNotEqual(self.percy_client.environment, None) 24 | 25 | def test_config_variables(self): 26 | self.assertEqual(self.percy_client.config.default_widths, [1280, 375]) 27 | self.percy_client.config.default_widths = [640, 480] 28 | self.assertEqual(self.percy_client.config.default_widths, [640, 480]) 29 | 30 | @requests_mock.Mocker() 31 | def test_create_build_from_token(self, mock): 32 | fixture_path = os.path.join(FIXTURES_DIR, 'build_response.json') 33 | build_fixture = open(fixture_path).read() 34 | mock.post('https://percy.io/api/v1/builds/', text=build_fixture) 35 | resources = [ 36 | percy.Resource(resource_url='/main.css', is_root=False, content='foo'), 37 | ] 38 | 39 | build_data = self.percy_client.create_build(resources=resources) 40 | commit_data = self.percy_client.environment.commit_data 41 | assert mock.request_history[0].json() == { 42 | 'data': { 43 | 'type': 'builds', 44 | 'attributes': { 45 | 'branch': self.percy_client.environment.branch, 46 | 'target-branch': self.percy_client.environment.target_branch, 47 | 'target-commit-sha': self.percy_client.environment.target_commit_sha, 48 | 'commit-sha': commit_data['sha'], 49 | 'commit-committed-at': commit_data['committed_at'], 50 | 'commit-author-name': commit_data['author_name'], 51 | 'commit-author-email': commit_data['author_email'], 52 | 'commit-committer-name': commit_data['committer_name'], 53 | 'commit-committer-email': commit_data['committer_email'], 54 | 'commit-message': commit_data['message'], 55 | 'pull-request-number': self.percy_client.environment.pull_request_number, 56 | 'parallel-nonce': self.percy_client.environment.parallel_nonce, 57 | 'parallel-total-shards': self.percy_client.environment.parallel_total_shards, 58 | }, 59 | 'relationships': { 60 | 'resources': { 61 | 'data': [ 62 | { 63 | 'type': 'resources', 64 | 'id': resources[0].sha, 65 | 'attributes': { 66 | 'resource-url': resources[0].resource_url, 67 | 'mimetype': resources[0].mimetype, 68 | 'is-root': resources[0].is_root, 69 | } 70 | } 71 | ], 72 | } 73 | } 74 | 75 | } 76 | } 77 | assert build_data == json.loads(build_fixture) 78 | 79 | @requests_mock.Mocker() 80 | def test_finalize_build(self, mock): 81 | fixture_path = os.path.join(FIXTURES_DIR, 'build_response.json') 82 | build_fixture = open(fixture_path).read() 83 | mock.post('https://percy.io/api/v1/builds/', text=build_fixture) 84 | mock.post('https://percy.io/api/v1/builds/31/finalize', text='{"success": "true"}') 85 | 86 | build_data = self.percy_client.create_build() 87 | build_id = build_data['data']['id'] 88 | finalize_response = self.percy_client.finalize_build(build_id=build_id) 89 | 90 | assert mock.request_history[1].json() == {} 91 | assert finalize_response == {'success': 'true'} 92 | 93 | @requests_mock.Mocker() 94 | def test_create_snapshot(self, mock): 95 | fixture_path = os.path.join(FIXTURES_DIR, 'build_response.json') 96 | build_fixture = open(fixture_path).read() 97 | mock.post('https://percy.io/api/v1/builds/', text=build_fixture) 98 | build_id = json.loads(build_fixture)['data']['id'] 99 | 100 | resources = [ 101 | percy.Resource(resource_url='/', is_root=True, content='foo'), 102 | ] 103 | 104 | fixture_path = os.path.join(FIXTURES_DIR, 'snapshot_response.json') 105 | mock_data = open(fixture_path).read() 106 | mock.post('https://percy.io/api/v1/builds/{0}/snapshots/'.format(build_id), text=mock_data) 107 | 108 | build_data = self.percy_client.create_build() 109 | snapshot_data = self.percy_client.create_snapshot( 110 | build_id, 111 | resources, 112 | name='homepage', 113 | widths=[1280], 114 | enable_javascript=True, 115 | ) 116 | 117 | assert mock.request_history[1].json() == { 118 | 'data': { 119 | 'type': 'snapshots', 120 | 'attributes': { 121 | 'name': 'homepage', 122 | 'enable-javascript': True, 123 | 'widths': [1280], 124 | }, 125 | 'relationships': { 126 | 'resources': { 127 | 'data': [ 128 | { 129 | 'type': 'resources', 130 | 'id': resources[0].sha, 131 | 'attributes': { 132 | 'resource-url': resources[0].resource_url, 133 | 'mimetype': resources[0].mimetype, 134 | 'is-root': resources[0].is_root, 135 | } 136 | } 137 | ], 138 | } 139 | } 140 | } 141 | } 142 | assert snapshot_data == json.loads(mock_data) 143 | 144 | @requests_mock.Mocker() 145 | def test_finalize_snapshot(self, mock): 146 | mock.post('https://percy.io/api/v1/snapshots/123/finalize', text='{"success":true}') 147 | self.assertEqual(self.percy_client.finalize_snapshot(123)['success'], True) 148 | assert mock.request_history[0].json() == {} 149 | 150 | @requests_mock.Mocker() 151 | def test_upload_resource(self, mock): 152 | mock.post('https://percy.io/api/v1/builds/123/resources/', text='{"success": "true"}') 153 | 154 | content = 'foo' 155 | result = self.percy_client.upload_resource(build_id=123, content=content) 156 | 157 | assert mock.request_history[0].json() == { 158 | 'data': { 159 | 'type': 'resources', 160 | 'id': utils.sha256hash(content), 161 | 'attributes': { 162 | 'base64-content': utils.base64encode(content) 163 | } 164 | 165 | } 166 | } 167 | assert result == {'success': 'true'} 168 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from percy import errors 5 | from percy import config 6 | 7 | 8 | class TestPercyConfig(unittest.TestCase): 9 | def setUp(self): 10 | os.environ['PERCY_TOKEN'] = 'abcd1234' 11 | self.config = config.Config() 12 | 13 | def tearDown(self): 14 | del os.environ['PERCY_TOKEN'] 15 | 16 | def test_getters(self): 17 | self.assertEqual(self.config.api_url, 'https://percy.io/api/v1') 18 | self.assertEqual(self.config.default_widths, []) 19 | self.assertEqual(self.config.access_token, 'abcd1234') 20 | 21 | def test_setters(self): 22 | self.config.api_url = 'https://microsoft.com/' 23 | self.assertEqual(self.config.api_url, 'https://microsoft.com/') 24 | 25 | self.assertEqual(self.config.default_widths, []) 26 | self.config.default_widths = (1280, 375) 27 | self.assertEqual(self.config.default_widths, (1280, 375)) 28 | 29 | self.config.access_token = None 30 | self.assertRaises(errors.AuthError, lambda: self.config.access_token) 31 | 32 | self.config.access_token = 'percy123' 33 | self.assertEqual(self.config.access_token, 'percy123') 34 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import requests_mock 4 | import unittest 5 | import percy 6 | from percy import connection 7 | 8 | 9 | class TestPercyConnection(unittest.TestCase): 10 | def setUp(self): 11 | config = percy.Config(access_token='foo') 12 | environment = percy.Environment() 13 | 14 | self.percy_connection = connection.Connection(config, environment) 15 | 16 | @requests_mock.Mocker() 17 | def test_get(self, mock): 18 | mock.get('http://api.percy.io', text='{"data":"GET Percy"}') 19 | data = self.percy_connection.get('http://api.percy.io') 20 | self.assertEqual(data['data'], 'GET Percy') 21 | 22 | auth_header = mock.request_history[0].headers['Authorization'] 23 | assert auth_header == 'Token token=foo' 24 | 25 | # TODO: This test fails, because requests_mock replaces the adapter where the 26 | # retry configuration is injected. Can we find a different way to supply mock responses? 27 | # @requests_mock.Mocker() 28 | # def test_get_error_is_retried(self, mock): 29 | # mock.get('http://api.percy.io', [{'text':'{"error":"foo"}', 'status_code':503}, 30 | # {'text':'{"data":"GET Percy"}', 'status_code':200}]) 31 | # data = self.percy_connection.get('http://api.percy.io') 32 | # self.assertEqual(data['data'], 'GET Percy') 33 | 34 | @requests_mock.Mocker() 35 | def test_get_error(self, mock): 36 | mock.get('http://api.percy.io', text='{"error":"foo"}', status_code=401) 37 | self.assertRaises(requests.exceptions.HTTPError, lambda: self.percy_connection.get( 38 | 'http://api.percy.io' 39 | )) 40 | 41 | @requests_mock.Mocker() 42 | def test_post(self, mock): 43 | mock.post('http://api.percy.io', text='{"data":"POST Percy"}') 44 | data = self.percy_connection.post( 45 | 'http://api.percy.io', 46 | data='{"data": "data"}' 47 | ) 48 | self.assertEqual(data['data'], 'POST Percy') 49 | 50 | auth_header = mock.request_history[0].headers['Authorization'] 51 | assert auth_header == 'Token token=foo' 52 | 53 | @requests_mock.Mocker() 54 | def test_post_error(self, mock): 55 | mock.post('http://api.percy.io', text='{"error":"foo"}', status_code=500) 56 | self.assertRaises(requests.exceptions.HTTPError, lambda: self.percy_connection.post( 57 | 'http://api.percy.io', 58 | data='{"data": "data"}' 59 | )) 60 | -------------------------------------------------------------------------------- /tests/test_environment.py: -------------------------------------------------------------------------------- 1 | import os 2 | import percy 3 | import pytest 4 | import sys 5 | 6 | class BaseTestPercyEnvironment(object): 7 | def setup_method(self, method): 8 | self.original_env = {} 9 | self.original_env['TRAVIS_BUILD_ID'] = os.getenv('TRAVIS_BUILD_ID', None) 10 | self.original_env['TRAVIS_BUILD_NUMBER'] = os.getenv('TRAVIS_BUILD_NUMBER', None) 11 | self.original_env['TRAVIS_COMMIT'] = os.getenv('TRAVIS_COMMIT', None) 12 | self.original_env['TRAVIS_BRANCH'] = os.getenv('TRAVIS_BRANCH', None) 13 | self.original_env['TRAVIS_PULL_REQUEST'] = os.getenv('TRAVIS_PULL_REQUEST', None) 14 | self.original_env['TRAVIS_PULL_REQUEST_BRANCH'] = os.getenv('TRAVIS_PULL_REQUEST_BRANCH', None) 15 | 16 | def teardown_method(self, method): 17 | self.clear_env_vars() 18 | 19 | # Restore the original environment variables. 20 | for key, value in self.original_env.items(): 21 | if value: 22 | os.environ[key] = value 23 | 24 | def clear_env_vars(self): 25 | all_possible_env_vars = [ 26 | # Unset Percy vars. 27 | 'PERCY_COMMIT', 28 | 'PERCY_BRANCH', 29 | 'PERCY_TARGET_BRANCH', 30 | 'PERCY_TARGET_COMMIT', 31 | 'PERCY_PULL_REQUEST', 32 | 'PERCY_PARALLEL_NONCE', 33 | 'PERCY_PARALLEL_TOTAL', 34 | 35 | # Unset Travis vars. 36 | 'TRAVIS_BUILD_ID', 37 | 'TRAVIS_BUILD_NUMBER', 38 | 'TRAVIS_COMMIT', 39 | 'TRAVIS_BRANCH', 40 | 'TRAVIS_PULL_REQUEST', 41 | 'TRAVIS_PULL_REQUEST_BRANCH', 42 | 'CI_NODE_TOTAL', 43 | 44 | # Unset Jenkins vars. 45 | 'JENKINS_URL', 46 | 'BUILD_NUMBER', 47 | 'ghprbPullId', 48 | 'ghprbActualCommit', 49 | 'ghprbSourceBranch', 50 | 'GIT_COMMIT', 51 | 52 | # Unset Circle CI vars. 53 | 'CIRCLECI', 54 | 'CIRCLE_SHA1', 55 | 'CIRCLE_BRANCH', 56 | 'CIRCLE_BUILD_NUM', 57 | 'CIRCLE_WORKFLOW_WORKSPACE_ID', 58 | 'CI_PULL_REQUESTS', 59 | 'CIRCLE_NODE_TOTAL', 60 | 61 | # Unset Codeship vars. 62 | 'CI_NAME', 63 | 'CI_BRANCH', 64 | 'CI_PULL_REQUEST', 65 | 'CI_COMMIT_ID', 66 | 'CI_BUILD_NUMBER', 67 | 'CI_BUILD_ID', 68 | 'CI_NODE_TOTAL', 69 | 70 | # Unset Drone vars. 71 | 'CI', 72 | 'DRONE', 73 | 'DRONE_COMMIT', 74 | 'DRONE_BRANCH', 75 | 'CI_PULL_REQUEST', 76 | 77 | # Unset Semaphore CI vars 78 | 'CI', 79 | 'SEMAPHORE', 80 | 'REVISION', 81 | 'BRANCH_NAME', 82 | 'SEMAPHORE_BRANCH_ID', 83 | 'SEMAPHORE_BUILD_NUMBER', 84 | 'SEMAPHORE_CURRENT_THREAD', 85 | 'SEMAPHORE_THREAD_COUNT', 86 | 'PULL_REQUEST_NUMBER', 87 | 88 | # Unset Buildkite vars 89 | 'BUILDKITE', 90 | 'BUILDKITE_COMMIT', 91 | 'BUILDKITE_BRANCH', 92 | 'BUILDKITE_PULL_REQUEST', 93 | 'BUILDKITE_BUILD_ID', 94 | 'BUILDKITE_PARALLEL_JOB_COUNT', 95 | 96 | 97 | # Unset GitLab vars 98 | 'GITLAB_CI', 99 | 'CI_COMMIT_SHA', 100 | 'CI_COMMIT_REF_NAME', 101 | 'CI_JOB_ID', 102 | 'CI_JOB_STAGE', 103 | ] 104 | for env_var in all_possible_env_vars: 105 | if os.getenv(env_var): 106 | del os.environ[env_var] 107 | 108 | 109 | class TestNoEnvironment(BaseTestPercyEnvironment): 110 | def setup_method(self, method): 111 | super(TestNoEnvironment, self).setup_method(self) 112 | self.environment = percy.Environment() 113 | 114 | def test_current_ci(self): 115 | assert self.environment.current_ci == None 116 | 117 | def test_target_branch(self): 118 | assert self.environment.target_branch == None 119 | # Can be overridden with PERCY_TARGET_BRANCH. 120 | os.environ['PERCY_TARGET_BRANCH'] = 'staging' 121 | assert self.environment.target_branch == 'staging' 122 | 123 | def test_target_commit_sha(self): 124 | assert self.environment.target_commit_sha == None 125 | # Can be overridden with PERCY_TARGET_COMMIT. 126 | os.environ['PERCY_TARGET_COMMIT'] = 'test-target-commit' 127 | assert self.environment.target_commit_sha == 'test-target-commit' 128 | 129 | def test_pull_request_number(self): 130 | assert self.environment.pull_request_number == None 131 | # Can be overridden with PERCY_PULL_REQUEST. 132 | os.environ['PERCY_PULL_REQUEST'] = '1234' 133 | assert self.environment.pull_request_number == '1234' 134 | 135 | def test_commit_live(self, monkeypatch): 136 | def isstr(s): 137 | if sys.version_info >= (3,0): 138 | return isinstance(s, str) 139 | else: 140 | return isinstance(s, basestring) 141 | 142 | # Call commit using the real _raw_commit_data, which calls git underneath, so allow full 143 | # commit object with attributes containing any string. (Real data changes with each commit) 144 | commit_data = self.environment.commit_data 145 | assert isstr(commit_data['branch']) 146 | assert isstr(commit_data['sha']) 147 | assert isstr(commit_data['message']) 148 | assert isstr(commit_data['committed_at']) 149 | assert isstr(commit_data['author_name']) 150 | assert isstr(commit_data['author_email']) 151 | assert isstr(commit_data['committer_name']) 152 | assert isstr(commit_data['committer_email']) 153 | 154 | @pytest.fixture() 155 | def test_commit_with_failed_raw_commit(self, monkeypatch): 156 | # Call commit using faking a _raw_commit_data failure. 157 | # If git command fails, only data from environment variables. 158 | os.environ['PERCY_COMMIT'] = 'testcommitsha' 159 | os.environ['PERCY_BRANCH'] = 'testbranch' 160 | monkeypatch.setattr(self.environment, '_raw_commit_output', lambda x: '') 161 | assert self.environment.commit_data == { 162 | 'branch': 'testbranch', 163 | 'author_email': None, 164 | 'author_name': None, 165 | 'committer_email': None, 166 | 'committer_name': None, 167 | 'sha': 'testcommitsha', 168 | } 169 | self.clear_env_vars() 170 | 171 | @pytest.fixture() 172 | def test_commit_with_mocked_raw_commit(self, monkeypatch): 173 | # Call commit with _raw_commit_data returning mock data, so we can confirm it 174 | # gets formatted correctly 175 | os.environ['PERCY_BRANCH'] = 'the-coolest-branch' 176 | def fake_raw_commit(commit_sha): 177 | return """COMMIT_SHA:2fcd1b107aa25e62a06de7782d0c17544c669d139 178 | AUTHOR_NAME:Tim Haines 179 | AUTHOR_EMAIL:timhaines@example.com 180 | COMMITTER_NAME:Other Tim Haines 181 | COMMITTER_EMAIL:othertimhaines@example.com 182 | COMMITTED_DATE:2018-03-10 14:41:02 -0800 183 | COMMIT_MESSAGE:This is a great commit""" 184 | 185 | monkeypatch.setattr(self.environment, '_raw_commit_output', fake_raw_commit) 186 | assert self.environment.commit_data == { 187 | 'branch': 'the-coolest-branch', 188 | 'sha': '2fcd1b107aa25e62a06de7782d0c17544c669d139', 189 | 'committed_at': '2018-03-10 14:41:02 -0800', 190 | 'message': 'This is a great commit', 191 | 'author_name': 'Tim Haines', 192 | 'author_email': 'timhaines@example.com', 193 | 'committer_name': 'Other Tim Haines', 194 | 'committer_email': 'othertimhaines@example.com' 195 | } 196 | 197 | 198 | @pytest.fixture() 199 | def test_branch(self, monkeypatch): 200 | # Default calls _raw_branch_output and call git underneath, so allow any non-empty string. 201 | assert len(self.environment.branch) > 0 202 | 203 | # If git command fails, falls back to None and prints warning. 204 | monkeypatch.setattr(self.environment, '_raw_branch_output', lambda: '') 205 | assert self.environment.branch == None 206 | 207 | # Can be overridden with PERCY_BRANCH. 208 | os.environ['PERCY_BRANCH'] = 'foo' 209 | assert self.environment.branch == 'foo' 210 | 211 | def test_commit_sha(self): 212 | assert not self.environment.commit_sha 213 | # Can be overridden with PERCY_COMMIT. 214 | os.environ['PERCY_COMMIT'] = 'commit-sha' 215 | assert self.environment.commit_sha == 'commit-sha' 216 | 217 | def test_parallel_nonce(self): 218 | os.environ['PERCY_PARALLEL_NONCE'] = 'foo' 219 | assert self.environment.parallel_nonce == 'foo' 220 | 221 | def test_parallel_total(self): 222 | os.environ['PERCY_PARALLEL_TOTAL'] = '2' 223 | assert self.environment.parallel_total_shards == 2 224 | 225 | 226 | class TestTravisEnvironment(BaseTestPercyEnvironment): 227 | def setup_method(self, method): 228 | super(TestTravisEnvironment, self).setup_method(self) 229 | os.environ['TRAVIS_BUILD_ID'] = '1234' 230 | os.environ['TRAVIS_BUILD_NUMBER'] = 'travis-build-number' 231 | os.environ['TRAVIS_PULL_REQUEST'] = 'false' 232 | os.environ['TRAVIS_PULL_REQUEST_BRANCH'] = 'false' 233 | os.environ['TRAVIS_COMMIT'] = 'travis-commit-sha' 234 | os.environ['TRAVIS_BRANCH'] = 'travis-branch' 235 | os.environ['CI_NODE_TOTAL'] = '3' 236 | self.environment = percy.Environment() 237 | 238 | def test_current_ci(self): 239 | assert self.environment.current_ci == 'travis' 240 | 241 | def test_pull_request_number(self): 242 | assert self.environment.pull_request_number == None 243 | 244 | os.environ['TRAVIS_PULL_REQUEST'] = '256' 245 | assert self.environment.pull_request_number == '256' 246 | 247 | # PERCY env vars should take precendence over CI. Checked here once, assume other envs work. 248 | os.environ['PERCY_PULL_REQUEST'] = '1234' 249 | assert self.environment.pull_request_number == '1234' 250 | 251 | def test_branch(self): 252 | assert self.environment.branch == 'travis-branch' 253 | 254 | # Triggers special path if PR build in Travis. 255 | os.environ['TRAVIS_PULL_REQUEST'] = '256' 256 | os.environ['TRAVIS_PULL_REQUEST_BRANCH'] = 'travis-pr-branch' 257 | assert self.environment.branch == 'travis-pr-branch' 258 | 259 | os.environ['PERCY_BRANCH'] = 'foo' 260 | assert self.environment.branch == 'foo' 261 | 262 | def test_commit_sha(self): 263 | assert self.environment.commit_sha == 'travis-commit-sha' 264 | 265 | os.environ['PERCY_COMMIT'] = 'commit-sha' 266 | assert self.environment.commit_sha == 'commit-sha' 267 | 268 | def test_parallel_nonce(self): 269 | assert self.environment.parallel_nonce == 'travis-build-number' 270 | 271 | os.environ['PERCY_PARALLEL_NONCE'] = 'nonce' 272 | assert self.environment.parallel_nonce == 'nonce' 273 | 274 | def test_parallel_total(self): 275 | assert self.environment.parallel_total_shards == 3 276 | 277 | os.environ['CI_NODE_TOTAL'] = '' 278 | assert self.environment.parallel_total_shards == None 279 | 280 | os.environ['PERCY_PARALLEL_TOTAL'] = '1' 281 | assert self.environment.parallel_total_shards == 1 282 | 283 | 284 | class TestJenkinsEnvironment(BaseTestPercyEnvironment): 285 | def setup_method(self, method): 286 | super(TestJenkinsEnvironment, self).setup_method(self) 287 | os.environ['JENKINS_URL'] = 'http://localhost:8080/' 288 | os.environ['BUILD_NUMBER'] = 'jenkins-build-number' 289 | os.environ['ghprbSourceBranch'] = 'jenkins-source-branch' 290 | os.environ['ghprbActualCommit'] = 'jenkins-commit-sha' 291 | os.environ['GIT_COMMIT'] = 'jenkins-commit-sha-from-git-plugin' 292 | self.environment = percy.Environment() 293 | 294 | def test_current_ci(self): 295 | assert self.environment.current_ci == 'jenkins' 296 | 297 | def test_pull_request_number(self): 298 | assert self.environment.pull_request_number == None 299 | 300 | os.environ['ghprbPullId'] = '256' 301 | assert self.environment.pull_request_number == '256' 302 | 303 | def test_branch(self): 304 | assert self.environment.branch == 'jenkins-source-branch' 305 | 306 | def test_commit_sha(self): 307 | assert self.environment.commit_sha == 'jenkins-commit-sha' 308 | del os.environ['ghprbActualCommit'] 309 | assert self.environment.commit_sha == 'jenkins-commit-sha-from-git-plugin' 310 | 311 | def test_parallel_nonce(self): 312 | assert self.environment.parallel_nonce == 'jenkins-build-number' 313 | 314 | def test_parallel_total(self): 315 | assert self.environment.parallel_total_shards is None 316 | 317 | 318 | class TestCircleEnvironment(BaseTestPercyEnvironment): 319 | def setup_method(self, method): 320 | super(TestCircleEnvironment, self).setup_method(self) 321 | os.environ['CIRCLECI'] = 'true' 322 | os.environ['CIRCLE_BRANCH'] = 'circle-branch' 323 | os.environ['CIRCLE_SHA1'] = 'circle-commit-sha' 324 | os.environ['CIRCLE_BUILD_NUM'] = 'circle-build-number' 325 | os.environ['CIRCLE_WORKFLOW_WORKSPACE_ID'] = 'circle-workflow-workspace-id' 326 | os.environ['CIRCLE_NODE_TOTAL'] = '3' 327 | os.environ['CI_PULL_REQUESTS'] = 'https://github.com/owner/repo-name/pull/123' 328 | self.environment = percy.Environment() 329 | 330 | def test_current_ci(self): 331 | assert self.environment.current_ci == 'circle' 332 | 333 | def test_branch(self): 334 | assert self.environment.branch == 'circle-branch' 335 | 336 | def test_commit_sha(self): 337 | assert self.environment.commit_sha == 'circle-commit-sha' 338 | 339 | def test_parallel_nonce(self): 340 | assert self.environment.parallel_nonce == 'circle-workflow-workspace-id' 341 | del os.environ['CIRCLE_WORKFLOW_WORKSPACE_ID'] 342 | assert self.environment.parallel_nonce == 'circle-build-number' 343 | 344 | def test_parallel_total(self): 345 | assert self.environment.parallel_total_shards == 3 346 | 347 | 348 | class TestCodeshipEnvironment(BaseTestPercyEnvironment): 349 | def setup_method(self, method): 350 | super(TestCodeshipEnvironment, self).setup_method(self) 351 | os.environ['CI_NAME'] = 'codeship' 352 | os.environ['CI_BRANCH'] = 'codeship-branch' 353 | os.environ['CI_BUILD_NUMBER'] = 'codeship-build-number' 354 | os.environ['CI_BUILD_ID'] = 'codeship-build-id' 355 | os.environ['CI_PULL_REQUEST'] = 'false' # This is always false on Codeship, unfortunately. 356 | os.environ['CI_COMMIT_ID'] = 'codeship-commit-sha' 357 | os.environ['CI_NODE_TOTAL'] = '3' 358 | self.environment = percy.Environment() 359 | 360 | def test_current_ci(self): 361 | assert self.environment.current_ci == 'codeship' 362 | 363 | def test_branch(self): 364 | assert self.environment.branch == 'codeship-branch' 365 | 366 | def test_commit_sha(self): 367 | assert self.environment.commit_sha == 'codeship-commit-sha' 368 | 369 | def test_parallel_nonce(self): 370 | assert self.environment.parallel_nonce == 'codeship-build-number' 371 | del os.environ['CI_BUILD_NUMBER'] 372 | assert self.environment.parallel_nonce == 'codeship-build-id' 373 | 374 | def test_parallel_total(self): 375 | assert self.environment.parallel_total_shards == 3 376 | 377 | 378 | class TestDroneEnvironment(BaseTestPercyEnvironment): 379 | def setup_method(self, method): 380 | super(TestDroneEnvironment, self).setup_method(self) 381 | os.environ['DRONE'] = 'true' 382 | os.environ['DRONE_COMMIT'] = 'drone-commit-sha' 383 | os.environ['DRONE_BRANCH'] = 'drone-branch' 384 | os.environ['CI_PULL_REQUEST'] = '123' 385 | self.environment = percy.Environment() 386 | 387 | def test_current_ci(self): 388 | assert self.environment.current_ci == 'drone' 389 | 390 | def test_branch(self): 391 | assert self.environment.branch == 'drone-branch' 392 | 393 | def test_commit_sha(self): 394 | assert self.environment.commit_sha == 'drone-commit-sha' 395 | 396 | def test_parallel_nonce(self): 397 | assert self.environment.parallel_nonce is None 398 | 399 | def test_parallel_total(self): 400 | assert self.environment.parallel_total_shards is None 401 | 402 | 403 | class TestSemaphoreEnvironment(BaseTestPercyEnvironment): 404 | def setup_method(self, method): 405 | super(TestSemaphoreEnvironment, self).setup_method(self) 406 | os.environ['SEMAPHORE'] = 'true' 407 | os.environ['BRANCH_NAME'] = 'semaphore-branch' 408 | os.environ['REVISION'] = 'semaphore-commit-sha' 409 | os.environ['SEMAPHORE_BRANCH_ID'] = 'semaphore-branch-id' 410 | os.environ['SEMAPHORE_BUILD_NUMBER'] = 'semaphore-build-number' 411 | os.environ['SEMAPHORE_THREAD_COUNT'] = '2' 412 | os.environ['PULL_REQUEST_NUMBER'] = '123' 413 | self.environment = percy.Environment() 414 | 415 | def test_current_ci(self): 416 | assert self.environment.current_ci == 'semaphore' 417 | 418 | def test_branch(self): 419 | assert self.environment.branch == 'semaphore-branch' 420 | 421 | def test_commit_sha(self): 422 | assert self.environment.commit_sha == 'semaphore-commit-sha' 423 | 424 | def test_parallel_nonce(self): 425 | expected_nonce = 'semaphore-branch-id/semaphore-build-number' 426 | assert self.environment.parallel_nonce == expected_nonce 427 | 428 | def test_parallel_total(self): 429 | assert self.environment.parallel_total_shards == 2 430 | 431 | 432 | class TestBuildkiteEnvironment(BaseTestPercyEnvironment): 433 | def setup_method(self, method): 434 | super(TestBuildkiteEnvironment, self).setup_method(self) 435 | os.environ['BUILDKITE'] = 'true' 436 | os.environ['BUILDKITE_COMMIT'] = 'buildkite-commit-sha' 437 | os.environ['BUILDKITE_BRANCH'] = 'buildkite-branch' 438 | os.environ['BUILDKITE_PULL_REQUEST'] = 'false' 439 | os.environ['BUILDKITE_BUILD_ID'] = 'buildkite-build-id' 440 | os.environ['BUILDKITE_PARALLEL_JOB_COUNT'] = '2' 441 | self.environment = percy.Environment() 442 | 443 | def test_current_ci(self): 444 | assert self.environment.current_ci == 'buildkite' 445 | 446 | def test_branch(self): 447 | assert self.environment.branch == 'buildkite-branch' 448 | 449 | def test_commit_sha(self): 450 | assert self.environment.commit_sha == 'buildkite-commit-sha' 451 | os.environ['BUILDKITE_COMMIT'] = 'HEAD' 452 | assert self.environment.commit_sha is None 453 | 454 | def test_parallel_nonce(self): 455 | assert self.environment.parallel_nonce == 'buildkite-build-id' 456 | 457 | def test_parallel_total(self): 458 | assert self.environment.parallel_total_shards == 2 459 | 460 | 461 | class TestGitlabEnvironment(BaseTestPercyEnvironment): 462 | def setup_method(self, method): 463 | super(TestGitlabEnvironment, self).setup_method(self) 464 | os.environ['GITLAB_CI'] = 'true' 465 | os.environ['CI_COMMIT_SHA'] = 'gitlab-commit-sha' 466 | os.environ['CI_COMMIT_REF_NAME'] = 'gitlab-branch' 467 | os.environ['CI_JOB_ID'] = 'gitlab-job-id' 468 | os.environ['CI_JOB_STAGE'] = 'test' 469 | self.environment = percy.Environment() 470 | 471 | def test_current_ci(self): 472 | assert self.environment.current_ci == 'gitlab' 473 | 474 | def test_branch(self): 475 | assert self.environment.branch == 'gitlab-branch' 476 | 477 | def test_commit_sha(self): 478 | assert self.environment.commit_sha == 'gitlab-commit-sha' 479 | 480 | def test_parallel_nonce(self): 481 | assert self.environment.parallel_nonce == 'gitlab-branch/gitlab-job-id' 482 | -------------------------------------------------------------------------------- /tests/test_resource_loader.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | 4 | from percy.resource_loader import ResourceLoader 5 | 6 | TEST_FILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testdata') 7 | 8 | class FakeWebdriver(object): 9 | page_source = 'foo' 10 | current_url = '/' 11 | 12 | 13 | class FakeWebdriverAbsoluteUrl(object): 14 | page_source = 'foo' 15 | current_url = 'http://testserver/' 16 | 17 | 18 | class TestPercyResourceLoader(unittest.TestCase): 19 | def test_blank_loader(self): 20 | resource_loader = ResourceLoader() 21 | assert resource_loader.build_resources == [] 22 | resource_loader = ResourceLoader(webdriver=FakeWebdriver()) 23 | assert resource_loader.snapshot_resources[0].resource_url == '/' 24 | 25 | def test_build_resources(self): 26 | root_dir = os.path.join(TEST_FILES_DIR, 'static') 27 | resource_loader = ResourceLoader(root_dir=root_dir, base_url='/assets/') 28 | resources = resource_loader.build_resources 29 | resource_urls = sorted([r.resource_url for r in resources]) 30 | assert resource_urls == [ 31 | '/assets/app.js', 32 | '/assets/images/jellybeans.png', 33 | '/assets/images/logo.png', 34 | '/assets/styles.css', 35 | ] 36 | 37 | def test_absolute_snapshot_resources(self): 38 | resource_loader = ResourceLoader(webdriver=FakeWebdriverAbsoluteUrl()) 39 | assert resource_loader.snapshot_resources[0].resource_url == '/' 40 | -------------------------------------------------------------------------------- /tests/test_runner.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | import unittest 5 | 6 | import percy 7 | import pytest 8 | import requests_mock 9 | from percy import errors 10 | from percy import utils 11 | 12 | FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixtures') 13 | TEST_FILES_DIR = os.path.join(os.path.dirname(__file__), 'testdata') 14 | 15 | 16 | class FakeWebdriver(object): 17 | page_source = 'page source' 18 | current_url = '/' 19 | 20 | 21 | SIMPLE_BUILD_FIXTURE = { 22 | 'data': { 23 | 'id': '123', 24 | 'type': 'builds', 25 | 'relationships': { 26 | 'self': '/api/v1/builds/123', 27 | 'missing-resources': {}, 28 | }, 29 | }, 30 | } 31 | 32 | SIMPLE_SNAPSHOT_FIXTURE = { 33 | 'data': { 34 | 'id': '256', 35 | 'type': 'snapshots', 36 | 'relationships': { 37 | 'self': '/api/v1/snapshots/256', 38 | 'missing-resources': {}, 39 | }, 40 | }, 41 | } 42 | 43 | class TestRunner(unittest.TestCase): 44 | def test_init(self): 45 | runner = percy.Runner() 46 | assert runner.client.config.default_widths == [] 47 | runner = percy.Runner(config=percy.Config(default_widths=[1280, 375])) 48 | assert runner.client.config.default_widths == [1280, 375] 49 | 50 | @requests_mock.Mocker() 51 | def test_safe_initialize_when_disabled(self, mock): 52 | runner = percy.Runner() 53 | assert runner._is_enabled == False 54 | runner.initialize_build() 55 | 56 | @requests_mock.Mocker() 57 | def test_initialize_build(self, mock): 58 | root_dir = os.path.join(TEST_FILES_DIR, 'static') 59 | loader = percy.ResourceLoader(root_dir=root_dir, base_url='/assets/') 60 | config = percy.Config(access_token='foo') 61 | runner = percy.Runner(config=config, loader=loader) 62 | 63 | response_text = json.dumps(SIMPLE_BUILD_FIXTURE) 64 | mock.post('https://percy.io/api/v1/builds/', text=response_text) 65 | runner.initialize_build() 66 | 67 | # Whitebox check that the current build data is set correctly. 68 | assert runner._current_build == SIMPLE_BUILD_FIXTURE 69 | assert runner.build_id == SIMPLE_BUILD_FIXTURE['data']['id'] 70 | 71 | @requests_mock.Mocker() 72 | def test_initialize_build_sends_missing_resources(self, mock): 73 | root_dir = os.path.join(TEST_FILES_DIR, 'static') 74 | loader = percy.ResourceLoader(root_dir=root_dir, base_url='/assets/') 75 | config = percy.Config(access_token='foo') 76 | runner = percy.Runner(config=config, loader=loader) 77 | 78 | build_fixture = { 79 | 'data': { 80 | 'id': '123', 81 | 'type': 'builds', 82 | 'relationships': { 83 | 'self': "/api/v1/snapshots/123", 84 | 'missing-resources': { 85 | 'data': [ 86 | { 87 | 'type': 'resources', 88 | 'id': loader.build_resources[0].sha, 89 | }, 90 | ], 91 | }, 92 | }, 93 | }, 94 | } 95 | mock.post('https://percy.io/api/v1/builds/', text=json.dumps(build_fixture)) 96 | mock.post('https://percy.io/api/v1/builds/123/resources/', text='{"success": true}') 97 | runner.initialize_build() 98 | 99 | # Make sure the missing resources were uploaded. The mock above will not fail if not called. 100 | with open(loader.build_resources[0].local_path, 'r') as f: 101 | content = f.read() 102 | assert len(content) > 0 103 | assert mock.request_history[1].json() == { 104 | 'data': { 105 | 'type': 'resources', 106 | 'id': loader.build_resources[0].sha, 107 | 'attributes': { 108 | 'base64-content': utils.base64encode(content) 109 | }, 110 | }, 111 | } 112 | 113 | def test_safe_snapshot_when_disabled(self): 114 | runner = percy.Runner() 115 | assert runner._is_enabled == False 116 | runner.snapshot() 117 | 118 | @requests_mock.Mocker() 119 | def test_snapshot(self, mock): 120 | root_dir = os.path.join(TEST_FILES_DIR, 'static') 121 | webdriver = FakeWebdriver() 122 | loader = percy.ResourceLoader(root_dir=root_dir, base_url='/assets/', webdriver=webdriver) 123 | config = percy.Config(access_token='foo') 124 | runner = percy.Runner(config=config, loader=loader) 125 | 126 | response_text = json.dumps(SIMPLE_BUILD_FIXTURE) 127 | mock.post('https://percy.io/api/v1/builds/', text=response_text) 128 | runner.initialize_build() 129 | 130 | # Plain snapshot without a missing resource. 131 | response_text = json.dumps(SIMPLE_SNAPSHOT_FIXTURE) 132 | mock.post('https://percy.io/api/v1/builds/123/snapshots/', text=response_text) 133 | mock.post('https://percy.io/api/v1/snapshots/256/finalize', text='{"success": true}') 134 | runner.snapshot() 135 | 136 | # Snapshot with a missing resource. 137 | response_text = json.dumps({ 138 | 'data': { 139 | 'id': '256', 140 | 'type': 'snapshots', 141 | 'relationships': { 142 | 'self': '/api/v1/snapshots/256', 143 | 'missing-resources': { 144 | 'data': [ 145 | { 146 | 'type': 'resources', 147 | 'id': loader.snapshot_resources[0].sha, 148 | }, 149 | ], 150 | }, 151 | }, 152 | }, 153 | }) 154 | mock.post('https://percy.io/api/v1/builds/123/snapshots/', text=response_text) 155 | mock.post('https://percy.io/api/v1/builds/123/resources/', text='{"success": true}') 156 | mock.post('https://percy.io/api/v1/snapshots/256/finalize', text='{"success": true}') 157 | runner.snapshot(name='foo', enable_javascript=True, widths=[1280]) 158 | 159 | # Assert that kwargs are passed through correctly to create_snapshot. 160 | assert mock.request_history[3].json()['data']['attributes'] == { 161 | 'enable-javascript': True, 162 | 'name': 'foo', 163 | 'widths': [1280], 164 | } 165 | 166 | # Assert that the snapshot resource was uploaded correctly. 167 | assert mock.request_history[4].json() == { 168 | 'data': { 169 | 'type': 'resources', 170 | 'id': loader.snapshot_resources[0].sha, 171 | 'attributes': { 172 | 'base64-content': utils.base64encode(FakeWebdriver.page_source) 173 | }, 174 | }, 175 | } 176 | 177 | @requests_mock.Mocker() 178 | def test_finalize_build(self, mock): 179 | config = percy.Config(access_token='foo') 180 | runner = percy.Runner(config=config) 181 | 182 | self.assertRaises(errors.UninitializedBuildError, lambda: runner.finalize_build()) 183 | 184 | response_text = json.dumps(SIMPLE_BUILD_FIXTURE) 185 | mock.post('https://percy.io/api/v1/builds/', text=response_text) 186 | runner.initialize_build() 187 | 188 | mock.post('https://percy.io/api/v1/builds/123/finalize', text='{"success": true}') 189 | runner.finalize_build() 190 | assert mock.request_history[1].json() == {} 191 | 192 | # Whitebox check that the current build data is reset. 193 | assert runner._current_build == None 194 | 195 | def test_safe_finalize_when_disabled(self): 196 | runner = percy.Runner() 197 | assert runner._is_enabled == False 198 | runner.finalize_build() 199 | -------------------------------------------------------------------------------- /tests/test_user_agent.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import re 3 | import percy 4 | from percy.user_agent import UserAgent 5 | 6 | class TestPercyUserAgent(unittest.TestCase): 7 | def setUp(self): 8 | self.config = percy.Config(access_token='foo') 9 | self.environment = percy.Environment() 10 | 11 | def test_string(self): 12 | self.assertTrue( 13 | re.match( 14 | r'^Percy/v1 python-percy-client/[.\d]+ \(python/[.\d]+(; travis)?\)$', 15 | str(UserAgent(self.config, self.environment)) 16 | ) 17 | ) 18 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import sys 5 | import unittest 6 | 7 | from percy import utils 8 | 9 | 10 | class TestPercyUtils(unittest.TestCase): 11 | def test_sha256hash(self): 12 | self.assertEqual( 13 | utils.sha256hash('foo'), 14 | '2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae', 15 | ) 16 | self.assertEqual( 17 | utils.sha256hash(u'I ♡ Python'), 18 | '5ebc381ed49ffbbff4efabd2c5e2ac5f116984cd72fffa4272cc528176bb09f6', 19 | ) 20 | 21 | if sys.version_info >= (3,0): 22 | binary_content = b'\x01\x02\x99' 23 | else: 24 | binary_content = '\x01\x02\x99' 25 | 26 | self.assertEqual( 27 | utils.sha256hash(binary_content), 28 | '17b9440d8fa6bb5eb0e06a68dd3882c01c5a757f889118f9fbdf50e6e7025581', 29 | ) 30 | 31 | def test_base64encode(self): 32 | self.assertEqual(utils.base64encode('foo'), 'Zm9v') 33 | self.assertEqual(utils.base64encode(u'I ♡ Python'), 'SSDimaEgUHl0aG9u') 34 | 35 | if sys.version_info >= (3,0): 36 | binary_content = b'\x01\x02\x99' 37 | else: 38 | binary_content = '\x01\x02\x99' 39 | 40 | self.assertEqual(utils.base64encode(binary_content), 'AQKZ') 41 | -------------------------------------------------------------------------------- /tests/testdata/static/app.js: -------------------------------------------------------------------------------- 1 | function() { 2 | 3 | } -------------------------------------------------------------------------------- /tests/testdata/static/images/jellybeans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy/python-percy-client/c6add77bb5a45d0d57ea579bea577086db114cab/tests/testdata/static/images/jellybeans.png -------------------------------------------------------------------------------- /tests/testdata/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/percy/python-percy-client/c6add77bb5a45d0d57ea579bea577086db114cab/tests/testdata/static/images/logo.png -------------------------------------------------------------------------------- /tests/testdata/static/styles.css: -------------------------------------------------------------------------------- 1 | .body {} -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py33, py34, py35 3 | 4 | [testenv] 5 | setenv = 6 | PYTHONPATH = {toxinidir}:{toxinidir}/percy 7 | deps = 8 | -r{toxinidir}/requirements_dev.txt 9 | commands = 10 | py.test --basetemp={envtmpdir} 11 | 12 | 13 | ; If you want to make tox run the tests with the same versions, create a 14 | ; requirements.txt with the pinned versions and uncomment the following lines: 15 | ; deps = 16 | ; -r{toxinidir}/requirements.txt 17 | --------------------------------------------------------------------------------