From 54e78b62748b50a7f831d601aa4b4e97417e67fb Mon Sep 17 00:00:00 2001 From: Benjamin Forehand Jr Date: Tue, 24 Apr 2018 11:00:19 -0400 Subject: [PATCH 1/2] This adds some user integration tests to aide the SoftVision team a bit. Right now I have 3 tests. test_upload: This will create a file and make sure it uploads by verifying a file uploads and is assigned a URL. test_download: This will create a file, upload it and then download it making sure it is the same filename that was uploaded. We can expand this later to maybe check the sizes and such. test_progress: This will create a file and make sure the progress bar shows up after it begins uploading. These are python tests and use Pipenv to manage dependencies as well as tox as the virtualenv manager, and finally pytest as the test runner. --- .dockerignore | 3 +- .gitignore | 4 +- circle.yml | 45 ++++++++++- docker-compose.yml | 13 ++++ package.json | 3 + test/integration/Pipfile | 17 +++++ test/integration/README.md | 89 ++++++++++++++++++++++ test/integration/conftest.py | 76 ++++++++++++++++++ test/integration/pages/__init__.py | 0 test/integration/pages/desktop/__init__.py | 0 test/integration/pages/desktop/base.py | 18 +++++ test/integration/pages/desktop/download.py | 16 ++++ test/integration/pages/desktop/home.py | 26 +++++++ test/integration/pages/desktop/progress.py | 24 ++++++ test/integration/pages/desktop/share.py | 21 +++++ test/integration/pipenv.txt | 1 + test/integration/scripts/start-docker.sh | 4 + test/integration/test_download.py | 6 ++ test/integration/test_progress.py | 6 ++ test/integration/test_upload.py | 6 ++ test/integration/tox.ini | 24 ++++++ 21 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 test/integration/Pipfile create mode 100644 test/integration/README.md create mode 100644 test/integration/conftest.py create mode 100644 test/integration/pages/__init__.py create mode 100644 test/integration/pages/desktop/__init__.py create mode 100644 test/integration/pages/desktop/base.py create mode 100644 test/integration/pages/desktop/download.py create mode 100644 test/integration/pages/desktop/home.py create mode 100644 test/integration/pages/desktop/progress.py create mode 100644 test/integration/pages/desktop/share.py create mode 100644 test/integration/pipenv.txt create mode 100755 test/integration/scripts/start-docker.sh create mode 100644 test/integration/test_download.py create mode 100644 test/integration/test_progress.py create mode 100644 test/integration/test_upload.py create mode 100755 test/integration/tox.ini diff --git a/.dockerignore b/.dockerignore index 9c2801b3..0882d9e6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ node_modules .git +.tox .DS_Store firefox assets @@ -7,4 +8,4 @@ docs public test coverage -.nyc_output \ No newline at end of file +.nyc_output diff --git a/.gitignore b/.gitignore index 1e6cbed3..1c5a2fc6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ coverage dist .idea .DS_Store -.nyc_output \ No newline at end of file +.nyc_output +.tox +.pytest_cache diff --git a/circle.yml b/circle.yml index 071b31ae..df2e5218 100644 --- a/circle.yml +++ b/circle.yml @@ -31,9 +31,49 @@ jobs: - node_modules - run: npm run check - run: npm run lint - - run: npm test + - run: npm run test - store_artifacts: path: coverage + integration_tests: + working_directory: ~/send + machine: true + steps: + - checkout + - restore_cache: + keys: + - uitest-cache-{{ checksum "test/integration/Pipfile" }} + - uitest-cache-{{ checksum "test/integration/pipenv.txt" }} + - run: + name: Install Docker Compose + command: | + set -x + pip install docker-compose>=1.18 + docker-compose --version + - run: + name: Install Tox + command: | + set -x + pip install tox + - run: + name: Start docker container + command: docker-compose up -d + - run: + name: Run User Integration Tests + command: | + npm run start:integration-docker + npm run test-integration-docker + environment: + MOZ_HEADLESS: 1 + - store_artifacts: + path: send-test.html + - save_cache: + key: uitest-cache-{{ checksum "test/integration/Pipfile" }} + paths: + - test/integration/.tox + - save_cache: + key: uitest-cache-{{ checksum "test/integration/pipenv.txt" }} + paths: + - test/integration/.tox deploy_dev: machine: true steps: @@ -58,6 +98,7 @@ workflows: filters: branches: ignore: master + - integration_tests build_and_deploy_dev: jobs: - build: @@ -96,4 +137,4 @@ workflows: branches: ignore: /.*/ tags: - only: /^v.*/ \ No newline at end of file + only: /^v.*/ diff --git a/docker-compose.yml b/docker-compose.yml index f72bf161..14559257 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,3 +10,16 @@ services: - REDIS_HOST=redis redis: image: redis:alpine + ports: + - "6379:6379" + selenium-firefox: + image: b4handjr/selenium-firefox + volumes: + - .:/send + working_dir: /send + expose: + - "4444" + ports: + - "5900" + - "4444:4444" + shm_size: 2g diff --git a/package.json b/package.json index 6a77716c..5c0dea5a 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,9 @@ "test": "npm-run-all test:*", "test:backend": "nyc mocha --reporter=min test/backend", "test:frontend": "cross-env NODE_ENV=development node test/frontend/runner.js && nyc report --reporter=html", + "test-integration-local": "tox -c test/integration/tox.ini", + "test-integration-docker": "docker-compose exec -T --user root selenium-firefox tox -c test/integration/tox.ini", + "start:integration-docker": "docker-compose exec -T --user root selenium-firefox ./test/integration/scripts/start-docker.sh &", "start": "npm run clean && cross-env NODE_ENV=development webpack-dev-server", "prod": "node server/prod.js" }, diff --git a/test/integration/Pipfile b/test/integration/Pipfile new file mode 100644 index 00000000..ff8cb7a6 --- /dev/null +++ b/test/integration/Pipfile @@ -0,0 +1,17 @@ +[[source]] + +url = "https://pypi.python.org/simple" +verify_ssl = true +name = "pypi" + + +[packages] + +selenium = "==3.11.0" +flake8 = "==3.5.0" +flake8-isort = "==2.5" +PyPOM = "==1.3.0" +pytest = "==3.5.0" +pytest-html = "==1.16.1" +pytest-selenium = "==1.12.0" +pytest-xdist = "==1.22.2" diff --git a/test/integration/README.md b/test/integration/README.md new file mode 100644 index 00000000..ea20885c --- /dev/null +++ b/test/integration/README.md @@ -0,0 +1,89 @@ +# Integration Tests for [Firefox Send](https://send.firefox.com/). +## How to run the tests locally +### Clone the repository + +If you have cloned this project already then you can skip this, otherwise you'll +need to clone this repo using Git. If you do not know how to clone a GitHub +repository, check out this [help page][git-clone] from GitHub. + +If you think you would like to contribute to the tests by writing or maintaining +them in the future, it would be a good idea to create a fork of this repository +first, and then clone that. GitHub also has great instructions for +[forking a repository][git-fork]. + +### App Setup + +Please view the README at the root directory of the project. + +### Run the tests + +Included in the docker-compose file is an image containing Firefox Nightly. +[tox][Tox] is our test environment manager and [pytest][pytest] is the test runner. + +To run the tests, execute the command below: +1. Make sure all of the images are running: +```sh +docker-compose ps +``` +If not start them detached: +```sh +docker-compose up -d +``` +2. Start the tests within the docker container +```sh +npm run test:integration-docker +``` + +If you have [geckodriver][geckodriver] installed you can use these steps: +```sh +npm start & +npm run test:integration +``` +This will use your local Firefox installation. + +### Adding a test + +The tests are written in Python using a POM, or Page Object Model. The plugin we use for this is called [pypom][pypom]. Please read the documentation there for good examples on how to use the Page Object Model when writing tests. + +The pytest plugin that we use for running tests has a number of advanced command line options available too. The full documentation for the plugin can be found [here][pytest-selenium]. + +### Watching a test run (within the docker container) + +The tests are run on a live version of Firefox, but they are run headless. To access the container where the tests are run to view them follow these steps: + +1. Make sure all of the containers are running: +```sh +docker-compose ps +``` +If not start them detached: +```sh +docker-compose up -d +``` + +2. Copy the port that is forwarded for the ```selenium-firefox``` image: +```sh +0.0.0.0:32771->5900/tcp +``` +Note: Your port may not match what is seen here. + +You will want to copy what ever IP address and port is before the ```->5900/tcp```. + +3. Open your favorite VNC viewer and type in, or paste that address. +4. The password is ```secret```. +5. The viewer should open a window with a Ubuntu logo. If that happens you are connected to the ```selenium-firefox``` image and if you start the test, you should see a Firefox window open and the tests running. + +### Debugging a failure + +Whether a test passes or fails will result in a HTML report being created. This report will have detailed information of the test run and if a test does fail, it will provide geckodriver logs, terminal logs, as well as a screenshot of the browser when the test failed. We use a pytest plugin called [pytest-html][pytest-html] to create this report. The report can be found within the root directory of the project and is named ```send-test.html```. It should be viewed within a browser. + +[flake8]: http://flake8.pycqa.org/en/latest/ +[git-clone]: https://help.github.com/articles/cloning-a-repository/ +[git-fork]: https://help.github.com/articles/fork-a-repo/ +[geckodriver]: https://github.com/mozilla/geckodriver/releases/tag/v0.19.1 +[pypom]: http://pypom.readthedocs.io/en/latest/ +[pytest]: https://docs.pytest.org/en/latest/ +[pytest-html]: https://github.com/pytest-dev/pytest-html +[pytest-selenium]: http://pytest-selenium.readthedocs.org/ +[Selenium]: http://selenium-python.readthedocs.io/index.html +[selenium-api]: http://selenium-python.readthedocs.io/locating-elements.html +[Tox]: http://tox.readthedocs.io/ diff --git a/test/integration/conftest.py b/test/integration/conftest.py new file mode 100644 index 00000000..12dc858b --- /dev/null +++ b/test/integration/conftest.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +"""Configuration files for pytest.""" +import pytest +import requests +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.retry import Retry + +from pages.desktop.download import Download +from pages.desktop.home import Home + + +@pytest.fixture +def firefox_options(firefox_options, download_location_dir): + """Firefox options.""" + firefox_options.set_preference("browser.download.panel.shown", False) + firefox_options.set_preference( + "browser.helperApps.neverAsk.openFile", "text/plain") + firefox_options.set_preference( + "browser.helperApps.neverAsk.saveToDisk", "text/plain") + firefox_options.set_preference("browser.download.folderList", 2) + firefox_options.set_preference( + "browser.download.dir", "{0}".format(download_location_dir)) + firefox_options.add_argument('-foreground') + firefox_options.log.level = 'trace' + return firefox_options + + +@pytest.fixture(scope='session', autouse=True) +def _verify_url(request, base_url): + """Verifies the base URL""" + verify = request.config.option.verify_base_url + if base_url and verify: + session = requests.Session() + retries = Retry(backoff_factor=0.1, + status_forcelist=[500, 502, 503, 504]) + session.mount(base_url, HTTPAdapter(max_retries=retries)) + session.get(base_url, verify=False) + + +@pytest.fixture +def download_location_dir(tmpdir): + """Directory for downloading sample file.""" + return tmpdir.mkdir('test_download') + + +@pytest.fixture +def upload_location_dir(tmpdir): + """Directory for uploading sample file.""" + return tmpdir.mkdir('test_upload') + + +@pytest.fixture +def test_file(upload_location_dir): + """Create test upload/download file.""" + setattr(test_file, 'name', 'sample.txt') + setattr(test_file, 'location', upload_location_dir.join(test_file.name)) + return test_file + + +@pytest.fixture +def download_file(upload_file): + """Uploads and downloads a file""" + download = Download(upload_file.selenium, upload_file.file_url).open() + download.download_btn.click() + return download + + +@pytest.fixture +def upload_file(selenium, base_url, download_location_dir, test_file): + """Upload file fixture.""" + home = Home(selenium, base_url).open() + test_file.location.write('This is a test! This is a test!') + return home.upload_area("{0}".format(test_file.location.realpath())) diff --git a/test/integration/pages/__init__.py b/test/integration/pages/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/integration/pages/desktop/__init__.py b/test/integration/pages/desktop/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/integration/pages/desktop/base.py b/test/integration/pages/desktop/base.py new file mode 100644 index 00000000..bfa6b0f0 --- /dev/null +++ b/test/integration/pages/desktop/base.py @@ -0,0 +1,18 @@ +from pypom import Page +from selenium.webdriver.common.by import By + + +class Base(Page): + + _url = '{base_url}' + _send_logo_locator = (By.CLASS_NAME, 'logo') + + def __init__(self, selenium, base_url, locale='en-US', **kwargs): + super(Base, self).__init__( + selenium, base_url, locale=locale, timeout=10, **kwargs) + + def wait_for_page_to_load(self): + self.wait.until( + lambda _: self.find_element( + *self._send_logo_locator).is_displayed()) + return self diff --git a/test/integration/pages/desktop/download.py b/test/integration/pages/desktop/download.py new file mode 100644 index 00000000..16081765 --- /dev/null +++ b/test/integration/pages/desktop/download.py @@ -0,0 +1,16 @@ +from selenium.webdriver.common.by import By + +from pages.desktop.base import Base + + +class Download(Base): + + _download_button_locator = (By.CLASS_NAME, 'btn--download') + + def wait_for_page_to_load(self): + self.wait.until(lambda _: self.download_btn.is_displayed()) + + @property + def download_btn(self): + """Download button.""" + return self.find_element(*self._download_button_locator) diff --git a/test/integration/pages/desktop/home.py b/test/integration/pages/desktop/home.py new file mode 100644 index 00000000..36bf50bb --- /dev/null +++ b/test/integration/pages/desktop/home.py @@ -0,0 +1,26 @@ +from selenium.webdriver.common.by import By + +from pages.desktop.base import Base + + +class Home(Base): + """Addons Home page""" + + _upload_area_locator = (By.ID, 'file-upload') + _upload_button_locator = (By.CLASS_NAME, 'btn--file') + + @property + def upload_btn(self): + """Upload button.""" + return self.find_element(*self._upload_button_locator) + + def upload_area(self, path, cancel=False): + """Area that allows for drag and drop uploading. + + Returns Progress Object. + """ + self.find_element(*self._upload_area_locator).send_keys(path) + from pages.desktop.progress import Progress + return Progress( + self.selenium, self.base_url).wait_for_page_to_load( + cancel_after_load=cancel) diff --git a/test/integration/pages/desktop/progress.py b/test/integration/pages/desktop/progress.py new file mode 100644 index 00000000..b837e708 --- /dev/null +++ b/test/integration/pages/desktop/progress.py @@ -0,0 +1,24 @@ +from selenium.webdriver.common.by import By + +from pages.desktop.base import Base + + +class Progress(Base): + + _cancel_button = (By.ID, 'cancel-upload') + _progress_icon_locator = (By.CLASS_NAME, 'progress__bar') + + def wait_for_page_to_load(self, cancel_after_load=False): + self.wait.until( + lambda _: self.find_element( + *self._progress_icon_locator).is_displayed()) + if cancel_after_load: + self.cancel_btn.click() + return + from pages.desktop.share import Share + return Share(self.selenium, self.base_url).wait_for_page_to_load() + + @property + def cancel_btn(self): + """Cancel upload button.""" + return self.find_element(*self._cancel_button) diff --git a/test/integration/pages/desktop/share.py b/test/integration/pages/desktop/share.py new file mode 100644 index 00000000..5110e0a3 --- /dev/null +++ b/test/integration/pages/desktop/share.py @@ -0,0 +1,21 @@ +from selenium.webdriver.common.by import By + +from pages.desktop.base import Base + + +class Share(Base): + + _share_page_locator = (By.CLASS_NAME, 'sharePage') + _share_url_locator = (By.ID, 'fileUrl') + + def wait_for_page_to_load(self): + self.wait.until( + lambda _: self.find_element( + *self._share_page_locator).is_displayed()) + return self + + @property + def file_url(self): + """File uploaded URL.""" + return self.find_element( + *self._share_url_locator).get_property('value') diff --git a/test/integration/pipenv.txt b/test/integration/pipenv.txt new file mode 100644 index 00000000..d4adcb0c --- /dev/null +++ b/test/integration/pipenv.txt @@ -0,0 +1 @@ +pipenv==11.9.0 diff --git a/test/integration/scripts/start-docker.sh b/test/integration/scripts/start-docker.sh new file mode 100755 index 00000000..45de0474 --- /dev/null +++ b/test/integration/scripts/start-docker.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# piping to dev/null for starting the server within the firefox docker image +npm install > "/dev/null" 2>&1 +npm start > "/dev/null" 2>&1 & diff --git a/test/integration/test_download.py b/test/integration/test_download.py new file mode 100644 index 00000000..c1c750d4 --- /dev/null +++ b/test/integration/test_download.py @@ -0,0 +1,6 @@ +"""Test files regarding downloads.""" + + +def test_download(download_file, download_location_dir, test_file): + """Test downloaded file matches uploaded file.""" + assert download_location_dir.ensure(test_file.name) diff --git a/test/integration/test_progress.py b/test/integration/test_progress.py new file mode 100644 index 00000000..c2e354f1 --- /dev/null +++ b/test/integration/test_progress.py @@ -0,0 +1,6 @@ +"""Test files regarding the upload progress pages.""" + + +def test_progress(upload_file): + """Test progress icon shows while uploading.""" + assert upload_file diff --git a/test/integration/test_upload.py b/test/integration/test_upload.py new file mode 100644 index 00000000..e9dc09ae --- /dev/null +++ b/test/integration/test_upload.py @@ -0,0 +1,6 @@ +"""Test files regarding uploading.""" + + +def test_upload(upload_file): + """Test file upload and creates URL.""" + assert upload_file.file_url is not None diff --git a/test/integration/tox.ini b/test/integration/tox.ini new file mode 100755 index 00000000..eac1ea81 --- /dev/null +++ b/test/integration/tox.ini @@ -0,0 +1,24 @@ +[tox] +envlist = integration-tests, flake8 +skipsdist = True + +[testenv] +recreate=True +skip_install = True +passenv = DISPLAY MOZ_HEADLESS +deps = -rpipenv.txt +commands = + pipenv install --skip-lock + pipenv run pytest -v --verify-base-url --driver Firefox --html=send-test.html --self-contained-html {posargs} + +[testenv:flake8] +commands = + pipenv install --skip-lock + pipenv run flake8 {posargs:.} + +[flake8] +exclude = .eggs,.tox,docs,node_modules + +[pytest] +base_url = http://localhost:8080 +sensitive_url = mozilla\.(com|org) From 3e08c3574034f530e26156ff1704aba527d13651 Mon Sep 17 00:00:00 2001 From: Benjamin Forehand Jr Date: Tue, 24 Apr 2018 11:07:11 -0400 Subject: [PATCH 2/2] Updated strings and descriptions. --- test/integration/pages/desktop/base.py | 1 + test/integration/pages/desktop/download.py | 1 + test/integration/pages/desktop/home.py | 2 +- test/integration/pages/desktop/progress.py | 1 + test/integration/pages/desktop/share.py | 1 + 5 files changed, 5 insertions(+), 1 deletion(-) diff --git a/test/integration/pages/desktop/base.py b/test/integration/pages/desktop/base.py index bfa6b0f0..b9b75c04 100644 --- a/test/integration/pages/desktop/base.py +++ b/test/integration/pages/desktop/base.py @@ -3,6 +3,7 @@ from selenium.webdriver.common.by import By class Base(Page): + """Base object model.""" _url = '{base_url}' _send_logo_locator = (By.CLASS_NAME, 'logo') diff --git a/test/integration/pages/desktop/download.py b/test/integration/pages/desktop/download.py index 16081765..59134d6d 100644 --- a/test/integration/pages/desktop/download.py +++ b/test/integration/pages/desktop/download.py @@ -4,6 +4,7 @@ from pages.desktop.base import Base class Download(Base): + """Download page object model.""" _download_button_locator = (By.CLASS_NAME, 'btn--download') diff --git a/test/integration/pages/desktop/home.py b/test/integration/pages/desktop/home.py index 36bf50bb..a5b0c6cf 100644 --- a/test/integration/pages/desktop/home.py +++ b/test/integration/pages/desktop/home.py @@ -4,7 +4,7 @@ from pages.desktop.base import Base class Home(Base): - """Addons Home page""" + """Firefox Send Home page object model.""" _upload_area_locator = (By.ID, 'file-upload') _upload_button_locator = (By.CLASS_NAME, 'btn--file') diff --git a/test/integration/pages/desktop/progress.py b/test/integration/pages/desktop/progress.py index b837e708..f7821820 100644 --- a/test/integration/pages/desktop/progress.py +++ b/test/integration/pages/desktop/progress.py @@ -4,6 +4,7 @@ from pages.desktop.base import Base class Progress(Base): + """Progress page object model.""" _cancel_button = (By.ID, 'cancel-upload') _progress_icon_locator = (By.CLASS_NAME, 'progress__bar') diff --git a/test/integration/pages/desktop/share.py b/test/integration/pages/desktop/share.py index 5110e0a3..a8197e86 100644 --- a/test/integration/pages/desktop/share.py +++ b/test/integration/pages/desktop/share.py @@ -4,6 +4,7 @@ from pages.desktop.base import Base class Share(Base): + """SHare page object model.""" _share_page_locator = (By.CLASS_NAME, 'sharePage') _share_url_locator = (By.ID, 'fileUrl')