From 0638164a01c4c023c6a31b7f1e7c3a6763f78f01 Mon Sep 17 00:00:00 2001
From: Chris Darke <chrisdarke42@gmail.com>
Date: Wed, 20 Apr 2016 14:44:54 -0600
Subject: [PATCH] Issue #2709543 Adding additional features to SauceLabs
 module.

---
 README.txt                            |  45 +++
 SauceLabsCoreTest.php.bak             |  27 ++
 includes/SaucelabsTest.php            | 665 ++++++++++++++++++++++++++++++++++
 includes/SaucelabsTestDB.php          |  58 +++
 includes/SaucelabsTestForms.php       | 147 ++++++++
 saucelabs.admin.inc                   | 395 ++++++++++++++------
 saucelabs.batch.inc                   |  68 ++--
 saucelabs.info                        |   8 +
 saucelabs.install                     |  65 ++--
 saucelabs.module                      |  63 +++-
 saucelabs.runner.inc                  |  23 --
 saucelabs_demo/TestDemo.php           |  37 ++
 saucelabs_demo/saucelabs_demo.info    |   6 +
 saucelabs_demo/saucelabs_demo.install |  59 +++
 saucelabs_demo/saucelabs_demo.module  |  63 ++++
 15 files changed, 1530 insertions(+), 199 deletions(-)
 create mode 100644 README.txt
 create mode 100644 SauceLabsCoreTest.php.bak
 create mode 100644 includes/SaucelabsTest.php
 create mode 100644 includes/SaucelabsTestDB.php
 create mode 100644 includes/SaucelabsTestForms.php
 delete mode 100644 saucelabs.runner.inc
 create mode 100644 saucelabs_demo/TestDemo.php
 create mode 100644 saucelabs_demo/saucelabs_demo.info
 create mode 100644 saucelabs_demo/saucelabs_demo.install
 create mode 100644 saucelabs_demo/saucelabs_demo.module

