diff --git a/.gitignore b/.gitignore
index 9e2e4b7212f6034228cbf6e1a7fe1415067a6478..082f3dd1da41ecaee958ad880b3fd0c8a7248935 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,4 @@ beat/web/static/
 doc/api/api/
 html/
 *.tar.bz2
+nohup.out
diff --git a/beat/web/reports/static/reports/test/report-spec.js b/beat/web/reports/static/reports/test/report-spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..93df73d7b8ea98608ccf19a09f0f86d435bb4442
--- /dev/null
+++ b/beat/web/reports/static/reports/test/report-spec.js
@@ -0,0 +1,90 @@
+// general tests for the reports app
+describe('reports app', function(){
+	browser.ignoreSynchronization = true;
+
+	// if there's an error in the web browser's console,
+	// fail the test and print the error
+	afterEach(function() {
+		let util = require('util');
+		browser.manage().logs().get('browser').then(function(logs) {
+			// 'failed to load resource' is a familiar log error
+			// when running adblockers. Don't fail on those.
+			let failingLogs = logs
+			.filter(l => !(/net::ERR_FAILED/.test(l.message)));
+
+			expect(failingLogs.length).toEqual(0);
+
+			if (failingLogs.length > 0) {
+				console.log(`logs: ${util.inspect(failingLogs)}`);
+			}
+		});
+	})
+
+	// /reports
+	describe('home', function(){
+		beforeEach(function(){
+			browser.get('http://localhost:8000/reports');
+		});
+
+		it('should load', function(){
+			expect(browser.getTitle()).toEqual('BEAT - Public Reports');
+		});
+
+		it('should have no reports', function(){
+			let noReportsText = browser.findElement(by.className('not-found'));
+			expect(noReportsText.getText()).toBe('No report found');
+		});
+	});
+
+	// /reports/user
+	describe('home for the test user', function(){
+		// login to the default user ('user') once before running all these tests
+		beforeAll(function(){
+			browser.get('http://localhost:8000/login/?next=/');
+			//browser.findElement(by.partialLinkText('Sign-in')).click();
+			browser.findElement(by.id('id_username')).sendKeys('user');
+			browser.findElement(by.id('id_password')).sendKeys('user');
+			browser.findElement(by.partialButtonText('Sign-in')).click();
+			return browser.wait(function(){
+				return browser.getCurrentUrl().then(function(url){
+					const rxUserLoggedIn = /events\/user\//;
+					return rxUserLoggedIn.test(url);
+				});
+			});
+		});
+
+		// go to user's reports page before each test
+		beforeEach(function(){
+			browser.get('http://localhost:8000/reports/user/');
+		});
+
+		// before adding a report, there shouldn't be any
+		it('should have no reports', function(){
+			let noReportsText = browser.findElement(by.className('not-found'));
+			expect(noReportsText.getText()).toBe('No report found');
+		});
+
+		// create a report
+		it('should create a new report called "test"', function(){
+			let newReportButton = browser.findElement(by.partialLinkText('New'));
+			newReportButton.click();
+
+			// wait for dialog box to pop up
+			browser.sleep(1000);
+
+			let reportNameInput = browser.findElement(by.css('.has-error input'));
+			reportNameInput.sendKeys('test');
+
+			let submitButton = element(by.buttonText('Create'));
+			expect(submitButton.isPresent()).toBeTruthy();
+			submitButton.click();
+
+			// wait for page to refresh
+			browser.sleep(1000);
+
+			let newReportLink = browser.findElement(by.linkText('user/test'));
+			newReportLink.click();
+			expect(browser.getTitle()).toBe('BEAT - Report');
+		});
+	});
+});
diff --git a/beat/web/reports/static/reports/test/test-spec.js b/beat/web/reports/static/reports/test/test-spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..b22411eb98ba6136b0d2bfca49c25800196e8858
--- /dev/null
+++ b/beat/web/reports/static/reports/test/test-spec.js
@@ -0,0 +1,18 @@
+// 'describe' blocks can hold other 'describe' blocks and test blocks
+describe('BEAT platform', function() {
+	/*
+	 * the BEAT platform does not use Angular in a way that
+	 * Protractor can automatically reason about,
+	 * so disable special Angular features
+	 */
+	browser.ignoreSynchronization = true;
+	// 'it' blocks are individual tests
+	it('should have the page title of "BEAT"', function() {
+		// 'browser' is a global object representing the browser
+		// assumes the BEAT web server is running locally on port 8000
+		browser.get('http://localhost:8000');
+
+		// simple test to sanity check Protractor
+		expect(browser.getTitle()).toEqual('BEAT');
+	});
+});
diff --git a/doc/admin/installation.rst b/doc/admin/installation.rst
index 7bc4b2ae22da0a6c6d4a0f2f54314e6b0caee274..87d4598d623cbe9576dd728b7254812db3d04a33 100644
--- a/doc/admin/installation.rst
+++ b/doc/admin/installation.rst
@@ -184,6 +184,127 @@ Or, to generate an HTML report::
    ``test.sql3`` on your current directory. You may delete it when you're done.
 
 
