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)