diff --git a/README.txt b/README.txt
new file mode 100644
index 0000000..95a27ad
--- /dev/null
+++ b/README.txt
@@ -0,0 +1,45 @@
+SauceLabs Drupal Monitoring integration.
+
+SauceLabs allows you to run tests (such as Selenium PHP tests in this case) via
+their servers.
+This module helps you integrate those tests with a front end, where you can
+acquire EndPoints for the tests, to call them via CRON, and also set up
+notifications or actions to happen based on the results.
+
+1. Get a SauceLabs account:
+Visit https://saucelabs.com and sign up if you haven't already
+
+2. Download Sausage for whatever environment the code will be running on:
+https://wiki.saucelabs.com/display/DOCS/Setting+Up+Sausage+for+OS+X+and+Linux
+https://saucedev.atlassian.net/wiki/display/DOCS/Setting+Up+Sausage+for+Windows
+
+In the case of Linux/OS X you would run:
+
+curl -s https://raw.githubusercontent.com/jlipps/sausage-bun/master/givememysausage.php | php
+
+(don't include Username and Key as this will be set up in the config)
+
+3. Place the vendor folder within a directory of your choosing (eg. private/saucelabs).
+
+4. Copy SauceLabsCoreTest.php.bak to this same folder (eg. private/saucelabs) and remove the '.bak'.
+
+5. View saucelabs_demo/TestDemo.php to see the general structure of tests:
+  i) All test files need to start with 'Test'.
+  ii) All test files need to extend SauceLabsCoreTest.
+  ii) All test files need to have a structured file docblock as follows:
+
+  /**
+    * @file
+    * {ClassName}
+    * {Name of Test}
+    * {Description of Test}
+  */
+
+6. Place test file in same location as SauceLabsCoreTest.php
+
+7. Install SauceLabs module and configure settings.
+
+8. You should see a list of any Test files on the dashboard. From here you can store your own
+test instances, and run tests straight away!
+
+9. To see how Message Stack, or any other use of completion hooks can be integrated, install saucelabs_demo.
diff --git a/SauceLabsCoreTest.php.bak b/SauceLabsCoreTest.php.bak
new file mode 100644
index 0000000..de737ce
--- /dev/null
+++ b/SauceLabsCoreTest.php.bak
@@ -0,0 +1,27 @@
+<?php
+
+/**
+ * @file
+ * SauceLabs Core Test
+ *
+ */
+
+define('SAUCE_PATH', getenv('SAUCE_PATH'));
+define('SAUCE_TMP_SESSION_STORAGE', getenv('SAUCE_TMP_SESSION_STORAGE'));
+
+require_once 'vendor/autoload.php';
+
+class SauceLabsCoreTest extends Sauce\Sausage\WebDriverTestCase
+{
+  public function setUp() {
+    $this->shareSession(true);
+    parent::setUp();
+    $this->setBrowserUrl(SAUCE_PATH);
+  }
+
+  public function prepareSession() {
+    parent::prepareSession();
+    file_put_contents(SAUCE_TMP_SESSION_STORAGE, $this->getSessionId() . PHP_EOL, FILE_APPEND);
+  }
+
+}
diff --git a/includes/SaucelabsTest.php b/includes/SaucelabsTest.php
new file mode 100644
index 0000000..cbd400e
--- /dev/null
+++ b/includes/SaucelabsTest.php
@@ -0,0 +1,665 @@
+<?php
+/**
+ * @file
+ * General methods for SauceLabs Tests.
+ */
+
+/**
+ * General methods for Tests for the SauceLabs module.
+ */
+class SaucelabsTest {
+
+  //@Todo: change these to protected
+  protected $test_id = NULL;
+  protected $name = '';
+  protected $description = '';
+  protected $tests = array();
+  protected $environment = '';
+  protected $path = '';
+  protected $report = TRUE;
+  protected $last_test = 0;
+  protected $last_duration = 0;
+  protected $last_state = FALSE;
+  protected $session_ids = array();
+
+  /**
+   * @return null
+   */
+  public function getTestId() {
+    return $this->test_id;
+  }
+
+  /**
+   * @param null $test_id
+   */
+  public function setTestId($test_id) {
+    $this->test_id = $test_id;
+  }
+
+  public function isStoredTest() {
+    if (is_numeric($this->getTestId())) {
+      return TRUE;
+    }
+    return FALSE;
+  }
+
+  /**
+   * @return string
+   */
+  public function getName() {
+    return $this->name;
+  }
+
+  /**
+   * @param string $name
+   */
+  public function setName($name) {
+    $this->name = $name;
+  }
+
+  /**
+   * @return string
+   */
+  public function getDescription() {
+    return $this->description;
+  }
+
+  /**
+   * @param string $description
+   */
+  public function setDescription($description) {
+    $this->description = $description;
+  }
+
+  /**
+   * @return array
+   */
+  public function getTests() {
+    return $this->tests;
+  }
+
+  public function getFullTestPaths() {
+    $location = variable_get('saucelabs_test_location', '');
+    $tests = $this->getTests();
+    foreach ($tests as &$test) {
+      $test = $location . '/' . $test;
+    }
+    return $tests;
+  }
+
+  /**
+   * @return string
+   */
+  public function getTestsSerialized() {
+    return serialize($this->tests);
+  }
+
+  /**
+   * @return string
+   */
+  public function getTestsFormatted() {
+    return implode(', ', $this->tests);
+  }
+
+  /**
+   * @param array $tests
+   */
+  public function setTests($tests) {
+    $tests = array_filter($tests);
+    $this->tests = $tests;
+  }
+
+  /**
+   * @param $tests_string
+   */
+  public function setTestsSerialized($tests_string) {
+    $this->setTests(unserialize($tests_string));
+  }
+
+  /**
+   * @return string
+   */
+  public function getEnvironment() {
+    return $this->environment;
+  }
+
+  /**
+   * @return string
+   */
+  public function getFullEnvironment() {
+    if (substr($this->environment, 0, 4) !== 'http') {
+      return 'http://' . $this->environment;
+    }
+    return $this->environment;
+  }
+
+  /**
+   * @param string $environment
+   */
+  public function setEnvironment($environment) {
+    $this->environment = $environment;
+  }
+
+  /**
+   * @return string
+   */
+  public function getPath() {
+    return $this->path;
+  }
+
+  /**
+   * @param string $path
+   */
+  public function setPath($path) {
+    $this->path = $path;
+  }
+
+  public function getFullPath() {
+    return $this->getFullEnvironment() . '/' . $this->getPath();
+  }
+
+  /**
+   * @return boolean
+   */
+  public function isReport() {
+    return $this->report;
+  }
+
+  /**
+   * @param boolean $report
+   */
+  public function setReport($report) {
+    $this->report = $report;
+  }
+
+  /**
+   * @return int
+   */
+  public function getLastTest() {
+    return $this->last_test;
+  }
+
+  /**
+   * @param int $last_test
+   */
+  public function setLastTest($last_test) {
+    $this->last_test = $last_test;
+  }
+
+  /**
+   * @return int
+   */
+  public function getLastDuration() {
+    return $this->last_duration;
+  }
+
+  /**
+   * @param int $last_duration
+   */
+  public function setLastDuration($last_duration) {
+    $this->last_duration = $last_duration;
+  }
+
+
+  /**
+   * @return boolean
+   */
+  public function isLastState() {
+    return $this->last_state;
+  }
+
+  /**
+   * Returning value for DB
+   * @return boolean
+   */
+  public function getLastState() {
+    return ($this->last_state) ? 1 : 0;
+  }
+
+  public function getStateText() {
+    return ($this->isLastState()) ? t('Passed') : t('Failed');
+  }
+
+  /**
+   * @param boolean $last_state
+   */
+  public function setLastState($last_state) {
+    $this->last_state = $last_state;
+  }
+
+  /**
+   * @return array
+   */
+  public function getSessionIds() {
+    return $this->session_ids;
+  }
+
+  public function getSessionIdsSerialized() {
+    return serialize($this->session_ids);
+  }
+
+  /**
+   * @param array $session_ids
+   */
+  public function setSessionIds($session_ids) {
+    $this->session_ids = $session_ids;
+  }
+
+  public function addSessionIdsFromFile($session_ids_string) {
+    if (strlen($session_ids_string) > 0) {
+      $session_ids = explode(PHP_EOL, $session_ids_string);
+      $session_ids = array_merge($this->getSessionIds(), $session_ids);
+      $this->setSessionIds($session_ids);
+    }
+  }
+
+  public function setSessionIdsFromFile($session_ids_string) {
+    if (strlen($session_ids_string) > 0) {
+      $session_ids = explode(PHP_EOL, $session_ids_string);
+      $this->setSessionIds($session_ids);
+    }
+  }
+
+  public function setSessionIdsSerialized($session_ids_string) {
+    $this->setSessionIds(unserialize($session_ids_string));
+  }
+
+  public function __construct($test = array(), $source = 'db') {
+    if (count($test)) {
+      switch ($source) {
+        case 'db':
+          $this->buildFromDBArray($test);
+          break;
+        case 'form':
+          $this->buildFromFormState($test);
+          break;
+      }
+
+    }
+  }
+
+  protected function buildFromDBArray($test_array) {
+    $this->setTestId($test_array['test_id']);
+    $this->setName($test_array['name']);
+    $this->setDescription($test_array['description']);
+    $this->setTestsSerialized($test_array['tests']);
+    $this->setEnvironment($test_array['environment']);
+    $this->setPath($test_array['path']);
+    $this->setReport($test_array['report']);
+    $this->setLastTest($test_array['last_test']);
+    $this->setLastDuration($test_array['last_duration']);
+    $this->setLastState($test_array['last_state']);
+    $this->setSessionIdsSerialized($test_array['session_ids']);
+  }
+
+  protected function buildFromFormState($test_array) {
+    $this->setName($test_array['name']);
+    $this->setDescription($test_array['description']);
+    $this->setTests($test_array['tests']);
+    $this->setEnvironment($test_array['environment']);
+    $this->setPath($test_array['path']);
+    $this->setReport($test_array['report']);
+  }
+
+  public function updateFromFormState($test) {
+    $this->buildFromFormState($test);
+  }
+
+  public function save() {
+    SaucelabsTestDB::merge($this);
+  }
+
+  public function getRecord() {
+    $record = array(
+      'name' => $this->getName(),
+      'description' => $this->getDescription(),
+      'environment' => $this->getEnvironment(),
+      'tests' => $this->getTestsSerialized(),
+      'path' => $this->getPath(),
+      'report' => $this->isReport(),
+      'last_test' => $this->getLastTest(),
+      'last_duration' => $this->getLastDuration(),
+      'last_state' => $this->getLastState(),
+      'session_ids' => $this->getSessionIdsSerialized(),
+    );
+    return $record;
+  }
+
+  public function getListingRecord() {
+    $last_state = $this->getStateText();
+    $last_duration = $this->getLastDuration();
+    if (($last_state === t('Failed')) && ($last_duration == 0)) {
+      $last_state = t('Never Run');
+      $last_duration = t('Never Run');
+    }
+    $name_options = array(
+      'attributes' => array(
+        'title' => $this->getDescription(),
+      ),
+    );
+    $record = array(
+      'name' => l($this->getName(), 'admin/saucelabs/tests/' . $this->getTestId(), $name_options),
+      'environment' => $this->getEnvironment(),
+      'path' => $this->getPath(),
+      'tests' => $this->getTestsFormatted(),
+      'last_state' => $last_state,
+      'last_duration' => $last_duration,
+    );
+    return $record;
+  }
+
+  public static function getListingHeader() {
+    return array(
+      'name' => t('Name'),
+      'environment' => t('Environment'),
+      'path' => t('Path'),
+      'tests' => t('Tests'),
+      'last_state' => t('Last State'),
+      'last_duration' => t('Last Duration (seconds)'),
+    );
+  }
+
+  public static function buildFromId($test_id) {
+    $test_array = SaucelabsTestDB::query($test_id);
+    $test = new SaucelabsTest($test_array);
+    return $test;
+  }
+
+  public static function finishedBatchRun($test_id) {
+    $test = self::buildFromId($test_id);
+    $test->storeTempSessionData();
+    $test->setDataFromSaucelabs();
+    $test->save();
+    $test->notifyTestRun();
+  }
+
+  protected function notifyTestRun() {
+    $this->notifyWatchdog();
+    module_invoke_all('saucelabs_test_complete', $this);
+  }
+
+  protected function notifyWatchdog() {
+    if ($this->isReport()) {
+      $name = $this->getName();
+      $status = $this->getStateText();
+      watchdog('SauceLabs', t('Test titled %test_name has %status', array(
+        '%test_name' => $name,
+        '%status' => $status
+      )));
+    }
+  }
+
+  /**
+   * Stores Temporary Session data, currently in files in DRUPAL TEMP, into the
+   * object, and later into db.
+   * Due to the fact that multiple files might be generated (if there is more
+   * than one test), we wipe the session ids at the start and use
+   * 'addSessionIdsFromFile' so as to avoid just storing the last file read.
+   */
+  protected function storeTempSessionData() {
+    $this->setSessionIds(array());
+    foreach ($this->getFullTestPaths() as $testFile) {
+      $temp_storage = $this->getTempFilePath($testFile);
+      try {
+        $handle = fopen($temp_storage, "r");
+        if (!$handle) {
+          watchdog('SauceLabs', 'Temporary Storage not available.');
+        }
+        else {
+          $contents = fread($handle, filesize($temp_storage));
+          fclose($handle);
+          $this->addSessionIdsFromFile($contents);
+        }
+      } catch (Exception $e) {
+        watchdog('SauceLabs', $e->getMessage());
+      }
+    }
+  }
+
+  protected function setDataFromSaucelabs() {
+    $session_ids = $this->getSessionIds();
+    $provisional_state = TRUE;
+    $provisional_duration = 0;
+    $provisional_start = 0;
+    if (count($session_ids) > 0) {
+      foreach ($session_ids as $session_id) {
+        if (strlen($session_id)) {
+          $s = self::getSaucelabsApi();
+          if (isset($s)) {
+            $job = $s->getJob($session_id);
+            if (!$job['passed']) {
+              $provisional_state = FALSE;
+            }
+            if ($job['creation_time'] < $job['end_time']) {
+              $provisional_duration += ($job['end_time'] - $job['creation_time']);
+            }
+            else {
+              if ($job['start_time'] < $job['end_time']) {
+                $provisional_duration += ($job['end_time'] - $job['start_time']);
+              }
+            }
+            if (($provisional_start === 0) || ($job['creation_time'] < $provisional_start)) {
+              $provisional_start = $job['creation_time'];
+            }
+          }
+          else {
+            drupal_set_message(t('Please enter your SauceLab details, including location of code, in the settings page, to get Test data back.'));
+          }
+        }
+      }
+      $this->setLastState($provisional_state);
+      $this->setLastTest($provisional_start);
+      $this->setLastDuration($provisional_duration);
+    }
+  }
+
+  public static function getSaucelabsApi() {
+    $path_to_vendor = variable_get('saucelabs_vendor_location', NULL);
+    if (isset($path_to_vendor)) {
+      require_once($path_to_vendor . '/sauce/sausage/src/Sauce/Sausage/SauceAPI.php');
+      require_once($path_to_vendor . '/sauce/sausage/src/Sauce/Sausage/SauceMethods.php');
+      $username = variable_get('saucelabs_username', '');
+      $api_key = variable_get('saucelabs_api', '');
+      return new Sauce\Sausage\SauceAPI($username, $api_key);
+    }
+    return NULL;
+  }
+
+  public function getStoredSessionsEmbedJS() {
+    $session_ids = $this->getSessionIds();
+    $jsPaths = array();
+    $username = variable_get('saucelabs_username', '');
+    $api_key = variable_get('saucelabs_api', '');
+    foreach ($session_ids as $session_id) {
+      $auth = hash_hmac('md5', $session_id, $username . ':' . $api_key);
+      $jsPaths[] = 'https://saucelabs.com/job-embed/' . $session_id . '.js?auth=' . $auth;
+    }
+    return $jsPaths;
+  }
+
+  protected static function getTestFiles() {
+    $location = variable_get('saucelabs_test_location', FALSE);
+    $options = array();
+    // Find the tests files. Only test files should exist in this directory.
+    if ($files = file_scan_directory(DRUPAL_ROOT . base_path() . $location, "/^Test.*\.php$/", array('recurse' => FALSE), 1)) {
+      foreach ($files as $file) {
+        $options[$file->uri] = $file->filename;
+      }
+    }
+    return $options;
+  }
+
+  public static function getTestFilesTableSelect() {
+    $files = array();
+    foreach (self::getTestFiles() as $file_uri => $file_name) {
+      $file_data_array = self::getTestFileDocBlock($file_uri);
+      $file_data_array['filename'] = $file_name;
+      $files[$file_name] = $file_data_array;
+    }
+    return $files;
+  }
+
+  public static function getTestFilesHeaders() {
+    return $header = array(
+      'filename' => t('Filename'),
+      'title' => t('Title'),
+      'description' => t('Description'),
+    );
+  }
+
+  public static function validate($values) {
+    $tests = array();
+    foreach ($values['tests'] as $test_key => $test_file) {
+      if ($test_key === $test_file) {
+        $tests[] = $test_file;
+      }
+    }
+    if (count($tests) === 0) {
+      form_set_error('tests', 'Please choose at least one test from the test listing');
+    }
+  }
+
+  /**
+   * Store values from Form submission, data from $form_state['values']
+   * which is passed in as an array.
+   *
+   * @param $values
+   */
+  public static function store($values) {
+    if (isset($values['test_id']) && is_numeric($values['test_id'])) {
+      $test = self::buildFromId($values['test_id']);
+    }
+    else {
+      $test = new SaucelabsTest();
+    }
+    $test->updateFromFormState($values);
+    $test->save();
+  }
+
+  public static function getAll() {
+    $results = SaucelabsTestDB::listAll();
+    $objectArray = array();
+    foreach ($results as $row) {
+      $objectArray[] = new SaucelabsTest($row);
+    }
+    return $objectArray;
+  }
+
+
+  /**
+   * Get the first doc block from a test file, parse the content and return
+   *
+   * @param $file_uri
+   * @return array
+   */
+  protected static function getTestFileDocBlock($file_uri) {
+    $docComments = array_filter(
+      token_get_all(file_get_contents($file_uri)), function ($entry) {
+      return $entry[0] == T_DOC_COMMENT;
+    }
+    );
+    $fileDocComment = array_shift($docComments);
+    $docCommentContent = array_slice(explode(PHP_EOL, $fileDocComment[1]), 2, 3);
+    $commentArray = array('title' => '', 'description' => '');
+    $commentArrayMap = array(
+      0 => 'className',
+      1 => 'title',
+      2 => 'description'
+    );
+    if (count($docCommentContent) > 0) {
+      foreach ($docCommentContent as $i => $comment) {
+        // Remove the ' * ' from the start of each comment line
+        $pattern = '/^([\s]*\*[\s]*)/';
+        $replacement = '';
+        $comment = preg_replace($pattern, $replacement, $comment);
+        $commentArray[$commentArrayMap[$i]] = $comment;
+      }
+    }
+    return $commentArray;
+  }
+
+  protected function getEnvironmentVars() {
+    $username = variable_get('saucelabs_username', '');
+    $api_key = variable_get('saucelabs_api', '');
+    $path = $this->getFullPath();
+    $env_vars = array();
+    $php_path = variable_get('saucelabs_php_binary_location', '');
+    if (strlen($php_path)) {
+      $env_vars[] = 'export PATH=' . $php_path . ':$PATH';
+    }
+    $env_vars[] = 'export SAUCE_USERNAME=' . $username;
+    $env_vars[] = 'export SAUCE_ACCESS_KEY=' . $api_key;
+    $env_vars[] = 'export SAUCE_PATH=' . $path;
+    return $env_vars;
+  }
+
+  protected static function sanitizeFilePath($file_path) {
+    if (!file_exists($file_path)) {
+      $location = variable_get('saucelabs_test_location', FALSE);
+      $file_path = $location . '/' . $file_path;
+      if (file_exists($file_path)) {
+        return $file_path;
+      }
+      return NULL;
+    }
+    return $file_path;
+  }
+
+  public static function getTempFilePath($test_file) {
+    $test_file = self::sanitizeFilePath($test_file);
+    if (isset($test_file)) {
+      $class_data = self::getTestFileDocBlock($test_file);
+      if (isset($class_data['className'])) {
+        $class_name = $class_data['className'];
+      }
+      else {
+        $path_parts = explode('/', $test_file);
+        $class_name = end($path_parts);
+      }
+      return file_directory_temp() . '/' . $class_name . '.sessions';
+    }
+    return FALSE;
+  }
+
+  public function run($redirect = FALSE) {
+    try {
+      $path_to_vendor = variable_get('saucelabs_vendor_location', '');
+      $threads = variable_get('saucelabs_parallel_threads', 1);
+
+      $env_vars = $this->getEnvironmentVars();
+      $jobs = array();
+      foreach ($this->getFullTestPaths() as $test_file) {
+        $temp_storage = self::getTempFilePath($test_file);
+        $env_vars[] = 'export SAUCE_TMP_SESSION_STORAGE=' . $temp_storage;
+        fopen($temp_storage, 'w');
+        $prefix = implode(';', $env_vars);
+        $exec_string = $path_to_vendor . '/bin/paratest -p ' . $threads . ' -f --phpunit=' . $path_to_vendor . '/bin/phpunit ' . $test_file . ' 2>&1';
+        $jobs[] = $prefix . ';' . $exec_string;
+      }
+
+      if (count($jobs)) {
+        // Define the Batch.
+        // @see saucelabs.batch.inc for batch functions.
+        $batch = array(
+          'operations' => array(
+            array('saucelabs_process_update', array($jobs, $this->getTestId())),
+          ),
+          'finished' => 'saucelabs_run_tests_finished',
+          'title' => t('SauceLabs Batch'),
+          'init_message' => t('Batch is starting.'),
+          'progress_message' => t('Processed @current out of @total.'),
+          'error_message' => t('SauceLabs Batch has encountered an error.'),
+          'file' => drupal_get_path('module', 'saucelabs') . '/saucelabs.batch.inc',
+        );
+        batch_set($batch);
+        if ($redirect) {
+          batch_process('admin/saucelabs/tests/' . $this->getTestId());
+        }
+      }
+    } catch (Exception $e) {
+      watchdog('SauceLabs', $e->getMessage(), WATCHDOG_ERROR);
+    }
+
+  }
+
+}
diff --git a/includes/SaucelabsTestDB.php b/includes/SaucelabsTestDB.php
new file mode 100644
index 0000000..2be94d6
--- /dev/null
+++ b/includes/SaucelabsTestDB.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * @file
+ * Database methods for SauceLabs Tests.
+ */
+
+/**
+ * Database methods for Tests for the SauceLabs module.
+ */
+class SaucelabsTestDB {
+  /**
+   * Updates or inserts a new record.
+   *
+   * @param $saucelabsTest SaucelabsTest object.
+   */
+  public static function merge($saucelabsTest) {
+    if ($saucelabsTest->isStoredTest()) {
+      db_merge('saucelabs')
+        ->key(array(
+          'test_id' => $saucelabsTest->getTestId(),
+        ))
+        ->fields($saucelabsTest->getRecord())
+        ->execute();
+    }
+    else {
+      db_insert('saucelabs')
+        ->fields($saucelabsTest->getRecord())
+        ->execute();
+    }
+  }
+
+  public static function delete($test_id) {
+    db_delete('saucelabs')
+      ->condition('test_id', $test_id)
+      ->execute();
+  }
+
+  /**
+   * Get the SauceLabs Tests from the database.
+   *
+   * @param $test_id integer test_id for the Saucelab Test.
+   * @return int|boolean An integer representing the language fallback setting
+   *  or FALSE if the record does not exist.
+   */
+  public static function query($test_id) {
+    $select = db_select('saucelabs');
+    $select->fields('saucelabs');
+    $select->condition('test_id', $test_id);
+    return $select->execute()->fetchAssoc();
+  }
+
+  public static function listAll() {
+    $select = db_select('saucelabs');
+    $select->fields('saucelabs');
+    return $select->execute()->fetchAllAssoc('test_id', PDO::FETCH_ASSOC);
+  }
+
+}
diff --git a/includes/SaucelabsTestForms.php b/includes/SaucelabsTestForms.php
new file mode 100644
index 0000000..0715caa
--- /dev/null
+++ b/includes/SaucelabsTestForms.php
@@ -0,0 +1,147 @@
+<?php
+/**
+ * @file
+ * Form actions for SauceLabs Tests.
+ */
+
+/**
+ * Form actions for Tests for the SauceLabs module.
+ */
+class SaucelabsTestForms {
+
+  static function adminForm(&$form) {
+    $form['saucelabs_settings'] = array(
+      '#type' => 'container',
+      '#weight' => 0,
+      '#name' => 'SauceLab Settings',
+    );
+    $form['system_settings'] = array(
+      '#type' => 'container',
+      '#weight' => 1,
+      '#name' => 'System Settings',
+    );
+    $form['system_settings']['saucelabs_vendor_location'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Vendor Location'),
+      '#description' => t('Directory in the Drupal root where vendor directory lives. eg: private/saucelabs/vendor'),
+      '#default_value' => variable_get('saucelabs_vendor_location', ''),
+      '#required' => TRUE,
+    );
+    $form['system_settings']['saucelabs_test_location'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Test Location'),
+      '#description' => t('Directory in the Drupal root where your test files live.'),
+      '#default_value' => variable_get('saucelabs_test_location', ''),
+      '#required' => TRUE,
+    );
+    $form['system_settings']['saucelabs_test_environments'] = array(
+      '#type' => 'textarea',
+      '#title' => t('Test Environments'),
+      '#description' => t('The environments available to run tests on (Prod, Dev, etc). Format as [baseurl]|[name]. eg: http://mytestsite.com|Production'),
+      '#default_value' => variable_get('saucelabs_test_environments', ''),
+      '#required' => TRUE,
+    );
+    $form['system_settings']['saucelabs_parallel_threads'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Number of Parallel threads'),
+      '#description' => t('The number of concurrent threads to run tests on.'),
+      '#default_value' => variable_get('saucelabs_parallel_threads', 1),
+      '#required' => TRUE,
+    );
+    $form['system_settings']['saucelabs_php_binary_location'] = array(
+      '#type' => 'textfield',
+      '#title' => t('PHP 5.6 Binary Location'),
+      '#description' => t('If you are overriding your default PHP to get PHP 5.6, you may need to include the PHP path here, for the shell_exec to find PHP 5.6. Eg. /Applications/MAMP/bin/php/php5.6.10/bin'),
+      '#default_value' => variable_get('saucelabs_php_binary_location', ''),
+      '#required' => FALSE,
+    );
+    $form['saucelabs_settings']['saucelabs_username'] = array(
+      '#type' => 'textfield',
+      '#title' => t('SauceLabs Username'),
+      '#description' => t('Your SauceLabs account username.'),
+      '#default_value' => variable_get('saucelabs_username', ''),
+      '#required' => TRUE,
+    );
+    $form['saucelabs_settings']['saucelabs_api'] = array(
+      '#type' => 'textfield',
+      '#title' => t('SauceLabs Access Key'),
+      '#description' => t('Your SauceLabs account access key.'),
+      '#default_value' => variable_get('saucelabs_api', ''),
+      '#required' => TRUE,
+    );
+  }
+
+  static function testForm(&$form, $test) {
+    $form['name'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Test Name'),
+      '#description' => t('Administrative name for the tests being run.'),
+      '#default_value' => $test->getName(),
+      '#required' => TRUE,
+    );
+    $form['description'] = array(
+      '#type' => 'textarea',
+      '#title' => t('Description'),
+      '#description' => t('Description of the tests being run.'),
+      '#default_value' => $test->getDescription(),
+      '#required' => TRUE,
+    );
+    $selected_tests = array_combine($test->getTests(), array_fill(0, count($test->getTests()), TRUE));
+    $form['tests'] = array(
+      '#type' => 'tableselect',
+      '#prefix' => t('<label>Available Tests</label>'),
+      '#options' => SaucelabsTest::getTestFilesTableSelect(),
+      '#header' => SaucelabsTest::getTestFilesHeaders(),
+      '#empty' => t('No test files found.'),
+      '#default_value' => $selected_tests,
+    );
+    $env_options = variable_get('saucelabs_test_environments', 'local|local');
+    //use the List Module to convert the list of environments into an array
+    $env_options_array = list_extract_allowed_values($env_options, 'list_text', FALSE);
+    $form['environment'] = array(
+      '#type' => 'select',
+      '#title' => t('Environment'),
+      '#options' => $env_options_array,
+      '#description' => t('Choose which environment you wish to run this test on. Environments are configured in the Settings tab.'),
+      '#required' => TRUE,
+      '#default_value' => $test->getEnvironment(),
+    );
+    $form['path'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Path'),
+      '#description' => t('The path within the environment to run the test on.'),
+      '#default_value' => $test->getPath(),
+    );
+    $form['report'] = array(
+      '#type' => 'checkbox',
+      '#title' => t('Report to Watchdog'),
+      '#description' => t('Do you wish to have this test report each outcome to Watchdog?'),
+      '#default_value' => $test->isReport(),
+    );
+    $form['test_id'] = array(
+      '#type' => 'hidden',
+      '#value' => $test->getTestId(),
+    );
+    $submit_text = t('Create Test');
+    if (is_numeric($test->getTestId())) {
+      $submit_text = t('Update Test');
+    }
+    $form['submit'] = array(
+      '#type' => 'submit',
+      '#value' => $submit_text,
+    );
+
+  }
+
+  static function testFormValidate($form, $form_state) {
+    $values = $form_state['values'];
+    SaucelabsTest::validate($values);
+  }
+
+  static function testFormSubmit($form, &$form_state) {
+    $values = $form_state['values'];
+    SaucelabsTest::store($values);
+    $form_state['redirect'] = 'admin/saucelabs/dashboard';
+  }
+
+}
diff --git a/saucelabs.admin.inc b/saucelabs.admin.inc
index 49cf465..09d87ca 100644
--- a/saucelabs.admin.inc
+++ b/saucelabs.admin.inc
@@ -2,79 +2,259 @@
 
 /**
  * @file
- * Menu Callback definitions for the saucelabs module.
+ * Menu Callback definitions for the SauceLabs module.
  */
 
 /**
- * Saucelabs Settings form.
+ * Test Creation form.
+ *
+ * @param $form
+ * @param $form_state
+ * @param $test
+ * @return mixed
  */