+End-to-End Testing
+------------------
+
+`Protractor <http://www.protractortest.org/#/>`_ is an e2e (end-to-end) testing tool for web apps. Protractor runs tests through Selenium using a real browser, and as such needs a headed environment and a compatible browser installed.
+
+.. warning::
+   Protractor will open a new browser window in the foreground when it is started.
+
+Setup
+=====
+
+Currently, testing the BEAT web platform with Protractor requires additional setup after successfully setting up the project locally:
+
+- Install Protractor
+
+  .. code:: bash
+
+         ./parts/buildout-node/node-*/bin/npm i -g protractor
+
+- Download/update the webdriver-manager's dependencies (Selenium & more)
+
+  .. code:: bash
+
+   ./parts/buildout-node/node-*/bin/webdriver-manager update
+
+Running tests with the provided script
+======================================
+
+The ``protractor.sh`` script is a one-liner to run Protractor tests. It handles database creation/saving/restoring and manages the required local server processes. However, it assumes several things:
+
+- It is being ran in the top directory of the ``beat.web`` repository
+- The repository has already ran ``./bin/buildout`` successfully and with default development configuration
+- Protractor's ``.conf`` file is ``./protractor-conf.js``
+- Default (no) additional arguments passed to ``webdriver-manager`` or Django ``runserver``
+- Django uses ``./django.sql3`` as the database
+- If ``./django.sql3`` does not exist, the default database generated by ``./bin/django install`` is sufficient for testing
+
+Manual test running
+===================
+
+If the ``protractor.sh`` script won't work, one can test manually.
+
+The ``webdriver-manager`` must be running while testing. To run tests using a local BEAT web server, you must have the BEAT web server up as well.
+
+Starting the webdriver server
+_____________________________
+
+- Start the webdriver server in a separate shell (or append `` &`` to run it as a background process in the current shell)
+
+  .. code:: bash
+
+     ./parts/buildout-node/node-*/bin/webdriver-manager start
+
+  .. important::
+
+     You may only have 1 webdriver manager running at once.
+
+- After the webdriver finishes initialization, you can run tests
+
+  .. code:: bash
+
+     ./parts/buildout-node/node-*/bin/protractor protractor-conf.js
+
+- If you started your webdriver server as a background process, you can kill all webdriver processes
+
+  .. code:: bash
+
+     pkill -f webdriver-manager
+
+Understanding the output of Protractor
+======================================
+
+By default Protractor prints to ``STDOUT``. If a test passes, nothing is printed about that particular test. If a test fails, Protractor will print more information about the failure, including the specific test, type of failure that occurred, and a stack trace. At the end of testing, Protractor will print a summary of the test run.
+
+Saving test results
+___________________
+
+Beyond simply piping Protractor's output to a file, you may enable detailed logging via a specified JSON file. Just uncomment the relevant line in ``protractor-conf.js`` and optionally change the output file location:
+
+.. code:: javascript
+
+	//resultJsonOutputFile: './protractor-test-results.json'
+
+Adding your test to Protractor
+==============================
+
+The configuration file detailing the test files is ``protractor-conf.js``. The ``specs`` field is a comma-separated list of test files - just add your new test file to the list and run protractor again.
+
+For example, to add the test file ``example-spec.js``:
+
+- Before
+
+  .. code:: javascript
+
+     specs: [
+            './beat/web/reports/static/reports/test/test-spec.js'
+     ],
+
+- After
+
+  .. code:: javascript
+
+     specs: [
+            './beat/web/reports/static/reports/test/test-spec.js',
+            'example-spec.js'
+     ],
+
+Writing Protractor tests
+========================
+
+Protractor uses and expects tests to use the `Jasmine BDD testing framework <https://jasmine.github.io/>`_. For a tutorial on writing Protractor tests, see the `official Protractor tutorial <http://www.protractortest.org/#/tutorial>`_. Protractor also has documentation on their website.
+
+BEAT platform & Protractor's Angular support
+____________________________________________
+
+By default, Protractor assumes that the tested website will use Angular in a particular fashion to more intelligently detect a page that has finished rendering. However, the BEAT platform does not use Angular this way, and Protractor will hang forever. To tell Protractor not to assume this compatibility, add the following line at the top of each top-level ``describe`` block in your test files:
+
+.. code:: javascript
+
+   browser.ignoreSynchronization = true;
+
 .. _administratorguide-installation-instantiating:
 
 Instantiating and Starting a Development System
