From 3e1eff2a013cc8f632ae09d31555839fbee00203 Mon Sep 17 00:00:00 2001 From: Bryan Tong Minh Date: Mon, 6 Sep 2010 10:18:53 +0000 Subject: [PATCH] Follow-up r70137: Made asynchronous upload working a bit more. It now fully works from the API; works still needs to be done for the normal UI. PHPUnit tests are updated and should cover most code paths that can be called from the API. ApiUpload: * Added "statuskey" parameter; this is the key that is returned by an async upload * Refactored warnings transformation into its own function * filename is no longer required on all uploads UploadFromUrlJob: * Moved upload results to its own entry in $_SESSION, instead of using the one from upload * Fix storing in session by calling wfSetupSession and session_write_close where needed Tests: * Set $wgUser in ApiSetup, so that individual tests don't have to do this for themselves * Added tests to cover most code paths from the API * Fixed UploadFromUrlTestSuite so that its tests are included in a regular phpunit invocation (something strange with the AutoLoader; not sure what) Other files: * Allow passing session id to wfSetupSession * Explicitly close the session before doing jobs, so that jobs can't manipulate the current session --- RELEASE-NOTES | 2 + includes/GlobalFunctions.php | 5 +- includes/Wiki.php | 2 + includes/api/ApiUpload.php | 90 +++++-- includes/job/UploadFromUrlJob.php | 43 ++-- includes/upload/UploadFromUrl.php | 2 + maintenance/tests/ApiSetup.php | 4 +- maintenance/tests/UploadFromUrlTest.php | 239 ++++++++++++++----- maintenance/tests/UploadFromUrlTestSuite.php | 6 +- 9 files changed, 293 insertions(+), 100 deletions(-) diff --git a/RELEASE-NOTES b/RELEASE-NOTES index 521ae8f419..129196e878 100644 --- a/RELEASE-NOTES +++ b/RELEASE-NOTES @@ -380,6 +380,8 @@ it from source control: http://www.mediawiki.org/wiki/Download_from_SVN missing * (bug 24724) list=allusers is out by 1 (shows total users - 1) * (bug 24166) API error when using rvprop=tags +* Introduced "asynchronous download" mode for upload-by-url. Requires + $wgAllowAsyncCopyUploads to be true. === Languages updated in 1.17 === diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php index c30b783d1a..271628b3ab 100644 --- a/includes/GlobalFunctions.php +++ b/includes/GlobalFunctions.php @@ -2918,7 +2918,7 @@ function wfHttpOnlySafe() { /** * Initialise php session */ -function wfSetupSession() { +function wfSetupSession( $sessionId = false ) { global $wgSessionsInMemcached, $wgCookiePath, $wgCookieDomain, $wgCookieSecure, $wgCookieHttpOnly, $wgSessionHandler; if( $wgSessionsInMemcached ) { @@ -2944,6 +2944,9 @@ function wfSetupSession() { session_set_cookie_params( 0, $wgCookiePath, $wgCookieDomain, $wgCookieSecure ); } session_cache_limiter( 'private, must-revalidate' ); + if ( $sessionId ) { + session_id( $sessionId ); + } wfSuppressWarnings(); session_start(); wfRestoreWarnings(); diff --git a/includes/Wiki.php b/includes/Wiki.php index cd671ce283..0c65b4aaa2 100644 --- a/includes/Wiki.php +++ b/includes/Wiki.php @@ -379,6 +379,8 @@ class MediaWiki { $output->output(); // Do any deferred jobs $this->doUpdates( $deferredUpdates ); + // Close the session so that jobs don't access the current session + session_write_close(); $this->doJobs(); wfProfileOut( __METHOD__ ); } diff --git a/includes/api/ApiUpload.php b/includes/api/ApiUpload.php index 9ba52eaa9b..af08a62497 100644 --- a/includes/api/ApiUpload.php +++ b/includes/api/ApiUpload.php @@ -55,7 +55,10 @@ class ApiUpload extends ApiBase { $this->mParams['file'] = $request->getFileName( 'file' ); // Select an upload module - $this->selectUploadModule(); + if ( !$this->selectUploadModule() ) { + // This is not a true upload, but a status request or similar + return; + } if ( !isset( $this->mUpload ) ) { $this->dieUsage( 'No upload module set', 'nomodule' ); } @@ -96,15 +99,39 @@ class ApiUpload extends ApiBase { } /** - * Select an upload module and set it to mUpload. Dies on failure. + * Select an upload module and set it to mUpload. Dies on failure. If the + * request was a status request and not a true upload, returns false; + * otherwise true + * + * @return bool */ protected function selectUploadModule() { $request = $this->getMain()->getRequest(); // One and only one of the following parameters is needed $this->requireOnlyOneParameter( $this->mParams, - 'sessionkey', 'file', 'url' ); + 'sessionkey', 'file', 'url', 'statuskey' ); + if ( $this->mParams['statuskey'] ) { + // Status request for an async upload + $sessionData = UploadFromUrlJob::getSessionData( $this->mParams['statuskey'] ); + if ( !isset( $sessionData['result'] ) ) { + $this->dieUsage(); + } + if ( $sessionData['result'] == 'Warning' ) { + $sessionData['warnings'] = $this->transformWarnings( $sessionData['warnings'] ); + $sessionData['sessionkey'] = $this->mParams['statuskey']; + } + $this->getResult()->addValue( null, $this->getModuleName(), $sessionData ); + return false; + + } + + // The following modules all require the filename parameter to be set + if ( is_null( $this->mParams['filename'] ) ) { + $this->dieUsageMsg( array( 'missingparam', 'filename' ) ); + } + if ( $this->mParams['sessionkey'] ) { // Upload stashed in a previous request $sessionData = $request->getSessionData( UploadBase::getSessionKeyName() ); @@ -132,6 +159,11 @@ class ApiUpload extends ApiBase { $async = false; if ( $this->mParams['asyncdownload'] ) { + if ( $this->mParams['leavemessage'] && !$this->mParams['ignorewarnings'] ) { + $this->dieUsage( 'Using leavemessage without ignorewarnings is not supported', + 'missing-ignorewarnings' ); + } + if ( $this->mParams['leavemessage'] ) { $async = 'async-leavemessage'; } else { @@ -143,6 +175,8 @@ class ApiUpload extends ApiBase { $this->mParams['url'], $async ); } + + return true; } /** @@ -225,25 +259,8 @@ class ApiUpload extends ApiBase { if ( !$this->mParams['ignorewarnings'] ) { $warnings = $this->mUpload->checkWarnings(); if ( $warnings ) { - // Add indices - $this->getResult()->setIndexedTagName( $warnings, 'warning' ); - - if ( isset( $warnings['duplicate'] ) ) { - $dupes = array(); - foreach ( $warnings['duplicate'] as $key => $dupe ) - $dupes[] = $dupe->getName(); - $this->getResult()->setIndexedTagName( $dupes, 'duplicate' ); - $warnings['duplicate'] = $dupes; - } - - if ( isset( $warnings['exists'] ) ) { - $warning = $warnings['exists']; - unset( $warnings['exists'] ); - $warnings[$warning['warning']] = $warning['file']->getName(); - } - $result['result'] = 'Warning'; - $result['warnings'] = $warnings; + $result['warnings'] = $this->transformWarnings( $warnings ); $sessionKey = $this->mUpload->stashSession(); if ( !$sessionKey ) { @@ -257,6 +274,32 @@ class ApiUpload extends ApiBase { } return; } + + /** + * Transforms a warnings array returned by mUpload->checkWarnings() into + * something that can be directly used as API result + */ + protected function transformWarnings( $warnings ) { + // Add indices + $this->getResult()->setIndexedTagName( $warnings, 'warning' ); + + if ( isset( $warnings['duplicate'] ) ) { + $dupes = array(); + foreach ( $warnings['duplicate'] as $key => $dupe ) { + $dupes[] = $dupe->getName(); + } + $this->getResult()->setIndexedTagName( $dupes, 'duplicate' ); + $warnings['duplicate'] = $dupes; + } + + if ( isset( $warnings['exists'] ) ) { + $warning = $warnings['exists']; + unset( $warnings['exists'] ); + $warnings[$warning['warning']] = $warning['file']->getName(); + } + + return $warnings; + } /** * Perform the actual upload. Returns a suitable result array on success; @@ -290,7 +333,7 @@ class ApiUpload extends ApiBase { // requested so return array( 'result' => 'Queued', - 'sessionkey' => $error[0][1], + 'statuskey' => $error[0][1], ); } else { $this->getResult()->setIndexedTagName( $error, 'error' ); @@ -320,7 +363,6 @@ class ApiUpload extends ApiBase { $params = array( 'filename' => array( ApiBase::PARAM_TYPE => 'string', - ApiBase::PARAM_REQUIRED => true ), 'comment' => array( ApiBase::PARAM_DFLT => '' @@ -351,6 +393,7 @@ class ApiUpload extends ApiBase { $params += array( 'asyncdownload' => false, 'leavemessage' => false, + 'statuskey' => null, ); } return $params; @@ -375,6 +418,7 @@ class ApiUpload extends ApiBase { $params += array( 'asyncdownload' => 'Make fetching a URL asynchronous', 'leavemessage' => 'If asyncdownload is used, leave a message on the user talk page if finished', + 'statuskey' => 'Fetch the upload status for this session key', ); } diff --git a/includes/job/UploadFromUrlJob.php b/includes/job/UploadFromUrlJob.php index 31c7dc79a3..63166ef9ef 100644 --- a/includes/job/UploadFromUrlJob.php +++ b/includes/job/UploadFromUrlJob.php @@ -16,6 +16,8 @@ * @ingroup JobQueue */ class UploadFromUrlJob extends Job { + const SESSION_KEYNAME = 'wsUploadFromUrlJobData'; + public $upload; protected $user; @@ -24,14 +26,6 @@ class UploadFromUrlJob extends Job { } public function run() { - # Until we find a way to store data in sessions, set leaveMessage to - # true unconditionally - $this->params['leaveMessage'] = true; - # Similar for ignorewarnings. This is not really a good fallback, but - # there is no easy way to get a wikitext formatted warning message to - # show to the user - $this->params['ignoreWarnings'] = true; - # Initialize this object and the upload object $this->upload = new UploadFromUrl(); $this->upload->initialize( @@ -60,6 +54,8 @@ class UploadFromUrlJob extends Job { if ( !$this->params['ignoreWarnings'] ) { $warnings = $this->upload->checkWarnings(); if ( $warnings ) { + wfSetupSession( $this->params['sessionId'] ); + if ( $this->params['leaveMessage'] ) { $this->user->leaveUserMessage( wfMsg( 'upload-warning-subj' ), @@ -72,7 +68,10 @@ class UploadFromUrlJob extends Job { 'warnings', $warnings ); } - // FIXME: stash in session + # Stash the upload in the session + $this->upload->stashSession( $this->params['sessionKey'] ); + session_write_close(); + return true; } } @@ -111,28 +110,44 @@ class UploadFromUrlJob extends Job { ) ); } } else { + wfSetupSession( $this->params['sessionId'] ); if ( $status->isOk() ) { $this->storeResultInSession( 'Success', - 'filename', $this->getLocalFile()->getName() ); + 'filename', $this->upload->getLocalFile()->getName() ); } else { $this->storeResultInSession( 'Failure', 'errors', $status->getErrorsArray() ); } - + session_write_close(); } } /** - * Store a result in the session data - * THIS IS BROKEN. $_SESSION does not exist when using runJobs.php + * Store a result in the session data. Note that the caller is responsible + * for appropriate session_start and session_write_close calls. * * @param $result String: the result (Success|Warning|Failure) * @param $dataKey String: the key of the extra data * @param $dataValue Mixed: the extra data itself */ protected function storeResultInSession( $result, $dataKey, $dataValue ) { - $session &= $_SESSION[UploadBase::getSessionKeyname()][$this->params['sessionKey']]; + $session =& self::getSessionData( $this->params['sessionKey'] ); $session['result'] = $result; $session[$dataKey] = $dataValue; } + + /** + * Initialize the session data. Sets the intial result to queued. + */ + public function initializeSessionData() { + $session =& self::getSessionData( $this->params['sessionKey'] ); + $$session['result'] = 'Queued'; + } + + public static function &getSessionData( $key ) { + if ( !isset( $_SESSION[self::SESSION_KEYNAME][$key] ) ) { + $_SESSION[self::SESSION_KEYNAME][$key] = array(); + } + return $_SESSION[self::SESSION_KEYNAME][$key]; + } } diff --git a/includes/upload/UploadFromUrl.php b/includes/upload/UploadFromUrl.php index e206a24052..42b382750f 100644 --- a/includes/upload/UploadFromUrl.php +++ b/includes/upload/UploadFromUrl.php @@ -197,8 +197,10 @@ class UploadFromUrl extends UploadBase { 'userName' => $user->getName(), 'leaveMessage' => $this->mAsync == 'async-leavemessage', 'ignoreWarnings' => $this->mIgnoreWarnings, + 'sessionId' => session_id(), 'sessionKey' => $sessionKey, ) ); + $job->initializeSessionData(); $job->insert(); return $sessionKey; } diff --git a/maintenance/tests/ApiSetup.php b/maintenance/tests/ApiSetup.php index 7f34f111ad..51d2ff67b5 100644 --- a/maintenance/tests/ApiSetup.php +++ b/maintenance/tests/ApiSetup.php @@ -22,7 +22,7 @@ abstract class ApiTestSetup extends PHPUnit_Framework_TestCase { static function setupUser() { if ( self::$user == NULL ) { self::$userName = "Useruser"; - self::$passWord = User::randomPassword(); + self::$passWord = 'Passpass'; self::$user = User::newFromName( self::$userName ); if ( !self::$user->getID() ) { @@ -33,5 +33,7 @@ abstract class ApiTestSetup extends PHPUnit_Framework_TestCase { self::$user->setPassword( self::$passWord ); self::$user->saveSettings(); } + + $GLOBALS['wgUser'] = self::$user; } } diff --git a/maintenance/tests/UploadFromUrlTest.php b/maintenance/tests/UploadFromUrlTest.php index b4f7e97351..c2c244630b 100644 --- a/maintenance/tests/UploadFromUrlTest.php +++ b/maintenance/tests/UploadFromUrlTest.php @@ -1,44 +1,50 @@ exists() ) { + $this->deleteFile( 'UploadFromUrlTest.png' ); + } } - function doApiRequest( $params, $data = null ) { - $session = isset( $data[2] ) ? $data[2] : array(); - $_SESSION = $session; - - $req = new FauxRequest( $params, true, $session ); + protected function doApiRequest( $params ) { + $sessionId = session_id(); + session_write_close(); + + $req = new FauxRequest( $params, true, $_SESSION ); $module = new ApiMain( $req, true ); $module->execute(); - return array( $module->getResultData(), $req, $_SESSION ); + wfSetupSession( $sessionId ); + return array( $module->getResultData(), $req ); } - function testClearQueue() { + /** + * Ensure that the job queue is empty before continuing + */ + public function testClearQueue() { while ( $job = Job::pop() ) { } $this->assertFalse( $job ); } - function testLogin() { + /** + * @todo Document why we test login, since the $wgUser hack used doesn't + * require login + */ + public function testLogin() { $data = $this->doApiRequest( array( 'action' => 'login', 'lgname' => self::$userName, @@ -64,19 +70,16 @@ class UploadFromUrlTest extends ApiTestSetup { /** * @depends testLogin + * @depends testClearQueue */ - function testSetupUrlDownload( $data ) { - global $wgUser; - $wgUser = User::newFromName( self::$userName ); - $wgUser->load(); - $data[2]['wsEditToken'] = $data[2]['wsToken']; - $token = md5( $data[2]['wsToken'] ) . EDIT_TOKEN_SUFFIX; + public function testSetupUrlDownload( $data ) { + $token = self::$user->editToken(); $exception = false; try { $this->doApiRequest( array( 'action' => 'upload', - ), $data ); + ) ); } catch ( UsageException $e ) { $exception = true; $this->assertEquals( "The token parameter must be set", $e->getMessage() ); @@ -91,7 +94,7 @@ class UploadFromUrlTest extends ApiTestSetup { ), $data ); } catch ( UsageException $e ) { $exception = true; - $this->assertEquals( "One of the parameters sessionkey, file, url is required", + $this->assertEquals( "One of the parameters sessionkey, file, url, statuskey is required", $e->getMessage() ); } $this->assertTrue( $exception, "Got exception" ); @@ -109,7 +112,7 @@ class UploadFromUrlTest extends ApiTestSetup { } $this->assertTrue( $exception, "Got exception" ); - $wgUser->removeGroup( 'sysop' ); + self::$user->removeGroup( 'sysop' ); $exception = false; try { $this->doApiRequest( array( @@ -124,8 +127,8 @@ class UploadFromUrlTest extends ApiTestSetup { } $this->assertTrue( $exception, "Got exception" ); - $wgUser->addGroup( '*' ); - $wgUser->addGroup( 'sysop' ); + self::$user->addGroup( '*' ); + self::$user->addGroup( 'sysop' ); $exception = false; $data = $this->doApiRequest( array( 'action' => 'upload', @@ -143,45 +146,65 @@ class UploadFromUrlTest extends ApiTestSetup { /** * @depends testLogin + * @depends testClearQueue */ - function testDoDownload( $data ) { - global $wgUser; - $data[2]['wsEditToken'] = $data[2]['wsToken']; - $token = md5( $data[2]['wsToken'] ) . EDIT_TOKEN_SUFFIX; + public function testAsyncUpload( $data ) { + $token = self::$user->editToken(); - $wgUser->addGroup( 'users' ); - $data = $this->doApiRequest( array( - 'action' => 'upload', - 'filename' => 'UploadFromUrlTest.png', - 'url' => 'http://bits.wikimedia.org/skins-1.5/common/images/poweredby_mediawiki_88x31.png', - 'asyncdownload' => 1, - 'token' => $token, - ), $data ); + self::$user->addGroup( 'users' ); - $job = Job::pop(); - $this->assertEquals( 'UploadFromUrlJob', get_class( $job ) ); + $data = $this->doAsyncUpload( $token, true ); + $this->assertEquals( $data[0]['upload']['result'], 'Success' ); + $this->assertEquals( $data[0]['upload']['filename'], 'UploadFromUrlTest.png' ); + $this->assertTrue( wfLocalFile( $data[0]['upload']['filename'] )->exists() ); + + $this->deleteFile( 'UploadFromUrlTest.png' ); - $status = $job->run(); + return $data; + } + + /** + * @depends testLogin + * @depends testClearQueue + */ + public function testAsyncUploadWarning( $data ) { + $token = self::$user->editToken(); + + self::$user->addGroup( 'users' ); - $this->assertTrue( $status ); + + $data = $this->doAsyncUpload( $token ); + + $this->assertEquals( $data[0]['upload']['result'], 'Warning' ); + $this->assertTrue( isset( $data[0]['upload']['sessionkey'] ) ); + + $data = $this->doApiRequest( array( + 'action' => 'upload', + 'sessionkey' => $data[0]['upload']['sessionkey'], + 'filename' => 'UploadFromUrlTest.png', + 'ignorewarnings' => 1, + 'token' => $token, + ) ); + $this->assertEquals( $data[0]['upload']['result'], 'Success' ); + $this->assertEquals( $data[0]['upload']['filename'], 'UploadFromUrlTest.png' ); + $this->assertTrue( wfLocalFile( $data[0]['upload']['filename'] )->exists() ); + + $this->deleteFile( 'UploadFromUrlTest.png' ); return $data; } /** * @depends testLogin + * @depends testClearQueue */ - function testSyncDownload( $data ) { - global $wgUser; - $data[2]['wsEditToken'] = $data[2]['wsToken']; - $token = md5( $data[2]['wsToken'] ) . EDIT_TOKEN_SUFFIX; + public function testSyncDownload( $data ) { + $token = self::$user->editToken(); $job = Job::pop(); $this->assertFalse( $job, 'Starting with an empty jobqueue' ); - //$this->deleteFile( 'UploadFromUrlTest.png' ); - - $wgUser->addGroup( 'users' ); + self::$user->addGroup( 'users' ); $data = $this->doApiRequest( array( 'action' => 'upload', 'filename' => 'UploadFromUrlTest.png', @@ -194,25 +217,121 @@ class UploadFromUrlTest extends ApiTestSetup { $this->assertFalse( $job ); $this->assertEquals( 'Success', $data[0]['upload']['result'] ); + $this->deleteFile( 'UploadFromUrlTest.png' ); return $data; } + + public function testLeaveMessage() { + $token = self::$user->editToken(); + + $talk = self::$user->getTalkPage(); + if ( $talk->exists() ) { + $a = new Article( $talk ); + $a->doDeleteArticle( '' ); + } + + $this->assertFalse( (bool)$talk->getArticleId( GAID_FOR_UPDATE ), 'User talk does not exist' ); + + $data = $this->doApiRequest( array( + 'action' => 'upload', + 'filename' => 'UploadFromUrlTest.png', + 'url' => 'http://bits.wikimedia.org/skins-1.5/common/images/poweredby_mediawiki_88x31.png', + 'asyncdownload' => 1, + 'token' => $token, + 'leavemessage' => 1, + 'ignorewarnings' => 1, + ) ); + + $job = Job::pop(); + $job->run(); + + $this->assertTrue( wfLocalFile( 'UploadFromUrlTest.png' )->exists() ); + $this->assertTrue( (bool)$talk->getArticleId( GAID_FOR_UPDATE ), 'User talk exists' ); + + $this->deleteFile( 'UploadFromUrlTest.png' ); + + $talkRev = Revision::newFromTitle( $talk ); + $talkSize = $talkRev->getSize(); + + $exception = false; + try { + $data = $this->doApiRequest( array( + 'action' => 'upload', + 'filename' => 'UploadFromUrlTest.png', + 'url' => 'http://bits.wikimedia.org/skins-1.5/common/images/poweredby_mediawiki_88x31.png', + 'asyncdownload' => 1, + 'token' => $token, + 'leavemessage' => 1, + ) ); + } catch ( UsageException $e ) { + $exception = true; + $this->assertEquals( 'Using leavemessage without ignorewarnings is not supported', $e->getMessage() ); + } + $this->assertTrue( $exception ); + + $job = Job::pop(); + $this->assertFalse( $job ); + + return; + + // Broken until using leavemessage with ignorewarnings is supported + $job->run(); + + $this->assertFalse( wfLocalFile( 'UploadFromUrlTest.png' )->exists() ); + + $talkRev = Revision::newFromTitle( $talk ); + $this->assertTrue( $talkRev->getSize() > $talkSize, 'New message left' ); + + } + /** - * @depends testDoDownload + * Helper function to perform an async upload, execute the job and fetch + * the status + * + * @return array The result of action=upload&statuskey=key */ - function testVerifyDownload( $data ) { - $t = Title::newFromText( "UploadFromUrlTest.png", NS_FILE ); - - $this->assertTrue( $t->exists() ); - - $this->deleteFile( 'UploadFromUrlTest.png' ); - } + private function doAsyncUpload( $token, $ignoreWarnings = false, $leaveMessage = false ) { + $params = array( + 'action' => 'upload', + 'filename' => 'UploadFromUrlTest.png', + 'url' => 'http://bits.wikimedia.org/skins-1.5/common/images/poweredby_mediawiki_88x31.png', + 'asyncdownload' => 1, + 'token' => $token, + ); + if ( $ignoreWarnings ) { + $params['ignorewarnings'] = 1; + } + if ( $leaveMessage ) { + $params['leavemessage'] = 1; + } + + $data = $this->doApiRequest( $params ); + $this->assertEquals( $data[0]['upload']['result'], 'Queued' ); + $this->assertTrue( isset( $data[0]['upload']['statuskey'] ) ); + $statusKey = $data[0]['upload']['statuskey']; + + $job = Job::pop(); + $this->assertEquals( 'UploadFromUrlJob', get_class( $job ) ); + + $status = $job->run(); + $this->assertTrue( $status ); + + $data = $this->doApiRequest( array( + 'action' => 'upload', + 'statuskey' => $statusKey, + 'token' => $token, + ) ); + + return $data; + } + /** * */ - function deleteFile( $name ) { + protected function deleteFile( $name ) { $t = Title::newFromText( $name, NS_FILE ); $this->assertTrue($t->exists(), "File '$name' exists"); diff --git a/maintenance/tests/UploadFromUrlTestSuite.php b/maintenance/tests/UploadFromUrlTestSuite.php index 927154b5be..5966a6ebf7 100644 --- a/maintenance/tests/UploadFromUrlTestSuite.php +++ b/maintenance/tests/UploadFromUrlTestSuite.php @@ -170,6 +170,10 @@ class UploadFromUrlTestSuite extends PHPUnit_Framework_TestSuite { } public static function suite() { - return new UploadFromUrlTestSuite( 'UploadFromUrlTest' ); + // Hack to invoke the autoloader required to get phpunit to recognize + // the UploadFromUrlTest class + class_exists( 'UploadFromUrlTest' ); + $suite = new UploadFromUrlTestSuite( 'UploadFromUrlTest' ); + return $suite; } } -- 2.20.1