id = $id; } else { Session::start(); $this->id = Session::get('TestSessionId'); } } /** * @return String Absolute path to the file persisting our state. */ public function getFilePath() { if($this->id) { $path = Director::getAbsFile(sprintf($this->config()->test_state_id_file, $this->id)); } else { $path = Director::getAbsFile($this->config()->test_state_file); } if(!is_writable(dirname($path))) { $path = str_replace(Director::baseFolder(), TEMP_FOLDER, $path); } return $path; } /** * Tests for the existence of the file specified by $this->test_state_file */ public function isRunningTests() { return(file_exists($this->getFilePath())); } /** * @param String $id */ public function setId($id) { $this->id = $id; } /** * @return String */ public function getId() { return $this->id; } /** * Creates a temp database, sets up any extra requirements, and writes the state file. The database will be * connected to as part of {@link self::applyState()}, so if you're continuing script execution after calling this * method, be aware that the database will be different - so various things may break (e.g. administrator logins * using the SS_DEFAULT_USERNAME / SS_DEFAULT_PASSWORD constants). * * If something isn't explicitly handled here, and needs special handling, then it should be taken care of by an * extension to TestSessionEnvironment. You can either extend onBeforeStartTestSession() or * onAfterStartTestSession(). Alternatively, for more fine-grained control, you can also extend * onBeforeApplyState() and onAfterApplyState(). See the {@link self::applyState()} method for more. * * @param array $state An array of test state options to write. */ public function startTestSession($state, $id = null) { $this->removeStateFile(); $this->id = $id; $extendedState = $this->extend('onBeforeStartTestSession', $state); // $extendedState is now a multi-dimensional array (if extensions exist) if($extendedState && is_array($extendedState)) { foreach($extendedState as $stateVal) { // $stateVal is one extension's additions to $state $state = array_merge($state, $stateVal); // Merge this into the original $state } } // Convert to JSON and back so we can share the applyState() code between this and ->loadFromFile() $json = json_encode($state, JSON_FORCE_OBJECT); $state = json_decode($json); $this->applyState($state); $this->extend('onAfterStartTestSession'); } public function updateTestSession($state) { $this->extend('onBeforeUpdateTestSession', $state); // Convert to JSON and back so we can share the appleState() code between this and ->loadFromFile() $json = json_encode($state, JSON_FORCE_OBJECT); $state = json_decode($json); $this->applyState($state); $this->extend('onAfterUpdateTestSession'); } /** * Assumes the database has already been created in startTestSession(), as this method can be called from * _config.php where we don't yet have a DB connection. * * Persists the state to the filesystem. * * You can extend this by creating an Extension object and implementing either onBeforeApplyState() or * onAfterApplyState() to add your own test state handling in. * * @throws LogicException * @throws InvalidArgumentException */ public function applyState($state) { $this->extend('onBeforeApplyState', $state); $database = (isset($state->database)) ? $state->database : null; // back up source global $databaseConfig; $this->oldDatabaseName = $databaseConfig['database']; // Load existing state from $this->state into $state, if there is any $oldState = $this->getState(); if($oldState) { foreach($oldState as $k => $v) { if(!isset($state->$k)) { $state->$k = $v; // Don't overwrite stuff in $state, as that's the new state } } } // ensure we have a connection to the database if($database) { $conn = DB::getConn(); if(!$conn) { $conn = DB::connect($databaseConfig); } $conn->selectDatabase($database); } else { $state->database = SapphireTest::create_temp_db(); } // Database if(isset($state->createDatabase)) { $this->createDatabaseForState($state); unset($state->createDatabase); } // Fixtures $fixtureFile = (isset($state->fixture)) ? $state->fixture : null; if($fixtureFile) { $this->loadFixtureIntoDb($fixtureFile); unset($state->fixture); } // Mailer $mailer = (isset($state->mailer)) ? $state->mailer : null; if($mailer) { if(!class_exists($mailer) || !is_subclass_of($mailer, 'Mailer')) { throw new InvalidArgumentException(sprintf( 'Class "%s" is not a valid class, or subclass of Mailer', $mailer )); } } // Date and time if(isset($state->datetime)) { require_once 'Zend/Date.php'; // Convert DatetimeField format if(!Zend_Date::isDate($state->datetime, 'yyyy-MM-dd HH:mm:ss')) { throw new LogicException(sprintf( 'Invalid date format "%s", use yyyy-MM-dd HH:mm:ss', $state->datetime )); } } $this->saveState($state); $this->extend('onAfterApplyState'); } /** * Import the database * * @return void */ public function createDatabaseForState($state) { if(isset($state->createDatabaseTemplate) && $state->createDatabaseTemplate) { $sql = file_get_contents($state->createDatabaseTemplate); // Split into individual query commands, removing comments $sqlCmds = array_filter( preg_split('/\s*;\s*/', preg_replace(array('/^$\n/m', '/^(\/|#).*$\n/m'), '', $sql) ) ); // Execute each query foreach($sqlCmds as $sqlCmd) { DB::query($sqlCmd); } // In case the dump involved CREATE TABLE commands, we need to ensure // the schema is still up to date $dbAdmin = new DatabaseAdmin(); $populate = (isset($state->requireDefaultRecords) && $state->requireDefaultRecords); Versioned::set_reading_mode(''); $dbAdmin->doBuild(true, $populate); unset($state->createDatabaseTemplate); } return $state; } /** * Sliented as if the file already exists by another process, we don't want * to modify. */ public function saveState($state) { @file_put_contents( $this->getFilePath(), json_encode($state, JSON_PRETTY_PRINT), LOCK_EX ); } public function loadFromFile() { if($this->isRunningTests()) { try { $contents = file_get_contents($this->getFilePath()); $json = json_decode($contents); $this->applyState($json); } catch(Exception $e) { throw new \Exception("A test session appears to be in progress, but we can't retrieve the details. " . "Try removing the " . $this->getFilePath() . " file. Inner " . "error: " . $e->getMessage()); } } } private function removeStateFile() { $file = $this->getFilePath(); if(file_exists($file)) { if(!unlink($file)) { throw new \Exception('Unable to remove the testsession state file, please remove it manually. File ' . 'path: ' . $file); } } } /** * Cleans up the test session state by restoring the normal database connect (for the rest of this request, if any) * and removes the {@link self::$test_state_file} so that future requests don't use this test state. * * Can be extended by implementing either onBeforeEndTestSession() or onAfterEndTestSession(). * * This should implement itself cleanly in case it is called twice (e.g. don't throw errors when the state file * doesn't exist anymore because it's already been cleaned up etc.) This is because during behat test runs where * a queueing system (e.g. silverstripe-resque) is used, the behat module may call this first, and then the forked * worker will call it as well - but there is only one state file that is created. */ public function endTestSession() { $this->extend('onBeforeEndTestSession'); if(SapphireTest::using_temp_db()) { $this->resetDatabaseName(); SapphireTest::set_is_running_test(false); } $this->removeStateFile(); $this->extend('onAfterEndTestSession'); } /** * Loads a YAML fixture into the database as part of the {@link TestSessionController}. * * @param string $fixtureFile The .yml file to load * @return FixtureFactory The loaded fixture * @throws LogicException */ protected function loadFixtureIntoDb($fixtureFile) { $realFile = realpath(BASE_PATH.'/'.$fixtureFile); $baseDir = realpath(Director::baseFolder()); if(!$realFile || !file_exists($realFile)) { throw new LogicException("Fixture file doesn't exist"); } else if(substr($realFile,0,strlen($baseDir)) != $baseDir) { throw new LogicException("Fixture file must be inside $baseDir"); } else if(substr($realFile,-4) != '.yml') { throw new LogicException("Fixture file must be a .yml file"); } else if(!preg_match('/^([^\/.][^\/]+)\/tests\//', $fixtureFile)) { throw new LogicException("Fixture file must be inside the tests subfolder of one of your modules."); } $factory = Injector::inst()->create('FixtureFactory'); $fixture = Injector::inst()->create('YamlFixture', $fixtureFile); $fixture->writeInto($factory); $state = $this->getState(); $state->fixtures[] = $fixtureFile; $this->applyState($state); return $fixture; } /** * Reset the database connection to use the original database. Called by {@link self::endTestSession()}. */ public function resetDatabaseName() { if($this->oldDatabaseName) { global $databaseConfig; $databaseConfig['database'] = $this->oldDatabaseName; $conn = DB::getConn(); if($conn) { $conn->selectDatabase($this->oldDatabaseName); } } } /** * @return stdClass Data as taken from the JSON object in {@link self::loadFromFile()} */ public function getState() { $path = Director::getAbsFile($this->getFilePath()); return (file_exists($path)) ? json_decode(file_get_contents($path)) : new stdClass; } }