diff --git a/src/Composer/Util/ProcessExecutor.php b/src/Composer/Util/ProcessExecutor.php index 03c5aa519..6b6e84bee 100644 --- a/src/Composer/Util/ProcessExecutor.php +++ b/src/Composer/Util/ProcessExecutor.php @@ -371,6 +371,9 @@ class ProcessExecutor return $this->errorOutput; } + /** + * @private + */ public function outputHandler($type, $buffer) { if ($this->captureOutput) { diff --git a/tests/Composer/Test/Downloader/GitDownloaderTest.php b/tests/Composer/Test/Downloader/GitDownloaderTest.php index bee7a09bd..65b41a529 100644 --- a/tests/Composer/Test/Downloader/GitDownloaderTest.php +++ b/tests/Composer/Test/Downloader/GitDownloaderTest.php @@ -17,6 +17,8 @@ use Composer\Config; use Composer\Test\TestCase; use Composer\Util\Filesystem; use Composer\Util\Platform; +use Composer\Test\Mock\ProcessExecutorMock; +use DateTime; use Prophecy\Argument; class GitDownloaderTest extends TestCase @@ -118,7 +120,11 @@ class GitDownloaderTest extends TestCase $processExecutor->expects($this->at(1)) ->method('execute') ->with($this->equalTo($this->winCompat("git branch -r")), $this->equalTo(null), $this->equalTo($this->winCompat('composerPath'))) - ->will($this->returnValue(0)); + ->will($this->returnCallback(function ($cmd, &$output) { + $output = ''; + + return 0; + })); $processExecutor->expects($this->at(2)) ->method('execute') @@ -189,7 +195,11 @@ class GitDownloaderTest extends TestCase $processExecutor->expects($this->at(4)) ->method('execute') ->with($this->equalTo($this->winCompat("git branch -r")), $this->equalTo(null), $this->equalTo($this->winCompat('composerPath'))) - ->will($this->returnValue(0)); + ->will($this->returnCallback(function ($cmd, &$output, $path) { + $output = ''; + + return 0; + })); $processExecutor->expects($this->at(5)) ->method('execute') @@ -253,7 +263,11 @@ class GitDownloaderTest extends TestCase $processExecutor->expects($this->at(5)) ->method('execute') ->with($this->equalTo('git branch -r')) - ->will($this->returnValue(0)); + ->will($this->returnCallback(function ($cmd, &$output, $path) { + $output = ''; + + return 0; + })); $processExecutor->expects($this->at(6)) ->method('execute') @@ -297,37 +311,29 @@ class GitDownloaderTest extends TestCase $packageMock->expects($this->any()) ->method('getPrettyVersion') ->will($this->returnValue('1.0.0')); - $processExecutor = $this->getMockBuilder('Composer\Util\ProcessExecutor')->setMethods(array('execute'))->getMock(); - $expectedGitCommand = $this->winCompat("git clone --no-checkout -- '{$url}' 'composerPath' && cd 'composerPath' && git remote add composer -- '{$url}' && git fetch composer && git remote set-url origin -- '{$url}' && git remote set-url composer -- '{$url}'"); - $processExecutor->expects($this->at(0)) - ->method('execute') - ->with($this->equalTo($expectedGitCommand)) - ->will($this->returnValue(0)); - - $expectedGitCommand = $this->winCompat("git remote set-url --push origin -- '{$pushUrl}'"); - $processExecutor->expects($this->at(1)) - ->method('execute') - ->with($this->equalTo($expectedGitCommand), $this->equalTo(null), $this->equalTo($this->winCompat('composerPath'))) - ->will($this->returnValue(0)); - - $processExecutor->expects($this->exactly(4)) - ->method('execute') - ->will($this->returnValue(0)); + $process = new ProcessExecutorMock; + $process->expects(array( + $this->winCompat("git clone --no-checkout -- '{$url}' 'composerPath' && cd 'composerPath' && git remote add composer -- '{$url}' && git fetch composer && git remote set-url origin -- '{$url}' && git remote set-url composer -- '{$url}'"), + $this->winCompat("git remote set-url --push origin -- '{$pushUrl}'"), + 'git branch -r', + $this->winCompat("git checkout 'ref' -- && git reset --hard 'ref' --"), + ), true); $config = new Config(); $config->merge(array('config' => array('github-protocols' => $protocols))); - $downloader = $this->getDownloaderMock(null, $config, $processExecutor); + $downloader = $this->getDownloaderMock(null, $config, $process); $downloader->download($packageMock, 'composerPath'); $downloader->prepare('install', $packageMock, 'composerPath'); $downloader->install($packageMock, 'composerPath'); $downloader->cleanup('install', $packageMock, 'composerPath'); + + $process->assertComplete($this); } public function testDownloadThrowsRuntimeExceptionIfGitCommandFails() { - $expectedGitCommand = $this->winCompat("git clone --no-checkout -- 'https://example.com/composer/composer' 'composerPath' && cd 'composerPath' && git remote add composer -- 'https://example.com/composer/composer' && git fetch composer && git remote set-url origin -- 'https://example.com/composer/composer' && git remote set-url composer -- 'https://example.com/composer/composer'"); $packageMock = $this->getMockBuilder('Composer\Package\PackageInterface')->getMock(); $packageMock->expects($this->any()) ->method('getSourceReference') @@ -335,19 +341,31 @@ class GitDownloaderTest extends TestCase $packageMock->expects($this->any()) ->method('getSourceUrls') ->will($this->returnValue(array('https://example.com/composer/composer'))); - $processExecutor = $this->getMockBuilder('Composer\Util\ProcessExecutor')->setMethods(array('execute'))->getMock(); - $processExecutor->expects($this->at(0)) - ->method('execute') - ->with($this->equalTo($expectedGitCommand)) - ->will($this->returnValue(1)); + $packageMock->expects($this->any()) + ->method('getSourceUrl') + ->will($this->returnValue('https://example.com/composer/composer')); + $packageMock->expects($this->any()) + ->method('getPrettyVersion') + ->will($this->returnValue('1.0.0')); + + $process = new ProcessExecutorMock; + $process->expects(array( + array( + 'cmd' => $this->winCompat("git clone --no-checkout -- 'https://example.com/composer/composer' 'composerPath' && cd 'composerPath' && git remote add composer -- 'https://example.com/composer/composer' && git fetch composer && git remote set-url origin -- 'https://example.com/composer/composer' && git remote set-url composer -- 'https://example.com/composer/composer'"), + 'return' => 1, + ), + )); // not using PHPUnit's expected exception because Prophecy exceptions extend from RuntimeException too so it is not safe try { - $downloader = $this->getDownloaderMock(null, null, $processExecutor); + $downloader = $this->getDownloaderMock(null, null, $process); $downloader->download($packageMock, 'composerPath'); $downloader->prepare('install', $packageMock, 'composerPath'); $downloader->install($packageMock, 'composerPath'); $downloader->cleanup('install', $packageMock, 'composerPath'); + + $process->assertComplete($this); + $this->fail('This test should throw'); } catch (\RuntimeException $e) { if ('RuntimeException' !== get_class($e)) { @@ -388,21 +406,29 @@ class GitDownloaderTest extends TestCase $packageMock->expects($this->any()) ->method('getVersion') ->will($this->returnValue('1.0.0.0')); + $packageMock->expects($this->any()) + ->method('getPrettyVersion') + ->will($this->returnValue('1.0.0')); - $process = $this->prophesize('Composer\Util\ProcessExecutor'); - $process->execute($this->winCompat('git show-ref --head -d'), Argument::cetera())->willReturn(0); - $process->execute($this->winCompat('git status --porcelain --untracked-files=no'), Argument::cetera())->willReturn(0); - $process->execute($this->winCompat('git remote -v'), Argument::cetera())->willReturn(0); - $process->execute($this->winCompat('git branch -r'), Argument::cetera())->willReturn(0); - $process->execute($expectedGitUpdateCommand, null, $this->winCompat($this->workingDir))->willReturn(0)->shouldBeCalled(); - $process->execute($this->winCompat("git checkout 'ref' -- && git reset --hard 'ref' --"), null, $this->winCompat($this->workingDir))->willReturn(0)->shouldBeCalled(); + $process = new ProcessExecutorMock; + $process->expects(array( + $this->winCompat('git show-ref --head -d'), + $this->winCompat('git status --porcelain --untracked-files=no'), + $this->winCompat('git remote -v'), + $expectedGitUpdateCommand, + $this->winCompat('git branch -r'), + $this->winCompat("git checkout 'ref' -- && git reset --hard 'ref' --"), + $this->winCompat('git remote -v'), + ), true); $this->fs->ensureDirectoryExists($this->workingDir.'/.git'); - $downloader = $this->getDownloaderMock(null, new Config(), $process->reveal()); + $downloader = $this->getDownloaderMock(null, new Config(), $process); $downloader->download($packageMock, $this->workingDir, $packageMock); $downloader->prepare('update', $packageMock, $this->workingDir, $packageMock); $downloader->update($packageMock, $packageMock, $this->workingDir); $downloader->cleanup('update', $packageMock, $this->workingDir, $packageMock); + + $process->assertComplete($this); } public function testUpdateWithNewRepoUrl() @@ -422,59 +448,38 @@ class GitDownloaderTest extends TestCase $packageMock->expects($this->any()) ->method('getVersion') ->will($this->returnValue('1.0.0.0')); + $packageMock->expects($this->any()) + ->method('getPrettyVersion') + ->will($this->returnValue('1.0.0')); - $processExecutor = $this->getMockBuilder('Composer\Util\ProcessExecutor')->setMethods(array('execute'))->getMock(); - $processExecutor->expects($this->at(0)) - ->method('execute') - ->with($this->equalTo($this->winCompat("git show-ref --head -d"))) - ->will($this->returnValue(0)); - $processExecutor->expects($this->at(1)) - ->method('execute') - ->with($this->equalTo($this->winCompat("git status --porcelain --untracked-files=no"))) - ->will($this->returnValue(0)); - $processExecutor->expects($this->at(2)) - ->method('execute') - ->with($this->equalTo($this->winCompat("git remote -v"))) - ->will($this->returnValue(0)); - $processExecutor->expects($this->at(3)) - ->method('execute') - ->with($this->equalTo($this->winCompat($expectedGitUpdateCommand)), $this->equalTo(null), $this->equalTo($this->winCompat($this->workingDir))) - ->will($this->returnValue(0)); - $processExecutor->expects($this->at(4)) - ->method('execute') - ->with($this->equalTo('git branch -r')) - ->will($this->returnValue(0)); - $processExecutor->expects($this->at(5)) - ->method('execute') - ->with($this->equalTo($this->winCompat("git checkout 'ref' -- && git reset --hard 'ref' --")), $this->equalTo(null), $this->equalTo($this->winCompat($this->workingDir))) - ->will($this->returnValue(0)); - $processExecutor->expects($this->at(6)) - ->method('execute') - ->with($this->equalTo($this->winCompat("git remote -v"))) - ->will($this->returnCallback(function ($cmd, &$output, $cwd) { - $output = 'origin https://github.com/old/url (fetch) + $process = new ProcessExecutorMock; + $process->expects(array( + $this->winCompat("git show-ref --head -d"), + $this->winCompat("git status --porcelain --untracked-files=no"), + $this->winCompat("git remote -v"), + $this->winCompat($expectedGitUpdateCommand), + 'git branch -r', + $this->winCompat("git checkout 'ref' -- && git reset --hard 'ref' --"), + array( + 'cmd' => $this->winCompat("git remote -v"), + 'stdout' => 'origin https://github.com/old/url (fetch) origin https://github.com/old/url (push) composer https://github.com/old/url (fetch) composer https://github.com/old/url (push) -'; - - return 0; - })); - $processExecutor->expects($this->at(7)) - ->method('execute') - ->with($this->equalTo($this->winCompat("git remote set-url origin -- 'https://github.com/composer/composer'")), $this->equalTo(null), $this->equalTo($this->winCompat($this->workingDir))) - ->will($this->returnValue(0)); - $processExecutor->expects($this->at(8)) - ->method('execute') - ->with($this->equalTo($this->winCompat("git remote set-url --push origin -- 'git@github.com:composer/composer.git'")), $this->equalTo(null), $this->equalTo($this->winCompat($this->workingDir))) - ->will($this->returnValue(0)); +', + ), + $this->winCompat("git remote set-url origin -- 'https://github.com/composer/composer'"), + $this->winCompat("git remote set-url --push origin -- 'git@github.com:composer/composer.git'"), + ), true); $this->fs->ensureDirectoryExists($this->workingDir.'/.git'); - $downloader = $this->getDownloaderMock(null, new Config(), $processExecutor); + $downloader = $this->getDownloaderMock(null, new Config(), $process); $downloader->download($packageMock, $this->workingDir, $packageMock); $downloader->prepare('update', $packageMock, $this->workingDir, $packageMock); $downloader->update($packageMock, $packageMock, $this->workingDir); $downloader->cleanup('update', $packageMock, $this->workingDir, $packageMock); + + $process->assertComplete($this); } /** diff --git a/tests/Composer/Test/Mock/ProcessExecutorMock.php b/tests/Composer/Test/Mock/ProcessExecutorMock.php new file mode 100644 index 000000000..370e65147 --- /dev/null +++ b/tests/Composer/Test/Mock/ProcessExecutorMock.php @@ -0,0 +1,143 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Mock; + +use Composer\Util\ProcessExecutor; +use Composer\Util\Platform; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Process\Process; +use React\Promise\Promise; + +/** + * @author Jordi Boggiano + */ +class ProcessExecutorMock extends ProcessExecutor +{ + private $expectations = array(); + private $strict = false; + private $defaultHandler = array('return' => 0, 'stdout' => '', 'stderr' => ''); + private $log = array(); + + /** + * @param array $expectations + * @param bool $strict set to true if you want to provide *all* expected commands, and not just a subset you are interested in testing + * @param array{return: int, stdout?: string, stderr?: string} $defaultHandler default command handler for undefined commands if not in strict mode + */ + public function expects(array $expectations, $strict = false, array $defaultHandler = array('return' => 0, 'stdout' => '', 'stderr' => '')) + { + $default = array('return' => 0, 'stdout' => '', 'stderr' => ''); + $this->expectations = array_map(function ($expect) use ($default) { + if (is_string($expect)) { + $expect = array('cmd' => $expect); + } + + return array_merge($default, $expect); + }, $expectations); + $this->strict = $strict; + $this->defaultHandler = array_merge($default, $defaultHandler); + } + + public function assertComplete(TestCase $testCase) + { + if ($this->expectations) { + $expectations = array_map(function ($expect) { + return $expect['cmd']; + }, $this->expectations); + throw new \LogicException( + 'There are still '.count($this->expectations).' expected process calls which have not been consumed:'.PHP_EOL. + implode(PHP_EOL, $expectations).PHP_EOL.PHP_EOL. + 'Received calls:'.PHP_EOL.implode(PHP_EOL, $this->log) + ); + } + + $testCase->assertTrue(true); + } + + public function execute($command, &$output = null, $cwd = null) + { + if (func_num_args() > 1) { + return $this->doExecute($command, $cwd, false, $output); + } + + return $this->doExecute($command, $cwd, false); + } + + public function executeTty($command, $cwd = null) + { + if (Platform::isTty()) { + return $this->doExecute($command, $cwd, true); + } + + return $this->doExecute($command, $cwd, false); + } + + private function doExecute($command, $cwd, $tty, &$output = null) + { + $this->captureOutput = func_num_args() > 3; + $this->errorOutput = ''; + + $callback = is_callable($output) ? $output : array($this, 'outputHandler'); + + if ($this->expectations && $command === $this->expectations[0]['cmd']) { + $expect = array_shift($this->expectations); + $stdout = $expect['stdout']; + $stderr = $expect['stderr']; + $return = $expect['return']; + } elseif (!$this->strict) { + $stdout = $this->defaultHandler['stdout']; + $stderr = $this->defaultHandler['stderr']; + $return = $this->defaultHandler['return']; + } else { + throw new \LogicException( + 'Received unexpected command "'.$command.'" in "'.$cwd.'"'.PHP_EOL. + ($this->expectations ? 'Expected "'.$this->expectations[0]['cmd'].'" at this point.' : 'Expected no more calls at this point.') + ); + } + + if ($stdout) { + call_user_func($callback, Process::STDOUT, $stdout); + } + if ($stderr) { + call_user_func($callback, Process::ERR, $stderr); + } + + if ($this->captureOutput && !is_callable($output)) { + $output = $stdout; + } + + $this->log[] = $command; + + $this->errorOutput = $stderr; + + return $return; + } + + public function executeAsync($command, $cwd = null) + { + $resolver = function ($resolve, $reject) { + // TODO strictly speaking this should resolve with a mock Process instance here + $resolve(); + }; + + $canceler = function () { + throw new \RuntimeException('Aborted process'); + }; + + return new Promise($resolver, $canceler); + } + + public function getErrorOutput() + { + return $this->errorOutput; + } +}