-function saucelabs_settings_form($form, &$form_state) {
-  $form['saucelabs_phpunit_location'] = array(
-    '#type' => 'textfield',
-    '#title' => t('PHPUnit Location'),
-    '#description' => t('Directory in the Drupal root where PHPUnit lives.'),
-    '#default_value' => variable_get('saucelabs_phpunit_location', 'private/saucelabs/vendor/phpunit/phpunit/phpunit'),
-    '#required' => TRUE,
-  );
-  $form['saucelabs_test_location'] = array(
-    '#type' => 'textfield',
-    '#title' => t('Test Location'),
-    '#description' => t('Directory in the Drupal root where your test files live.'),
-    '#default_value' => variable_get('saucelabs_test_location', ''),
-    '#required' => TRUE,
-  );
-  $form['saucelabs_username'] = array(
-    '#type' => 'textfield',
-    '#title' => t('SauceLabs Username'),
-    '#description' => t('Your Saucelabs account username.'),
-    '#default_value' => variable_get('saucelabs_username', ''),
-    '#required' => TRUE,
-  );
-  $form['saucelabs_api'] = array(
-    '#type' => 'textfield',
-    '#title' => t('SauceLabs Access Key'),
-    '#description' => t('Your Saucelabs account access key.'),
-    '#default_value' => variable_get('saucelabs_api', ''),
-    '#required' => TRUE,
-  );
+function saucelabs_edit_test_form($form, &$form_state, $test = NULL) {
+  if ($test === NULL) {
+    $test = new SaucelabsTest();
+  }
+  SaucelabsTestForms::testForm($form, $test);
+  return $form;
+}
+
+/**
+ * Test Creation validation
+ *
+ * @param $form
+ * @param $form_state
+ * @return mixed
+ */
+function saucelabs_edit_test_form_validate($form, &$form_state) {
+  SaucelabsTestForms::testFormValidate($form, $form_state);
+}
+
+/**
+ * Test Creation submission
+ *
+ * @param $form
+ * @param $form_state
+ * @return mixed
+ */
+function saucelabs_edit_test_form_submit($form, &$form_state) {
+  SaucelabsTestForms::testFormSubmit($form, $form_state);
+}
 
+/**
+ * SauceLabs Settings form.
+ *
+ * @param $form
+ * @param $form_state
+ */
+function saucelabs_settings_form($form, &$form_state) {
+  SaucelabsTestForms::adminForm($form);
   return system_settings_form($form);
 }
 
 /**
- * Saucelabs Dashboards.
+ * SauceLabs view Test information
+ *
+ * @param $form
+ * @param $form_state
+ * @param null $test
+ * @return array
+ */
+function saucelabs_view_test($form, &$form_state, $test = NULL) {
+  if (isset($test)) {
+    if (isset($form_state['storage']) && ($form_state['storage']['delete'])) {
+
+      $delete_confirm = t('Sure you want to delete %test_name?', array('%test_name' => $test->getName()));
+      $form['intro'] = array(
+        '#markup' => theme('html_tag', array(
+          'element' => array(
+            '#tag' => 'p',
+            '#value' => $delete_confirm,
+          ),
+        ))
+      );
+      $form['test_id'] = array(
+        '#type' => 'hidden',
+        '#value' => $test->getTestId(),
+      );
+      $form['#submit'][] = 'saucelabs_test_delete_submit';
+      return confirm_form($form, $question = "Confirm deletion of Test", 'admin/saucelabs/tests/' . $test->getTestId() . '/view');
+    }
+
+    $form = array();
+    $prefix = theme('html_tag', array(
+      'element' => array(
+        '#tag' => 'h2',
+        '#value' => $test->getName(),
+      ),
+    ));
+    $prefix .= theme('html_tag', array(
+      'element' => array(
+        '#tag' => 'p',
+        '#value' => $test->getDescription(),
+      ),
+    ));
+    $current_details = array();
+    $current_details[] = t('Runs: %tests', array('%tests' => implode(', ', $test->getTests())));
+    $current_details[] = t('On Path: %path', array('%path' => $test->getFullPath()));
+    $current_details[] = ($test->isReport()) ? t('Reporting to Watchdog') : t('Not reporting to Watchdog');
+    $current_details[] = t('Direct Path for running test via Cron: admin/saucelabs/tests/%saucelabs_test_id/run', array('%saucelabs_test_id' => $test->getTestId()));
+    $prefix .= theme('html_tag', array(
+      'element' => array(
+        '#tag' => 'p',
+        '#value' => implode('</br>', $current_details),
+      ),
+    ));
+
+    $last_test_details[] = t('Last Test Details:');
+    $last_status = $test->getStateText();
+    $status_colour = ($test->isLastState()) ? 'green' : 'red';
+    $last_test_details[] = theme('html_tag', array(
+      'element' => array(
+        '#tag' => 'span',
+        '#value' => $last_status,
+        '#attributes' => array(
+          'style' => 'color:' . $status_colour,
+        ),
+      ),
+    ));
+    $last_test_details[] = t('Started on: %date', array('%date' => date('d M Y H:i', $test->getLastTest())));
+    $last_test_details[] = t('Duration: %duration seconds', array('%duration' => $test->getLastDuration()));
+    $prefix .= theme('html_tag', array(
+      'element' => array(
+        '#tag' => 'p',
+        '#value' => implode('</br>', $last_test_details),
+      ),
+    ));
+    $form['#prefix'] = $prefix;
+    $form['test_id'] = array(
+      '#type' => 'hidden',
+      '#value' => $test->getTestId(),
+    );
+    $form['submit'] = array(
+      '#type' => 'submit',
+      '#value' => t('Run Test'),
+    );
+    $form['delete'] = array(
+      '#type' => 'submit',
+      '#value' => t('Delete Test'),
+      '#submit' => array('saucelabs_test_delete_submit'),
+    );
+    $suffix = '';
+    foreach ($test->getStoredSessionsEmbedJS() as $js_path) {
+      $suffix .= '<script src="' . $js_path . '"></script>';
+    }
+    $form['#suffix'] = $suffix;
+    return $form;
+  }
+  return NULL;
+}
+
+/**
+ * SauceLabs submit test (run test)
+ *
+ * @param $form
+ * @param $form_state
+ */
+function saucelabs_view_test_submit($form, &$form_state) {
+  $test = SaucelabsTest::buildFromId($form_state['values']['test_id']);
+  $test->run();
+}
+
+function saucelabs_test_delete_submit($form, &$form_state) {
+  if (!isset($form_state['storage']['delete'])) {
+    $form_state['storage']['delete'] = TRUE;
+    $form_state['rebuild'] = TRUE;
+  }
+  else {
+    if (isset($form_state['values']['test_id'])) {
+      $test = SaucelabsTest::buildFromId($form_state['values']['test_id']);
+      $test_name = $test->getName();
+      SaucelabsTestDB::delete($test->getTestId());
+      drupal_set_message(t('Test %test_name has been removed from the system', array('%test_name' => $test_name)));
+      drupal_goto('admin/saucelabs');
+    }
+  }
+}
+
+/**
+ * SauceLabs direct Run Test (from URL)
+ *
+ * @param null $test
+ */
+function saucelabs_run_test($test = NULL) {
+  if (isset($test)) {
+    $test->run(TRUE);
+  }
+}
+
+/**
+ * SauceLabs Dashboards.
  */
 function saucelabs_dashboard() {
-  $output = '';
-  $tests = array();
+  $s = SaucelabsTest::getSaucelabsApi();
+  $profile = '';
+  if (isset($s)) {
+    $user_details = $s->getAccountDetails();
+    $profile = '<h2>SauceLabs Account Details</h2>';
+    $profile .= '<p>Username: ' . $user_details['username'] . '</p>';
+    $profile .= '<p>Test Minutes remaining: ' . $user_details['minutes'] . '</p>';
+  }
+  else {
+    drupal_set_message(t('Please enter your SauceLab details, including location of code, in the settings page'));
+  }
+
+  $tests = SaucelabsTest::getAll();
+  $display_rows = array();
+  $display_header = SaucelabsTest::getListingHeader();
+  // Add edit and run links
+  $display_header['edit'] = 'Edit';
+  $display_header['view'] = 'View';
+
+  foreach ($tests as $test) {
+    $links = array(
+      l(t('Edit'), 'admin/saucelabs/tests/' . $test->getTestId() . '/edit'),
+      l(t('View'), 'admin/saucelabs/tests/' . $test->getTestId())
+    );
+    $display_rows[] = array_merge($test->getListingRecord(), $links);
+  }
+
+  $output = theme('html_tag', array(
+    'element' => array(
+      '#tag' => 'h2',
+      '#value' => 'Configured Tests',
+    ),
+  ));
+
+  $output .= theme('table', array(
+    'header' => $display_header,
+    'rows' => $display_rows
+  ));
 
   if (!$location = variable_get('saucelabs_test_location', FALSE)) {
     drupal_set_message("You need to set the locations for your tests.", "error");
   }
 
-  $header = array(
-    'filename' => t('Filename'),
-    'last_run' => t('Last Run'),
-    'last_playback' => t('Last Playback'),
-    'run' => '',
-  );
+  $form['#prefix'] = $profile . $output;
 
   $form['variables'] = array(
     '#type' => 'fieldset',
-    '#title' => t('Test Variables'),
+    '#title' => t('Manually Run Tests'),
   );
 
-  $form['variables']['baseurl'] = array(
+  $form['variables']['full_path'] = array(
     '#type' => 'textfield',
-    '#title' => t('Base URL'),
-    '#default_value' => isset($_SESSION['saucelabs_baseurl']) ? $_SESSION['saucelabs_baseurl'] : 'http://' . $_SERVER['HTTP_HOST'],
+    '#title' => t('Full Path'),
+    '#default_value' => isset($_SESSION['saucelabs_full_path']) ? $_SESSION['saucelabs_full_path'] : 'http://' . $_SERVER['HTTP_HOST'],
     '#required' => TRUE,
   );
 
   $form['tests'] = array(
     '#type' => 'tableselect',
-    '#header' => $header,
-    '#options' => _saucelabs_get_test_options($location),
+    '#header' => SaucelabsTest::getTestFilesHeaders(),
+    '#options' => SaucelabsTest::getTestFilesTableSelect(),
     '#empty' => t('No test files found.'),
     '#attributes' => array(
       'class' => array('saucelabs-test-list'),
@@ -94,77 +274,74 @@ function saucelabs_dashboard() {
 }
 
 /**
- * Submit Handler: Run the selected test via batch api.
+ * Prepare environment vars for Batch Job
+ *
+ * @param $path
+ * @return array
  */
-function _saucelabs_batch_run_tests($form, &$form_state) {
-  // Initialize array to store search and replace jobs.
-  $jobs = array();
-
-  // Loop through selected tests to create a jobs array.
-  if (isset($form_state['values'])) {
-    foreach ($form_state['values']['tests'] as $job) {
-      if ($job) {
-        $jobs[] = $job;
-      }
-    }
-  }
-
-  // Set the BaseURL in $_SESSION for continued use.
-  $_SESSION['saucelabs_baseurl'] = $form_state['values']['baseurl'];
-
-  // If we have jobs to perform.
-  if (count($jobs)) {
-    // Define the Batch.
-    // @see saucelabs.batch.inc for batch functions.
-    $batch = array(
-      'operations' => array(
-        array('saucelabs_process_update', array($jobs, $form_state['values']['baseurl'])),
-      ),
-      'finished' => 'saucelabs_run_tests_finished',
-      'title' => t('Saucelabs Batch'),
-      'init_message' => t('Batch is starting.'),
-      'progress_message' => t('Processed @current out of @total.'),
-      'error_message' => t('Saucelabs Batch has encountered an error.'),
-      'file' => drupal_get_path('module', 'saucelabs') . '/saucelabs.batch.inc',
-    );
-    batch_set($batch);
-  }
-  else {
-    drupal_set_message(t("No items selected"), 'error');
+function _saucelabs_batch_prep_environment($path) {
+  $username = variable_get('saucelabs_username', '');
+  $api_key = variable_get('saucelabs_api', '');
+  $env_vars = array();
+  $php_path = variable_get('saucelabs_php_binary_location', '');
+  if (strlen($php_path)) {
+    $env_vars[] = 'export PATH=' . $php_path . ':$PATH';
   }
+  $env_vars[] = 'export SAUCE_USERNAME=' . $username;
+  $env_vars[] = 'export SAUCE_ACCESS_KEY=' . $api_key;
+  $env_vars[] = 'export SAUCE_PATH=' . $path;
+  return $env_vars;
 }
 
 /**
- * Helper: Generate Options list of test.
- *
- * @param string $location
- *   Path to test files.
+ * Submit Handler: Run the selected test via batch api.
  *
- * @return array()
+ * @param $form
+ * @param $form_state
  */
-function _saucelabs_get_test_options($location) {
-  $options = array();
-  // Find the tests files. Only test files should exist in this directory.
-  if ($files = file_scan_directory(DRUPAL_ROOT . base_path() . $location, "/^Test.*\.php$/", array('recurse' => FALSE), 1)) {
-    foreach ($files as $file) {
-      $query = db_select('saucelabs_job', 'j');
-      $query->fields('j');
-      $query->condition('file', $file->uri);
-      $query->orderBy('initiated', 'DESC');
-      $query->range(0,1);
-      if ($result = $query->execute()->fetchAssoc()) {
-        $last_run = $result['initiated'];
-        $last_playback = $result['link'];
-      }
+function _saucelabs_batch_run_tests($form, &$form_state) {
+  // Initialize array to store search and replace jobs.
+  $jobs = array();
 
-      $options[$file->uri] = array(
-        'filename' => $file->filename,
-        'last_run' => isset($last_run) ? $last_run : '',
-        'last_playback' => isset($last_playback) ? $last_playback : '',
-        'run' => '<button class="btn btn-small btn-success">Run Test</button>',
+  // Check we have a valid path
+  if (isset($form_state['values']['full_path'])) {
+    $_SESSION['saucelabs_full_path'] = $form_state['values']['full_path'];
+    $path_to_vendor = variable_get('saucelabs_vendor_location', '');
+    $threads = variable_get('saucelabs_parallel_threads', 1);
+    $env_vars = _saucelabs_batch_prep_environment($form_state['values']['full_path']);
+    // Loop through selected tests to create a jobs array.
+    if (isset($form_state['values'])) {
+      foreach ($form_state['values']['tests'] as $test_file) {
+        if ($test_file) {
+          $temp_storage = SaucelabsTest::getTempFilePath($test_file);
+          $env_vars[] = 'export SAUCE_TMP_SESSION_STORAGE=' . $temp_storage;
+          fopen($temp_storage, 'w');
+          $prefix = implode(';', $env_vars);
+          $test_file = variable_get('saucelabs_test_location', FALSE) . '/' . $test_file;
+          $exec_string = $path_to_vendor . '/bin/paratest -p ' . $threads . ' -f --phpunit=' . $path_to_vendor . '/bin/phpunit ' . $test_file . ' 2>&1';
+          $jobs[] = $prefix . ';' . $exec_string;
+        }
+      }
+    }
+    // If we have jobs to perform.
+    if (count($jobs)) {
+      // Define the Batch.
+      // @see saucelabs.batch.inc for batch functions.
+      $batch = array(
+        'operations' => array(
+          array('saucelabs_process_update', array($jobs, NULL)),
+        ),
+        'finished' => 'saucelabs_run_tests_finished',
+        'title' => t('SauceLabs Batch'),
+        'init_message' => t('Batch is starting.'),
+        'progress_message' => t('Processed @current out of @total.'),
+        'error_message' => t('SauceLabs Batch has encountered an error.'),
+        'file' => drupal_get_path('module', 'saucelabs') . '/saucelabs.batch.inc',
       );
+      batch_set($batch);
+    }
+    else {
+      drupal_set_message(t("No items selected"), 'error');
     }
   }
-
-  return $options;
-}
\ No newline at end of file
+}
diff --git a/saucelabs.batch.inc b/saucelabs.batch.inc
index ebf0272..7dc62a1 100644
--- a/saucelabs.batch.inc
+++ b/saucelabs.batch.inc
@@ -1,5 +1,4 @@
 <?php
-
 /**
  * @file
  * Batch API definitions for the saucelabs module.
@@ -8,7 +7,10 @@
 /**
  * Process the batch.
  */
-function saucelabs_process_update($jobs, $baseurl, &$context) {
+function saucelabs_process_update($jobs, $test_id = NULL, &$context) {
+  $context['sandbox']['test_id'] = $test_id;
+
+  $context['results']['test_id'] = $test_id;
   // Set starting values that track progress of the batch.
   if (!isset($context['sandbox']['progress'])) {
     $context['sandbox']['progress'] = 0;
@@ -16,21 +18,23 @@ function saucelabs_process_update($jobs, $baseurl, &$context) {
     $context['sandbox']['currently_running'] = $jobs[$context['sandbox']['progress']];
   }
 
-  // Run search and replace on current job.
-  $success = _saucelabs_run_test($jobs[$context['sandbox']['progress']], $baseurl);
+  $context['message'] = t('@count/@max | Running Test @testname', array(
+      '@count' => $context['sandbox']['progress'],
+      '@max' => $context['sandbox']['max'],
+      '@testname' => $jobs[$context['sandbox']['progress']],
+    )
+  );
 
+  // Run SauceLabs Test on current Test Command.
+  $success = _saucelabs_run_test($jobs[$context['sandbox']['progress']]);
   // Update our progress information.
   if ($success) {
-    $context['results'][] = $jobs[$context['sandbox']['progress']] . " finished";
+    $context['results']['jobs'][] = $jobs[$context['sandbox']['progress']] . " finished";
   }
   $context['sandbox']['progress']++;
-  $context['sandbox']['currently_running'] = $jobs[$context['sandbox']['progress']];
-  $context['message'] = t('@count/@max | Searching for @search and replacing with @replace', array(
-    '@count' => $context['sandbox']['progress'],
-    '@max' => $context['sandbox']['max'],
-    '@search' => $context['sandbox']['currently_running'],
-    '@replace' => $jobs[$context['sandbox']['progress']])
-  );
+  if (isset($jobs[$context['sandbox']['progress']])) {
+    $context['sandbox']['currently_running'] = $jobs[$context['sandbox']['progress']];
+  }
 
   // Inform the batch engine that we are not finished,
   // and provide an estimation of the completion level we reached.
@@ -45,8 +49,14 @@ function saucelabs_process_update($jobs, $baseurl, &$context) {
 function saucelabs_run_tests_finished($success, $results, $operations) {
   if ($success) {
     // Here we do something meaningful with the results.
-    $message = count($results) . ' processed.';
+    $message = count($results['jobs']) . ' processed.';
     $message .= theme('item_list', $results);
+    if (isset($results['test_id'])) {
+      SaucelabsTest::finishedBatchRun($results['test_id']);
+    }
+    else {
+      drupal_set_message(t('Tests complete, please check on SauceLabs to view results'));
+    }
   }
   else {
     // An error occurred.
@@ -54,7 +64,8 @@ function saucelabs_run_tests_finished($success, $results, $operations) {
     $error_operation = reset($operations);
     $message = t('An error occurred while processing %error_operation with arguments: @arguments', array(
         '%error_operation' => $error_operation[0],
-        '@arguments' => print_r($error_operation[1], TRUE))
+        '@arguments' => print_r($error_operation[1], TRUE)
+      )
     );
   }
   drupal_set_message($message);
@@ -63,33 +74,16 @@ function saucelabs_run_tests_finished($success, $results, $operations) {
 /**
  * Run Test.
  *
- * @param string $file
- *   Should be absolute path to file.
- *
- * @param string $baseurl
- *   The Base URL that will be set in the Test.
+ * @param string $exec_string
+ *   Should be full executable string.
  *
  * @return bool
  */
-function _saucelabs_run_test($file, $baseurl) {
-  if ($path_to_phpunit = variable_get('saucelabs_phpunit_location', FALSE)) {
-
-    // Variables to send to runner script.
-    $u = variable_get('saucelabs_username', '');
-    $api = variable_get('saucelabs_api', '');
-    $t = $file;
-    $b = $baseurl;
-
-    if ($result = shell_exec(DRUPAL_ROOT . '/' . $path_to_phpunit . ' ' . $t .  ' "' . $u . '" ' . '"' . $api . '" ' . '"' . $b . '"' . ' 2>&1')) {
-      drupal_set_message($result);
-      return TRUE;
-    }
-    else {
-      return FALSE;
-    }
+function _saucelabs_run_test($exec_string) {
+  if ($result = shell_exec($exec_string)) {
+    return TRUE;
   }
   else {
-    drupal_set_message("You need to set the location of PHPUnit in the setting config.");
     return FALSE;
   }
-}
\ No newline at end of file
+}
diff --git a/saucelabs.info b/saucelabs.info
index 7e857c9..3dcbf25 100644
--- a/saucelabs.info
+++ b/saucelabs.info
@@ -1 +1,9 @@
 name = SauceLabs
+description = An interface in Drupal to store and kick-off SauceLabs tests on a specific site environment running the same code base.
+core = 7.x
+package = Sauce Labs
+files[] = includes/SaucelabsTest.php
+files[] = includes/SaucelabsTestDB.php
+files[] = includes/SaucelabsTestForms.php
+files[] = includes/SauceLabsCoreTest.php
+configure = admin/saucelabs/settings
diff --git a/saucelabs.install b/saucelabs.install
index c117c43..2371410 100644
--- a/saucelabs.install
+++ b/saucelabs.install
@@ -1,66 +1,79 @@
 <?php
 
 /**
- * @file
- * Install functions for the SauceLabs module
- * - Create table to store test history and links to Saucelabs playback.
- */
-
-/**
  * Implements hook_schema().
  */
 function saucelabs_schema() {
-  $schema['saucelabs_job'] = array(
-    'description' => 'SauceLabs Jobs Record.',
+  $schema['saucelabs'] = array(
+    'description' => 'Storage for SauceLabs tests.',
     'fields' => array(
-      'jid' => array(
-        'description' => 'The primary identifier for a job.',
+      'test_id' => array(
+        'description' => 'Saucelabs Test Id.',
         'type' => 'serial',
         'unsigned' => TRUE,
         'not null' => TRUE,
       ),
       'name' => array(
-        'description' => 'The test name.',
+        'description' => 'Name for the Test. Administrative use.',
         'type' => 'varchar',
         'length' => 255,
         'not null' => TRUE,
         'default' => '',
       ),
-      'file' => array(
-        'description' => 'The file location that the test lives in.',
-        'type' => 'varchar',
-        'length' => 255,
+      'description' => array(
+        'description' => 'Description of the test.',
+        'type' => 'text',
+        'not null' => TRUE,
+      ),
+      'tests' => array(
+        'description' => 'Test files used on this test.',
+        'type' => 'text',
         'not null' => TRUE,
-        'default' => '',
       ),
-      'job_id' => array(
-        'description' => 'SauceLabs Jobs ID.',
+      'environment' => array(
+        'description' => 'Target Environment for the test.',
         'type' => 'varchar',
         'length' => 255,
         'not null' => TRUE,
         'default' => '',
       ),
-      'link' => array(
-        'description' => 'URL to SauceLabs test playback.',
+      'path' => array(
+        'description' => 'Target Path for the test.',
         'type' => 'varchar',
         'length' => 255,
         'not null' => TRUE,
         'default' => '',
       ),
-      'initiated' => array(
-        'description' => 'The Unix timestamp when the job was created.',
+      'report' => array(
+        'description' => 'Boolean indicating whether the test should report to watchdog.',
+        'type' => 'int',
+        'not null' => TRUE,
+        'default' => 1,
+      ),
+      'last_test' => array(
+        'description' => 'Timestamp of the last time this test was run.',
+        'type' => 'int',
+        'not null' => TRUE,
+        'default' => 0,
+      ),
+      'last_duration' => array(
+        'description' => 'Duration in seconds of the last time this test was run.',
         'type' => 'int',
         'not null' => TRUE,
         'default' => 0,
       ),
-      'status' => array(
-        'description' => 'Test pass or fail',
+      'last_state' => array(
+        'description' => 'Boolean indicating whether the test was successful the last time it ran.',
         'type' => 'int',
         'not null' => TRUE,
         'default' => 0,
       ),
+      'session_ids' => array(
+        'description' => 'Session IDs for the SauceLab tests.',
+        'type' => 'text',
+      ),
     ),
-    'primary key' => array('jid'),
+    'primary key' => array('test_id'),
   );
   return $schema;
-}
\ No newline at end of file
+}
diff --git a/saucelabs.module b/saucelabs.module
index 5575822..93b2f6f 100644
--- a/saucelabs.module
+++ b/saucelabs.module
@@ -11,24 +11,79 @@
 function saucelabs_menu() {
   $items = array();
   $items['admin/saucelabs'] = array(
-    'title' => 'SauceLabs',
+    'title' => t('SauceLabs'),
     'page callback' => 'drupal_get_form',
     'page arguments' => array('saucelabs_dashboard'),
     'access arguments' => array('administer site'),
     'type' => MENU_NORMAL_ITEM,
     'file' => 'saucelabs.admin.inc',
+    'weight' => 0,
   );
   $items['admin/saucelabs/dashboard'] = array(
-    'title' => 'Dashboard',
+    'title' => t('Dashboard'),
     'type' => MENU_DEFAULT_LOCAL_TASK,
   );
   $items['admin/saucelabs/settings'] = array(
-    'title' => 'Settings',
+    'title' => t('Settings'),
     'page callback' => 'drupal_get_form',
     'page arguments' => array('saucelabs_settings_form'),
     'access arguments' => array('administer site'),
     'type' => MENU_LOCAL_TASK,
     'file' => 'saucelabs.admin.inc',
+    'weight' => 4,
+  );
+  $items['admin/saucelabs/create'] = array(
+    'title' => t('Create A Test'),
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('saucelabs_edit_test_form'),
+    'access arguments' => array('administer site'),
+    'type' => MENU_LOCAL_TASK,
+    'file' => 'saucelabs.admin.inc',
+    'weight' => 2,
+  );
+  $items['admin/saucelabs/tests/%saucelabs_test_id'] = array(
+    'title' => 'View Test',
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('saucelabs_view_test', 3),
+    'access arguments' => array('administer site'),
+    'type' => MENU_NORMAL_ITEM,
+    'file' => 'saucelabs.admin.inc',
+    'weight' => 2,
+  );
+  $items['admin/saucelabs/tests/%saucelabs_test_id/view'] = array(
+    'title' => 'View Test',
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('saucelabs_view_test', 3),
+    'access arguments' => array('administer site'),
+    'type' => MENU_DEFAULT_LOCAL_TASK,
+    'file' => 'saucelabs.admin.inc',
+    'weight' => 2,
+  );
+  $items['admin/saucelabs/tests/%saucelabs_test_id/run'] = array(
+    'title' => 'Run Test',
+    'page callback' => 'saucelabs_run_test',
+    'page arguments' => array(3),
+    'access arguments' => array('administer site'),
+    'type' => MENU_CALLBACK,
+    'file' => 'saucelabs.admin.inc',
+    'weight' => 2,
+  );
+  $items['admin/saucelabs/tests/%saucelabs_test_id/edit'] = array(
+    'title' => t('Edit Test'),
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('saucelabs_edit_test_form', 3),
+    'access arguments' => array('administer site'),
+    'type' => MENU_LOCAL_TASK,
+    'file' => 'saucelabs.admin.inc',
+    'weight' => 4,
   );
   return $items;
-}
\ No newline at end of file
+}
+
+/**
+ * Implements [wildcard]_load().
+ */
+function saucelabs_test_id_load($test_id) {
+  $test = SaucelabsTest::buildFromId($test_id);
+  return empty($test) ? FALSE : $test;
+}
diff --git a/saucelabs.runner.inc b/saucelabs.runner.inc
deleted file mode 100644
index 39c1f9a..0000000
--- a/saucelabs.runner.inc
+++ /dev/null
@@ -1,23 +0,0 @@
-
-<?php
-#!/usr/bin/env php
-/**
- * @file
- * Run SauceLabs PHPUnit tests with supplied variables.
- */
-
-global $argv;
-
-define('DEFAULT_DOMAIN', 'trystapp');
-define('DRUPAL_ROOT', '/Users/focal55/Documents/projects/trystapp');
-define('RANDOM', rand(100, 1000));
-
-// Constants supplied via arguments passed into script.
-define('SAUCELABS_USERNAME', $argv[2]);
-define('SAUCELABS_API', $argv[3]);
-define('SAUCELABS_TEST', $argv[4]);
-define('SAUCELABS_BASEURL', $argv[5]);
-
-include_once 'vendor/autoload.php';
-
-include "$argv[4]";
\ No newline at end of file
diff --git a/saucelabs_demo/TestDemo.php b/saucelabs_demo/TestDemo.php
new file mode 100644
index 0000000..8040326
--- /dev/null
+++ b/saucelabs_demo/TestDemo.php
@@ -0,0 +1,37 @@
+<?php
+
+/**
+ * @file
+ * TestDemo
+ * Test Demo
+ * Demo of how a Test file should be set up.
+ */
+
+require_once 'SauceLabsCoreTest.php';
+
+class TestDemo extends SauceLabsCoreTest {
+  protected $start_url = SAUCE_PATH;
+
+  public static $browsers = array(
+    // run FF15 on Windows 8 on Sauce
+    array(
+      'browserName' => 'firefox',
+      'desiredCapabilities' => array(
+        'version' => '15',
+        'platform' => 'Windows 2012',
+      )
+    ),
+    // run Chrome on Linux on Sauce
+    array(
+      'browserName' => 'chrome',
+      'desiredCapabilities' => array(
+        'platform' => 'Linux'
+      )
+    ),
+  );
+
+  public function testDemoTest() {
+    $this->assertContains("Some Page Title", $this->title());
+  }
+
+}
diff --git a/saucelabs_demo/saucelabs_demo.info b/saucelabs_demo/saucelabs_demo.info
new file mode 100644
index 0000000..2b3beab
--- /dev/null
+++ b/saucelabs_demo/saucelabs_demo.info
@@ -0,0 +1,6 @@
+name = SauceLabs Demo
+description = An Demo Module to show the integration with a service like Message Notify
+core = 7.x
+package = Sauce Labs
+dependencies[] = message_notify
+dependencies[] = token
diff --git a/saucelabs_demo/saucelabs_demo.install b/saucelabs_demo/saucelabs_demo.install
new file mode 100644
index 0000000..fbc7ed3
--- /dev/null
+++ b/saucelabs_demo/saucelabs_demo.install
@@ -0,0 +1,59 @@
+<?php
+
+/**
+ * Implements hook_install().
+ *
+ * Sets up Message Type, with a field for test id
+ *
+ * @throws \MessageException
+ */
+function saucelabs_demo_install() {
+
+  $values = array(
+    'description' => 'SauceLabs Demo Notification',
+    'message_text' => array(
+      LANGUAGE_NONE => array(
+        array('value' => 'Saucelabs Test "[saucelabs:name]" has run'),
+        array('value' => 'Saucelabs Test [saucelabs:name] has [saucelabs:status]'),
+      ),
+    )
+  );
+  $message_type = message_type_create('saucelabs_demo_notification', $values);
+  $message_type->save();
+  $field_name = 'field_saucelabs_demo_test_id';
+  if (!$field = field_info_field($field_name)) {
+    $field = array(
+      'field_name' => $field_name,
+      'type' => 'number_integer',
+    );
+    $field = field_create_field($field);
+  }
+
+  $instance = array(
+    'field_name' => $field['field_name'],
+    'entity_type' => 'message',
+    'bundle' => 'saucelabs_demo_notification',
+    'description' => 'Storage of the ID of the SauceLabs test the Message Instance was made for.',
+    'label' => 'SauceLabs Test ID',
+    'widget' => array(
+      'type' => 'textfield',
+    ),
+  );
+
+  field_create_instance($instance);
+
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function saucelabs_demo_uninstall() {
+  $message_type = message_type_load('saucelabs_demo_notification');
+  if (isset($message_type)) {
+    // Despite inline docs on this function requiring the message object, the
+    // actual function it calls on Entity, requires an id.
+    message_type_delete($message_type->name);
+  }
+  field_delete_field('field_saucelabs_demo_test_id');
+
+}
diff --git a/saucelabs_demo/saucelabs_demo.module b/saucelabs_demo/saucelabs_demo.module
new file mode 100644
index 0000000..34df510
--- /dev/null
+++ b/saucelabs_demo/saucelabs_demo.module
@@ -0,0 +1,63 @@
+<?php
+
+/**
+ * Implements hook_saucelabs_test_complete().
+ */
+function saucelabs_demo_saucelabs_test_complete($test) {
+  if (!$test->isLastState()) {
+    $message = message_create('saucelabs_demo_notification');
+    $wrapper = entity_metadata_wrapper('message', $message);
+    $wrapper->field_saucelabs_demo_test_id->set($test->getTestId());
+    message_notify_send_message($message);
+  }
+}
+
+/**
+ * Implements hook_token_info().
+ */
+function saucelabs_demo_token_info() {
+  $type = array(
+    'name' => t('SauceLabs Tests'),
+    'description' => t('Tokens related to SauceLabs Tests.'),
+  );
+
+  $info['name'] = array(
+    'name' => t('SauceLabs Test Name'),
+    'description' => t('Returns the name of the SauceLabs Test'),
+  );
+  $info['status'] = array(
+    'name' => t('SauceLabs Test Status'),
+    'description' => t('Returns the status of the SauceLabs Test'),
+  );
+  $info['last_timestamp'] = array(
+    'name' => t('SauceLabs Test Last Run'),
+    'description' => t('Returns the date/time of the last run of the SauceLabs Test'),
+    'type' => 'date'
+  );
+  return array(
+    'types' => array('saucelabs' => $type),
+    'tokens' => array('saucelabs' => $info)
+  );
+}
+
+/**
+ * Implements hook_tokens().
+ */
+function saucelabs_demo_tokens($type, $tokens, array $data = array(), array $options = array()) {
+  $replacements = array();
+  $test_id = $data['message']->field_saucelabs_demo_test_id['und'][0]['value'];
+  $test = SaucelabsTest::buildFromId($test_id);
+  if ($type == 'saucelabs') {
+    foreach ($tokens as $name => $original) {
+      switch ($name) {
+        case 'name':
+          $replacements[$original] = $test->getName();
+          break;
+        case 'status':
+          $replacements[$original] = $test->getStateText();
+      }
+    }
+  }
+
+  return $replacements;
+}
-- 
2.6.4 (Apple Git-63)

