From 704629b72935d20d507f73219b89013101d2c1c7 Mon Sep 17 00:00:00 2001
From: Benaka Moorthi <benaka.moorthi@gmail.com>
Date: Wed, 17 Jul 2013 06:18:21 -0400
Subject: [PATCH] Added initial UI integration tests that use cutycapt to take
 screenshots of URLs.

---
 .gitignore                                    |   3 +
 core/Access.php                               |   9 +-
 core/Config.php                               |   7 +
 core/FrontController.php                      |   1 +
 plugins/Zeitgeist/templates/dashboard.twig    |   2 +-
 .../Fixtures/ManySitesImportedLogs.php        |   9 +
 .../ManySitesImportedLogsWithXssAttempts.php  | 122 +++++++++
 tests/PHPUnit/Integration/ArchiveCronTest.php |   6 +
 tests/PHPUnit/IntegrationTestCase.php         |  19 +-
 tests/PHPUnit/UI/UIIntegrationTest.php        | 252 ++++++++++++++++++
 tests/PHPUnit/phpunit.xml.dist                |   1 +
 .../PHPUnit/populate-expected-screenshots.sh  |  38 +++
 tests/PHPUnit/proxy/archive.php               |   1 +
 tests/PHPUnit/proxy/index.php                 |   8 +-
 tests/PHPUnit/proxy/libs                      |   1 +
 tests/PHPUnit/proxy/plugins                   |   1 +
 tests/PHPUnit/proxy/tests                     |   1 +
 tests/PHPUnit/travis.sh                       |   7 +-
 tests/README.md                               |  32 +++
 tests/travis/php.ini                          |   1 -
 20 files changed, 509 insertions(+), 12 deletions(-)
 create mode 100644 tests/PHPUnit/Fixtures/ManySitesImportedLogsWithXssAttempts.php
 create mode 100644 tests/PHPUnit/UI/UIIntegrationTest.php
 create mode 100755 tests/PHPUnit/populate-expected-screenshots.sh
 create mode 120000 tests/PHPUnit/proxy/libs
 create mode 120000 tests/PHPUnit/proxy/plugins
 create mode 120000 tests/PHPUnit/proxy/tests

diff --git a/.gitignore b/.gitignore
index 0ae42c673e..b6b2b2391e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -63,3 +63,6 @@ docs/
 composer.phar
 vendor/
 /.htaccess
+tests/PHPUnit/UI/processed-ui-screenshots
+tests/PHPUnit/UI/expected-ui-screenshots
+
diff --git a/core/Access.php b/core/Access.php
index 9045e5bcb7..33952f943e 100644
--- a/core/Access.php
+++ b/core/Access.php
@@ -206,13 +206,20 @@ class Piwik_Access
     protected function reloadAccessSuperUser()
     {
         $this->isSuperUser = true;
+        
         try {
             $allSitesId = Piwik_SitesManager_API::getInstance()->getAllSitesId();
         } catch(Exception $e) {
             $allSitesId = array();
         }
         $this->idsitesByAccess['superuser'] = $allSitesId;
-        $this->login = Piwik_Config::getInstance()->superuser['login'];
+        
+        if (isset($GLOBALS['PIWIK_ACCESS_SUPERUSER_LOGIN'])) {
+            $this->login = $GLOBALS['PIWIK_ACCESS_SUPERUSER_LOGIN'];
+        } else {
+            $this->login = Piwik_Config::getInstance()->superuser['login'];
+        }
+        
         return true;
     }
 
diff --git a/core/Config.php b/core/Config.php
index 354f9e4e37..8b71e0c256 100644
--- a/core/Config.php
+++ b/core/Config.php
@@ -126,6 +126,13 @@ class Piwik_Config
         // for unit tests, we set that no plugin is installed. This will force
         // the test initialization to create the plugins tables, execute ALTER queries, etc.
         $this->configCache['PluginsInstalled'] = array('PluginsInstalled' => array());
+        
+        if (isset($configGlobal['Plugins'])) {
+            $this->configCache['Plugins'] = $this->configGlobal['Plugins'];
+            $this->configCache['Plugins']['Plugins'][] = 'DevicesDetection';
+        }
+        
+        $this->configCache['disable_merged_assets'] = 1;
     }
 
     /**
diff --git a/core/FrontController.php b/core/FrontController.php
index 22dde1dae4..9932f6b058 100644
--- a/core/FrontController.php
+++ b/core/FrontController.php
@@ -244,6 +244,7 @@ class Piwik_FrontController
 
             $pluginsManager = Piwik_PluginsManager::getInstance();
             $pluginsToLoad = Piwik_Config::getInstance()->Plugins['Plugins'];
+
             $pluginsManager->loadPlugins($pluginsToLoad);
 
             if ($exceptionToThrow) {
diff --git a/plugins/Zeitgeist/templates/dashboard.twig b/plugins/Zeitgeist/templates/dashboard.twig
index a1f7440c13..70c37303f6 100644
--- a/plugins/Zeitgeist/templates/dashboard.twig
+++ b/plugins/Zeitgeist/templates/dashboard.twig
@@ -38,6 +38,6 @@
     </div>
 
     {% include "_piwikTag.twig" %}
-
+    
     </body>
 </html>
diff --git a/tests/PHPUnit/Fixtures/ManySitesImportedLogs.php b/tests/PHPUnit/Fixtures/ManySitesImportedLogs.php
index bc4c3404b7..a0a474af1d 100644
--- a/tests/PHPUnit/Fixtures/ManySitesImportedLogs.php
+++ b/tests/PHPUnit/Fixtures/ManySitesImportedLogs.php
@@ -189,3 +189,12 @@ class Test_Piwik_Fixture_ManySitesImportedLogs extends Test_Piwik_BaseFixture
         self::executeLogImporter($logFile, $opts);
     }
 }
+
+// needed by tests that use stored segments w/ the proxy index.php
+class Test_Piwik_Access_OverrideLogin extends Piwik_Access
+{
+    public function getLogin()
+    {
+        return 'superUserLogin';
+    }
+}
diff --git a/tests/PHPUnit/Fixtures/ManySitesImportedLogsWithXssAttempts.php b/tests/PHPUnit/Fixtures/ManySitesImportedLogsWithXssAttempts.php
new file mode 100644
index 0000000000..80f36f5544
--- /dev/null
+++ b/tests/PHPUnit/Fixtures/ManySitesImportedLogsWithXssAttempts.php
@@ -0,0 +1,122 @@
+<?php
+/**
+ * Piwik - Open source web analytics
+ *
+ * @link    http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+require_once PIWIK_INCLUDE_PATH . '/tests/PHPUnit/Fixtures/ManySitesImportedLogs.php';
+
+/**
+ * Imports visits from several log files using the python log importer &
+ * adds goals/sites/etc. attempting to create XSS.
+ */
+class Test_Piwik_Fixture_ManySitesImportedLogsWithXssAttempts extends Test_Piwik_Fixture_ManySitesImportedLogs
+{
+    public function setUp()
+    {
+        parent::setUp();
+        
+        $this->setupDashboards();
+        $this->setupXssSegment();
+        $this->addAnnotations();
+    }
+
+    public function setUpWebsitesAndGoals()
+    {
+        // for conversion testing
+        $siteName = self::makeXssContent("site name", $sanitize = true);
+        self::createWebsite($this->dateTime, $ecommerce = 1, $siteName);
+        Piwik_Goals_API::getInstance()->addGoal(
+            $this->idSite, self::makeXssContent("goal name"), 'url', 'http', 'contains', false, 5);
+        
+        self::createWebsite($this->dateTime, $ecommerce = 0, $siteName = 'Piwik test two',
+            $siteUrl = 'http://example-site-two.com');
+    }
+    
+    /** Creates two dashboards that split the widgets up into different groups. */
+    public function setupDashboards()
+    {
+        $dashboardColumnCount = 3;
+        $dashboardCount = 3;
+        
+        $dashboards = array();
+        for ($i = 0; $i != $dashboardCount; ++$i) {
+            $layout = array();
+            for ($j = 0; $j != $dashboardColumnCount; ++$j) {
+                $layout[] = array();
+            }
+            
+            $dashboards[] = $layout;
+        }
+        
+        $oldGet = $_GET;
+        $_GET['idSite'] = $this->idSite;
+        
+        // collect widgets to add to the layout
+        $groupedWidgets = array();
+        $dashboard = 0;
+        foreach (Piwik_GetWidgetsList() as $category => $widgets) {
+            foreach ($widgets as $widget) {
+                if ($widget['uniqueId'] == 'widgetSEOgetRank'
+                    || $widget['uniqueId'] == 'widgetReferersgetKeywordsForPage'
+                    || strpos($widget['uniqueId'], 'widgetExample') === 0
+                ) {
+                    continue;
+                }
+                
+                $dashboard = ($dashboard + 1) % $dashboardCount;
+                $groupedWidgets[$dashboard][] = array(
+                    'uniqueId' => $widget['uniqueId'],
+                    'parameters' => $widget['parameters']
+                );
+            }
+        }
+        
+        // distribute widgets in each dashboard
+        $column = 0;
+        foreach ($groupedWidgets as $dashboardIndex => $dashboardWidgets) {
+            foreach ($dashboardWidgets as $widget) {
+                $column = ($column + 1) % $dashboardColumnCount;
+                
+                $dashboards[$dashboardIndex][$column][] = $widget;
+            }
+        }
+        
+        foreach ($dashboards as $id => $layout) {
+            $_GET['name'] = self::makeXssContent('dashboard name' . $id);
+            $_GET['layout'] = Piwik_Common::json_encode($layout);
+            $_GET['idDashboard'] = $id + 1;
+            Piwik_FrontController::getInstance()->fetchDispatch('Dashboard', 'saveLayout');
+        }
+        
+        $_GET = $oldGet;
+    }
+    
+    public function setupXssSegment()
+    {
+        $segmentName = self::makeXssContent('segment');
+        $segmentDefinition = "browserCode==FF";
+        Piwik_SegmentEditor_API::getInstance()->add(
+            $segmentName, $segmentDefinition, $this->idSite, $autoArchive = true, $enabledAllUsers = true);
+    }
+    
+    public function addAnnotations()
+    {
+        Piwik_Annotations_API::getInstance()->add($this->idSite, '2012-08-09', "Note 1", $starred = 1);
+        Piwik_Annotations_API::getInstance()->add(
+            $this->idSite, '2012-08-08', self::makeXssContent("annotation"), $starred = 0);
+        Piwik_Annotations_API::getInstance()->add($this->idSite, '2012-08-10', "Note 3", $starred = 1);
+    }
+    
+    // NOTE: since API_Request does sanitization, API methods do not. when calling them, we must
+    // sometimes do sanitization ourselves.
+    public static function makeXssContent($type, $sanitize = false)
+    {
+        $result = "<script>$('body').html('$type XSS!');</script>";
+        if ($sanitize) {
+            $result = Piwik_Common::sanitizeInputValue($result);
+        }
+        return $result;
+    }
+}
diff --git a/tests/PHPUnit/Integration/ArchiveCronTest.php b/tests/PHPUnit/Integration/ArchiveCronTest.php
index 36e1414421..47c3beaac4 100644
--- a/tests/PHPUnit/Integration/ArchiveCronTest.php
+++ b/tests/PHPUnit/Integration/ArchiveCronTest.php
@@ -13,6 +13,12 @@ class Test_Piwik_Integration_ArchiveCronTest extends IntegrationTestCase
 {
     public static $fixture = null; // initialized below class definition
     
+    public static function createAccessInstance()
+    {
+        Piwik_Access::setSingletonInstance($access = new Test_Piwik_Access_OverrideLogin());
+        Piwik_PostEvent('FrontController.initAuthenticationObject');
+    }
+    
     public function getApiForTesting()
     {
         $results = array();
diff --git a/tests/PHPUnit/IntegrationTestCase.php b/tests/PHPUnit/IntegrationTestCase.php
index 58f75dd5be..902380177d 100755
--- a/tests/PHPUnit/IntegrationTestCase.php
+++ b/tests/PHPUnit/IntegrationTestCase.php
@@ -30,6 +30,16 @@ abstract class IntegrationTestCase extends PHPUnit_Framework_TestCase
         Piwik_Config::getInstance()->setTestEnvironment();
     }
 
+    /**
+     * Sets up access instance.
+     */
+    public static function createAccessInstance()
+    {
+        Piwik_Access::setSingletonInstance(null);
+        Piwik_Access::getInstance();
+        Piwik_PostEvent('FrontController.initAuthenticationObject');
+    }
+    
     /**
      * Connects to MySQL w/o specifying a database.
      */
@@ -73,6 +83,7 @@ abstract class IntegrationTestCase extends PHPUnit_Framework_TestCase
         $pluginsManager = Piwik_PluginsManager::getInstance();
         $pluginsToLoad = Piwik_Config::getInstance()->Plugins['Plugins'];
         $pluginsToLoad[] = 'DevicesDetection';
+        
         $pluginsManager->loadPlugins($pluginsToLoad);
     }
 
@@ -117,7 +128,7 @@ abstract class IntegrationTestCase extends PHPUnit_Framework_TestCase
             Piwik::$piwikUrlCache = '';
 
             if ($createConfig) {
-                self::createTestConfig();
+                static::createTestConfig();
             }
 
             if ($dbName === false) // must be after test config is created
@@ -151,9 +162,7 @@ abstract class IntegrationTestCase extends PHPUnit_Framework_TestCase
         include "DataFiles/LanguageToCountry.php";
         include "DataFiles/Providers.php";
         
-        Piwik_Access::setSingletonInstance(null);
-        Piwik_Access::getInstance();
-        Piwik_PostEvent('FrontController.initAuthenticationObject');
+        static::createAccessInstance();
 
         // We need to be SU to create websites for tests
         Piwik::setUserIsSuperUser();
@@ -840,7 +849,7 @@ abstract class IntegrationTestCase extends PHPUnit_Framework_TestCase
         return $input;
     }
 
-    private function getProcessedAndExpectedDirs()
+    protected function getProcessedAndExpectedDirs()
     {
         $path = $this->getPathToTestDirectory();
         return array($path . '/processed/', $path . '/expected/');
diff --git a/tests/PHPUnit/UI/UIIntegrationTest.php b/tests/PHPUnit/UI/UIIntegrationTest.php
new file mode 100644
index 0000000000..14a047c832
--- /dev/null
+++ b/tests/PHPUnit/UI/UIIntegrationTest.php
@@ -0,0 +1,252 @@
+<?php
+/**
+ * Piwik - Open source web analytics
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+/**
+ * Tests UI code by grabbing screenshots of webpages and comparing with expected files.
+ * 
+ * Uses cutycapt.
+ * 
+ * TODO:
+ * - allow instrumentation javascript to be injected before screenshot is taken (so we can, say,
+ *   take a screenshot of column documentation)
+ */
+class Test_Piwik_Integration_UIIntegrationTest extends IntegrationTestCase
+{
+    const IMAGE_TYPE = 'png';
+    
+    public static $fixture = null; // initialized below class definition
+    private static $useXvfb = false;
+    
+    public static function createAccessInstance()
+    {
+        Piwik_Access::setSingletonInstance($access = new Test_Piwik_Access_OverrideLogin());
+        Piwik_PostEvent('FrontController.initAuthenticationObject');
+    }
+    
+    public static function setUpBeforeClass()
+    {
+        if (self::isXvfbAvailable()) {
+            self::$useXvfb = true;
+        } else if (!self::isCutyCaptAvailable()) {
+            self::markTestSkipped("cutycapt is not available, skipping UI integration tests. "
+                                . "(install with 'sudo apt-get intsall cutycapt')");
+        }
+        
+        parent::setUpBeforeClass();
+        
+        Piwik_AssetManager::removeMergedAssets();
+        
+        // launch archiving so tests don't run out of time
+        Piwik_VisitsSummary_API::getInstance()->get(self::$fixture->idSite, 'year', '2012-08-09');
+    }
+    
+    public static function tearDownAfterClass()
+    {
+        if (!Zend_Registry::get('db')) {
+            Piwik::createDatabaseObject();
+        }
+        
+        parent::tearDownAfterClass();
+    }
+    
+    public function setUp()
+    {
+        parent::setUp();
+        
+        list($processedDir, $expectedDir) = $this->getProcessedAndExpectedDirs();
+        if (!is_dir($processedDir)) {
+            mkdir($processedDir);
+        }
+        if (!is_dir($expectedDir)) {
+            mkdir($expectedDir);
+        }
+        
+        if (!Zend_Registry::get('db')) {
+            Piwik::createDatabaseObject();
+        }
+    }
+    
+    public function tearDown()
+    {
+        parent::tearDown();
+        
+        Zend_Registry::get('db')->closeConnection();
+        Zend_Registry::set('db', false);
+    }
+    
+    public function getUrlsForTesting()
+    {
+        $generalParams = 'idSite=1&period=week&date=2012-08-09';
+        $evolutionParams = 'idSite=1&period=day&date=2012-08-11&evolution_day_last_n=30';
+        $urlBase = 'module=CoreHome&action=index&' . $generalParams;
+        $widgetizeParams = "module=Widgetize&action=iframe";
+        $segment = urlencode("browserCode==FF");
+        
+        return array(
+            // dashboard
+            array('dashboard1', "?$urlBase#$generalParams&module=Dashboard&action=embeddedIndex&idDashboard=1"),
+            array('dashboard2', "?$urlBase#$generalParams&module=Dashboard&action=embeddedIndex&idDashboard=2"),
+            array('dashboard3', "?$urlBase#$generalParams&module=Dashboard&action=embeddedIndex&idDashboard=3"),
+            
+            // visitors pages (except real time map since it displays current time)
+            array('visitors_overview', "?$urlBase#$generalParams&module=VisitsSummary&action=index"),
+            array('visitors_visitorlog', "?$urlBase#$generalParams&module=Live&action=indexVisitorLog"),
+            array('visitors_devices', "?$urlBase#$generalParams&module=DevicesDetection&action=index"),
+            array('visitors_locations_provider', "?$urlBase#$generalParams&module=UserCountry&action=index"),
+            array('visitors_settings', "?$urlBase#$generalParams&module=UserSettings&action=index"),
+            array('visitors_times', "?$urlBase#$generalParams&module=VisitTime&action=index"),
+            array('visitors_engagement', "?$urlBase#$generalParams&module=VisitFrequency&action=index"),
+            array('visitors_custom_vars', "?$urlBase#$generalParams&module=CustomVariables&action=index"),
+            
+            // actions pages
+            array('actions_pages', "?$urlBase#$generalParams&module=Actions&action=indexPageUrls"),
+            array('actions_entry_pages', "?$urlBase#$generalParams&module=Actions&action=indexEntryPageUrls"),
+            array('actions_exit_pages', "?$urlBase#$generalParams&module=Actions&action=indexExitPageUrls"),
+            array('actions_page_titles', "?$urlBase#$generalParams&module=Actions&action=indexPageTitles"),
+            array('actions_site_search', "?$urlBase#$generalParams&module=Actions&action=indexSiteSearch"),
+            array('actions_outlinks', "?$urlBase#$generalParams&module=Actions&action=indexOutlinks"),
+            array('actions_downloads', "?$urlBase#$generalParams&module=Actions&action=indexDownloads"),
+
+            // referrers pages
+            array('referrers_overview', "?$urlBase#$generalParams&module=Referers&action=index"),
+            array('referrers_search_engines_keywords',
+                  "?$urlBase#$generalParams&module=Referers&action=getSearchEnginesAndKeywords"),
+            array('referrers_websites_social', "?$urlBase#$generalParams&module=Referers&action=indexWebsites"),
+            array('referrers_campaigns', "?$urlBase#$generalParams&module=Referers&action=indexCampaigns"),
+            
+            // goals pages
+            array('goals_ecommerce',
+                  "?$urlBase#$generalParams&module=Goals&action=ecommerceReport&idGoal=ecommerceOrder"),
+            array('goals_overview', "?$urlBase#$generalParams&module=Goals&action=index"),
+            array('goals_individual_goal', "?$urlBase#$generalParams&module=Goals&action=goalReport&idGoal=1"),
+            
+            // one page w/ segment
+            array('visitors_overview_segment',
+                  "?$urlBase#$generalParams&module=VisitsSummary&action=index&segment=$segment"),
+            
+            // widgetize
+            array("widgetize_visitor_log",
+                  "?$widgetizeParams&$generalParams&moduleToWidgetize=Live&actionToWidgetize=getVisitorLog"),
+            array("widgetize_html_table",
+                  "?$widgetizeParams&$generalParams&moduleToWidgetize=UserCountry&actionToWidgetize=getCountry"
+                . "&viewDataTable=table"),
+            array("widgetize_goals_table",
+                  "?$widgetizeParams&$generalParams&moduleToWidgetize=UserCountry&actionToWidgetize=getCountry"
+                . "&viewDataTable=tableGoals"),
+            array("widgetize_all_columns_table",
+                  "?$widgetizeParams&$generalParams&moduleToWidgetize=UserCountry&actionToWidgetize=getCountry"
+                . "&viewDataTable=tableAllColumns"),
+            array("widgetize_pie_graph",
+                  "?$widgetizeParams&$generalParams&moduleToWidgetize=UserCountry&actionToWidgetize=getCountry"
+                . "&viewDataTable=graphPie"),
+            array("widgetize_bar_graph",
+                  "?$widgetizeParams&$generalParams&moduleToWidgetize=UserCountry&actionToWidgetize=getCountry"
+                . "&viewDataTable=graphVerticalBar"),
+            array("widgetize_evolution_graph",
+                  "?$widgetizeParams&$evolutionParams&moduleToWidgetize=UserCountry&actionToWidgetize=getCountry"
+                . "&viewDataTable=graphEvolution"),
+            array("widgetize_tag_cloud",
+                  "?$widgetizeParams&$generalParams&moduleToWidgetize=UserCountry&actionToWidgetize=getCountry"
+                . "&viewDataTable=cloud"),
+            array("widgetize_actions_search",
+                  "?$widgetizeParams&$generalParams&moduleToWidgetize=Actions&actionToWidgetize=getPageUrls"
+                . "&filter_column_recursive=label&filter_pattern_recursive=i"),
+            array("widgetize_actions_flat",
+                  "?$widgetizeParams&$generalParams&moduleToWidgetize=Actions&actionToWidgetize=getPageUrls"
+                . "&flat=1"),
+            array("widgetize_actions_excludelowpop",
+                  "?$widgetizeParams&$generalParams&moduleToWidgetize=Actions&actionToWidgetize=getPageUrls"
+                . "&enable_filter_excludelowpop=1"),
+            
+            // row evolution
+            array("row_evolution_popup",
+                  "?$widgetizeParams&moduleToWidgetize=CoreHome&actionToWidgetize=getRowEvolutionPopover"
+                . "&apiMethod=UserSettings.getBrowser&label=Chrome&disableLink=1&idSite=1&period=day"
+                . "&date=2012-08-11"),
+            array("multi_row_evolution_popup",
+                  "?$widgetizeParams&moduleToWidgetize=CoreHome&actionToWidgetize=getMultiRowEvolutionPopover"
+                . "&label=" . urlencode("Chrome,Firefox") . "&apiMethod=UserSettings.getBrowser&idSite=1&period=day"
+                . "&date=2012-08-11&disableLink=1"),
+        );
+    }
+
+    /**
+     * @dataProvider getUrlsForTesting
+     * @group        Integration
+     * @group        UITests
+     */
+    public function testUIUrl($name, $urlQuery)
+    {
+        list($processedDir, $expectedDir) = $this->getProcessedAndExpectedDirs();
+        
+        $processedScreenshotPath = $processedDir . "$name." . self::IMAGE_TYPE;
+        $expectedScreenshotPath = $expectedDir . "$name." . self::IMAGE_TYPE;
+        
+        // run cutycapt w/ url and output to /processed-ui-screenshots/$name.svg
+        $this->runCutyCapt($urlQuery, $processedScreenshotPath);
+        
+        // compare processed w/ expected
+        $this->compareScreenshot($name, $expectedScreenshotPath, $processedScreenshotPath);
+    }
+    
+    private function runCutyCapt($urlQuery, $processedPath)
+    {
+        $url = self::getProxyUrl() . $urlQuery;
+        
+        $cmd = "cutycapt --url=\"$url\" --out=\"$processedPath\" --min-width=1366 --delay=500 2>&1";
+        if (self::$useXvfb) {
+            $cmd = 'xvfb-run --server-args="-screen 0, 1024x768x24" ' . $cmd;
+        }
+        
+        exec($cmd, $output, $result);
+        
+        if ($result !== 0) {
+            throw new Exception("cutycapt failed: " . implode("\n", $output) . "\n\ncommand used: $cmd");
+        }
+
+        return $output;
+    }
+    
+    private function compareScreenshot($name, $expectedPath, $processedPath)
+    {
+        $processed = file_get_contents($processedPath);
+        
+        if (!file_exists($expectedPath)) {
+            $this->markTestIncomplete("expected screenshot for processed '$processedPath' is missing");
+        }
+        
+        $expected = file_get_contents($expectedPath);
+        $this->assertTrue($expected == $processed, "screenshot compare failed for '$processedPath'");
+    }
+    
+    private static function isCutyCaptAvailable()
+    {
+        exec("cutycapt --help 2>&1", $output, $result);
+        return $result === 0 || $result === 1;
+    }
+    
+    private static function isXvfbAvailable()
+    {
+        exec("xvfb-run --help 2>&1", $output, $result);
+        return $result === 0 || $result === 1;
+    }
+    
+    protected function getProcessedAndExpectedDirs()
+    {
+        $path = $this->getPathToTestDirectory() . '/../UI';
+        return array($path . '/processed-ui-screenshots/', $path . '/expected-ui-screenshots/');
+    }
+    
+    public static function getProxyUrl()
+    {
+        return Test_Piwik_BaseFixture::getRootUrl() . 'tests/PHPUnit/proxy/index.php';
+    }
+}
+
+Test_Piwik_Integration_UIIntegrationTest::$fixture = new Test_Piwik_Fixture_ManySitesImportedLogsWithXssAttempts();
+
diff --git a/tests/PHPUnit/phpunit.xml.dist b/tests/PHPUnit/phpunit.xml.dist
index 622757f3b0..a88ad6f47c 100644
--- a/tests/PHPUnit/phpunit.xml.dist
+++ b/tests/PHPUnit/phpunit.xml.dist
@@ -39,6 +39,7 @@
   <testsuite name="IntegrationTests">
       <directory>./Integration</directory>
   </testsuite>
+  <!-- NOTE: UI Tests are not included here since they significantly add to the test run time. -->
 </testsuites>
 
 <logging>
diff --git a/tests/PHPUnit/populate-expected-screenshots.sh b/tests/PHPUnit/populate-expected-screenshots.sh
new file mode 100755
index 0000000000..22a8f5bdf0
--- /dev/null
+++ b/tests/PHPUnit/populate-expected-screenshots.sh
@@ -0,0 +1,38 @@
+#!/bin/bash
+
+current_branch=$(git rev-parse --abbrev-ref HEAD)
+
+# checkout branch and missing files/directories/links if needed
+if ! git checkout $1; then
+    echo "failed to checkout branch, aborting"
+    exit 1
+fi
+
+test_files="UI/UIIntegrationTest.php
+Fixtures/ManySitesImportedLogsWithXssAttempts.php
+proxy/libs
+proxy/plugins
+proxy/tests"
+
+for file in $test_files
+do
+    if [ ! -e "$file" ]; then
+        git checkout master "$file"
+    fi
+done
+
+# run UI tests
+phpunit UI &> /dev/null
+
+# copy processed png
+if [ ! -d "UI/expected-ui-screenshots" ]; then
+    mkdir UI/expected-ui-screenshots
+fi
+
+cp UI/processed-ui-screenshots/* UI/expected-ui-screenshots
+
+# go back to original branch
+rm populate-expected-screenshots.sh
+git reset --hard
+git checkout $current_branch
+
diff --git a/tests/PHPUnit/proxy/archive.php b/tests/PHPUnit/proxy/archive.php
index 61cc1c8b11..16693cabe0 100644
--- a/tests/PHPUnit/proxy/archive.php
+++ b/tests/PHPUnit/proxy/archive.php
@@ -3,5 +3,6 @@
 // include archive.php, and let 'er rip
 $GLOBALS['PIWIK_CONFIG_TEST_ENVIRONMENT'] = true;
 $GLOBALS['PIWIK_ACCESS_IS_SUPERUSER'] = true;
+$GLOBALS['PIWIK_ACCESS_SUPERUSER_LOGIN'] = 'superUserLogin';
 require realpath(dirname(__FILE__)) . "/../../../misc/cron/archive.php";
 
diff --git a/tests/PHPUnit/proxy/index.php b/tests/PHPUnit/proxy/index.php
index 86c2935ea4..859ea8fb04 100644
--- a/tests/PHPUnit/proxy/index.php
+++ b/tests/PHPUnit/proxy/index.php
@@ -1,13 +1,12 @@
 <?php
 /**
  * Proxy to index.php, but will use the Test DB
- * Currently only used only for the test: tests/PHPUnit/Integration/ImportLogsTest.php
- * since other integration tests do not call index.php via http but use the Piwik_API_Request object
- *
+ * Used by tests/PHPUnit/Integration/ImportLogsTest.php and tests/PHPUnit/Integration/UITest.php
  */
 
 $GLOBALS['PIWIK_CONFIG_TEST_ENVIRONMENT'] = true;
 $GLOBALS['PIWIK_ACCESS_IS_SUPERUSER'] = true;
+$GLOBALS['PIWIK_ACCESS_SUPERUSER_LOGIN'] = 'superUserLogin';
 
 // Wrapping the request inside ob_start() calls to ensure that the Test
 // calling us waits for the full request to process before unblocking
@@ -18,6 +17,9 @@ define('PIWIK_USER_PATH', PIWIK_INCLUDE_PATH);
 
 require_once PIWIK_INCLUDE_PATH . '/libs/upgradephp/upgrade.php';
 require_once PIWIK_INCLUDE_PATH . '/core/Loader.php';
+require_once PIWIK_INCLUDE_PATH . '/core/EventDispatcher.php';
+
+Piwik_Visualization_Cloud::$debugDisableShuffle = true;
 
 Piwik_Tracker::setTestEnvironment();
 Piwik_Tracker_Cache::deleteTrackerCache();
diff --git a/tests/PHPUnit/proxy/libs b/tests/PHPUnit/proxy/libs
new file mode 120000
index 0000000000..d63817ad19
--- /dev/null
+++ b/tests/PHPUnit/proxy/libs
@@ -0,0 +1 @@
+../../../libs
\ No newline at end of file
diff --git a/tests/PHPUnit/proxy/plugins b/tests/PHPUnit/proxy/plugins
new file mode 120000
index 0000000000..842d50c3ae
--- /dev/null
+++ b/tests/PHPUnit/proxy/plugins
@@ -0,0 +1 @@
+../../../plugins
\ No newline at end of file
diff --git a/tests/PHPUnit/proxy/tests b/tests/PHPUnit/proxy/tests
new file mode 120000
index 0000000000..c25bddb6dd
--- /dev/null
+++ b/tests/PHPUnit/proxy/tests
@@ -0,0 +1 @@
+../..
\ No newline at end of file
diff --git a/tests/PHPUnit/travis.sh b/tests/PHPUnit/travis.sh
index 6e25fcd7b9..29f63754e6 100755
--- a/tests/PHPUnit/travis.sh
+++ b/tests/PHPUnit/travis.sh
@@ -4,5 +4,10 @@ if [ -n "$TEST_SUITE" ]
 then
 	phpunit --configuration phpunit.xml --testsuite $TEST_SUITE --colors
 else
-	phpunit --configuration phpunit.xml --coverage-text --colors
+  if [ -n "$TEST_DIR" ]
+  then
+    phpunit --colors $TEST_DIR
+  else
+	  phpunit --configuration phpunit.xml --coverage-text --colors
+  fi
 fi
diff --git a/tests/README.md b/tests/README.md
index 0ed3730dbc..5fe76c5e63 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -131,6 +131,38 @@ work altered the expected images. The standard procedure described in the INTEGR
  - set up the vagrant piwik vm (which is used by the integration server) or
  - retrieve the files from the integration server.
 
+## UI Tests
+
+In the UI subdirectory are tests for Piwik's UI. Piwik's UI tests work by taking a screenshot
+of a URL and comparing it with an expected screenshot. If the screenshots do not match, there
+is a bug somewhere.
+
+**Requirements:**
+
+In order to run UI tests, you need to have CutyCapt installed on your machine. If you're
+using Ubuntu, you can install it with the following command:
+
+  $ sudo apt-get install cutycapt
+  
+If you're on a server without the X window system, you can still run UI tests, but you
+will need xvfb to do so. On Ubuntu, you can install xvfb with:
+
+  $ sudo apt-get install xvfb
+
+**Running Tests**
+
+Unfortunately, since different machines result in different screenshots, there is no expected
+set of screenshots. You must generate these yourself using an older commit. To do this, first
+find a commit where you know the UI works. Then run:
+
+  $ cd PHPUnit
+  $ ./populate-expected-screenshots.sh $commit_hash
+
+Once you have expected screenshots, you can test the UI by running:
+
+  $ cd PHPUnit
+  $ phpunit UI
+
 ## Continuous Integration
 
 We run a Jenkins server for continuous integration. It automatically downloads the latest version of the Piwik code
diff --git a/tests/travis/php.ini b/tests/travis/php.ini
index 9a7dc53a1d..00300fcfff 100644
--- a/tests/travis/php.ini
+++ b/tests/travis/php.ini
@@ -1,2 +1 @@
 opcache.enable = 0
-
-- 
GitLab