diff --git a/protractor-conf.js b/protractor-conf.js
new file mode 100644
index 0000000000000000000000000000000000000000..00bf9926fe894fb31d4e4a27af2f3ea9dfb5934e
--- /dev/null
+++ b/protractor-conf.js
@@ -0,0 +1,11 @@
+exports.config = {
+	seleniumAddress: 'http://localhost:4444/wd/hub',
+
+	specs: [
+		'./beat/web/reports/static/reports/test/test-spec.js',
+		'./beat/web/reports/static/reports/test/report-spec.js'
+	],
+	allScriptsTimeout: 60000,
+
+	//resultJsonOutputFile: './protractor-test-results.json'
+}
diff --git a/protractor.sh b/protractor.sh
new file mode 100755
index 0000000000000000000000000000000000000000..87ee1bc03530f34c944da88a0c0a32b9922960ff
--- /dev/null
+++ b/protractor.sh
@@ -0,0 +1,83 @@
+###############################################################################
+# e2e testing helper script
+# Preconditions:
+# 	- Ran in the top directory of the `beat.web` repository
+#	- `Buildout` has already been ran successfully
+#	- Default development environment configurations
+#	- $SHELL is BASH or ZSH
+# Postconditions:
+#	- `nohup.out` file will exist in top directory
+#	- If there was not a `django.sql3` db file, there will be one
+#
+# SUGGESTION: The vast majority of the time is spent generating a fresh db via
+# `./bin/django install`. You may have a file named `template.django.sql3`
+# in the top dir that will be copied and used as the initial db to massively
+# speed up testing.
+###############################################################################
+
+# make sure the beat web server and web manager arent running already
+if [ $(ps aux | grep -c 'django runserver') -gt 1 ]
+then
+	echo 'The BEAT web server is already running locally, aborting...'
+	exit -1
+fi
+if [ $(ps aux | grep -c 'webdriver-manager start') -gt 1 ]
+then
+	echo 'The webdriver-manager is already running locally, aborting...'
+	exit -1
+fi
+
+# if db already exists, save it
+if [ -a django.sql3 ]
+then
+	echo 'Found existing django database,' \
+		'saving it to "old.django.sql3"...'
+	mv django.sql3 old.django.sql3
+fi
+
+# Either use a template db...
+if [ -a template.django.sql3 ]
+then
+	echo 'Found template django database, copying it...'
+	cp template.django.sql3 django.sql3
+else
+# ...or generate a new one
+	echo 'Found no template db ("template.django.sql3"),' \
+		'generating a new db....'
+	./bin/django install
+fi
+
+# run the web server
+beat_cmd='./bin/django runserver'
+# spin up web manager
+webdriver_cmd='./parts/buildout-node/node-*/bin/webdriver-manager start'
+# run tests
+protractor_cmd='./parts/buildout-node/node-*/bin/protractor ./protractor-conf.js'
+
+# start bg processes
+echo 'Output from the BEAT web server &' \
+	'the Protractor webdriver-manager will be found in nohup.out'
+nohup $beat_cmd &
+beat_pid=$!
+nohup $webdriver_cmd &
+webdriver_pid=$!
+
+# couple seconds to let them set up
+sleep 2
+
+echo 'Running Protractor....'
+echo '----------------'
+# run tests
+$protractor_cmd
+
+echo '----------------'
+
+# if we saved a db, restore it
+if [ -a old.django.sql3 ]
+then
+	echo 'restoring old database...'
+	mv old.django.sql3 django.sql3
+fi
+
+# clean up any type of exit
+trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT