diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..661bd9a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: php + +php: + - 7.1 + - 7.2 + - nightly + +matrix: + fast_finish: true + allow_failures: + - php: nightly + +before_script: + - composer validate + - composer install --prefer-dist --no-interaction --no-progress --no-suggest --optimize-autoloader --verbose --profile + +script: + - vendor/bin/phpunit tests/ \ No newline at end of file diff --git a/composer.json b/composer.json index 358607e..d8fe2e4 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,11 @@ "SilverStripe\\RecipePlugin\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "SilverStripe\\Test\\RecipePlugin\\": "src/" + } + }, "extra": { "class": "SilverStripe\\RecipePlugin\\RecipePlugin", "branch-alias": { @@ -24,6 +29,7 @@ "composer-plugin-api": "^1.1" }, "require-dev": { + "phpunit/phpunit": "^7", "composer/composer": "^1.2" }, "minimum-stability": "dev" diff --git a/src/RecipeInstaller.php b/src/RecipeInstaller.php index d14b999..140ddab 100644 --- a/src/RecipeInstaller.php +++ b/src/RecipeInstaller.php @@ -8,6 +8,7 @@ use Composer\IO\IOInterface; use Composer\Json\JsonFile; use Composer\Package\PackageInterface; +use Composer\Util\Filesystem; use FilesystemIterator; use Iterator; use RecursiveDirectoryIterator; @@ -16,8 +17,16 @@ class RecipeInstaller extends LibraryInstaller { - public function __construct(IOInterface $io, Composer $composer) { - parent::__construct($io, $composer, null); + /** + * RecipeInstaller constructor. + * + * @param IOInterface $io + * @param Composer $composer + * @param string $type + * @param Filesystem $filesystem + */ + public function __construct(IOInterface $io, Composer $composer, $type = null, Filesystem $filesystem = null) { + parent::__construct($io, $composer, $type, $filesystem); } /** @@ -32,19 +41,23 @@ public function __construct(IOInterface $io, Composer $composer) { */ protected function installProjectFiles($recipe, $sourceRoot, $destinationRoot, $filePatterns, $registrationKey, $name = 'project') { - // load composer json data - $composerFile = new JsonFile(Factory::getComposerFile(), null, $this->io); - $composerData = $composerFile->read(); - $installedFiles = isset($composerData['extra'][$registrationKey]) - ? $composerData['extra'][$registrationKey] - : []; + // fetch the installed files from the json data + $installedFiles = $this->getInstalledFiles($registrationKey); // Load all project files $fileIterator = $this->getFileIterator($sourceRoot, $filePatterns); $any = false; foreach($fileIterator as $path => $info) { $destination = $destinationRoot . substr($path, strlen($sourceRoot)); + $destinationExt = pathinfo($destination, PATHINFO_EXTENSION); + if ($destinationExt === 'tmpl') { + $destination = substr($destination, 0, -5); + } $relativePath = substr($path, strlen($sourceRoot) + 1); // Name path without leading '/' + $relativePathExt = pathinfo($relativePath, PATHINFO_EXTENSION); + if ($relativePathExt === 'tmpl') { + $relativePath = substr($relativePath, 0, -5); + } // Write header if (!$any) { @@ -53,8 +66,8 @@ protected function installProjectFiles($recipe, $sourceRoot, $destinationRoot, $ } // Check if file exists - if (file_exists($destination)) { - if (file_get_contents($destination) === file_get_contents($path)) { + if ($this->fileExists($destination)) { + if ($this->fileGetContents($destination) === $this->fileGetContents($path)) { $this->io->write( " - Skipping $relativePath (existing, but unchanged)" ); @@ -72,7 +85,7 @@ protected function installProjectFiles($recipe, $sourceRoot, $destinationRoot, $ $any++; $this->io->write(" - Copying $relativePath"); $this->filesystem->ensureDirectoryExists(dirname($destination)); - copy($path, $destination); + $this->filesystem->copy($path, $destination); } // Add file to installed (even if already exists) @@ -84,6 +97,8 @@ protected function installProjectFiles($recipe, $sourceRoot, $destinationRoot, $ // If any files are written, modify composer.json with newly installed files if ($installedFiles) { sort($installedFiles); + $composerFile = $this->getComposerFile(); + $composerData = $composerFile->read(); if (!isset($composerData['extra'])) { $composerData['extra'] = []; } @@ -92,6 +107,31 @@ protected function installProjectFiles($recipe, $sourceRoot, $destinationRoot, $ } } + public function fileExists($filename) + { + return file_exists($filename); + } + + public function fileGetContents($filename, $use_include_path = false, $context = null, $offset = 0, $maxlen = null) + { + return file_get_contents($filename, $use_include_path, $context, $offset, $maxlen); + } + + protected function getComposerFile() + { + return new JsonFile(Factory::getComposerFile(), null, $this->io); + } + + protected function getInstalledFiles($registrationKey) + { + // load composer json data + $composerFile = $this->getComposerFile(); + $composerData = $composerFile->read(); + return isset($composerData['extra'][$registrationKey]) + ? $composerData['extra'][$registrationKey] + : []; + } + /** * Get iterator of matching source files to copy * diff --git a/tests/RecipeInstallerTest.php b/tests/RecipeInstallerTest.php new file mode 100644 index 0000000..1c60ea3 --- /dev/null +++ b/tests/RecipeInstallerTest.php @@ -0,0 +1,412 @@ +getMockBuilder(IOInterface::class) + ->setMethods([]) + ->getMock(); + $io->expects($this->exactly(2))->method('write')->willReturnCallback(function ($message) use (&$messages) { + $messages[] = $message; + }); + $composer = $this->getMockBuilder(Composer::class) + ->setMethods([ + 'getConfig', + ])->getMock(); + $composer->method('getConfig')->willReturn(new Config()); + + $filesystem = $this->getMockBuilder(Filesystem::class)->setMethods([])->getMock(); + $filesystem->expects($this->once())->method('ensureDirectoryExists')->with( + $destinationRoot + ); + $filesystem->expects($this->once())->method('copy')->with( + $sourceRoot . '/file.php.tmpl', + $destinationRoot . '/file.php' + ); + + $mockInstaller = $this->getMockBuilder(RecipeInstaller::class) + ->setConstructorArgs([ + $io, + $composer, + null, + $filesystem, + ]) + ->setMethods([ + 'getFileIterator', + 'getInstalledFiles', + 'fileExists', + 'getComposerFile', + ]) + ->getMock(); + $mockInstaller->method('getFileIterator')->willReturn([ + $sourceRoot . '/file.php.tmpl' => [], + ]); + $mockInstaller->method('fileExists')->willReturn(false); + $mockInstaller->method('getInstalledFiles')->willReturn([]); + $mockInstaller->method('getComposerFile')->willReturn( + $jsonFile = $this->getMockBuilder(JsonFile::class) + ->disableOriginalConstructor() + ->setMethods([]) + ->getMock() + ); + + $jsonFile->expects($this->once())->method('write')->willReturnCallback(function ($data) use ($registrationKey) { + $this->assertArrayHasKey('extra', $data); + $this->assertArrayHasKey($registrationKey, $data['extra']); + $this->assertCount(1, $data['extra'][$registrationKey]); + $this->assertContains('file.php', $data['extra'][$registrationKey]); + }); + + $reflectionClass = new \ReflectionClass($mockInstaller); + $reflectionMethod = $reflectionClass->getMethod('installProjectFiles'); + $reflectionMethod->setAccessible(true); + $reflectionMethod->invokeArgs($mockInstaller, [ + $recipeName, + $sourceRoot, + $destinationRoot, + '*.php', + $registrationKey, + $projectName, + ]); + + // perhaps theses tests are needlessly tightly coupled to the output + $this->assertCount(2, $messages); + $this->assertContains(sprintf('Installing %s files for recipe %s', $projectName, $recipeName), $messages[0]); + $this->assertContains('Copying file.php', $messages[1]); + } + + public function testInstallProjectFilesExistsSame() + { + $recipeName = 'test'; + $sourceRoot = '/source'; + $destinationRoot = '/destination'; + $registrationKey = 'key'; + $projectName = 'test project'; + + $messages = []; + $io = $this->getMockBuilder(IOInterface::class) + ->setMethods([]) + ->getMock(); + $io->expects($this->exactly(2))->method('write')->willReturnCallback(function ($message) use (&$messages) { + $messages[] = $message; + }); + $composer = $this->getMockBuilder(Composer::class) + ->setMethods([ + 'getConfig', + ])->getMock(); + $composer->method('getConfig')->willReturn(new Config()); + + $filesystem = $this->getMockBuilder(Filesystem::class)->setMethods([])->getMock(); + $filesystem->expects($this->never())->method('copy'); + + $mockInstaller = $this->getMockBuilder(RecipeInstaller::class) + ->setConstructorArgs([ + $io, + $composer, + null, + $filesystem, + ]) + ->setMethods([ + 'getFileIterator', + 'getInstalledFiles', + 'fileExists', + 'fileGetContents', + 'getComposerFile', + ]) + ->getMock(); + $mockInstaller->method('getFileIterator')->willReturn([ + $sourceRoot . '/file.php.tmpl' => [], + ]); + $mockInstaller->method('fileExists')->willReturn(true); + $mockInstaller->expects($this->exactly(2))->method('fileGetContents')->willReturn('contents'); + $mockInstaller->method('getInstalledFiles')->willReturn([]); + $mockInstaller->method('getComposerFile')->willReturn( + $jsonFile = $this->getMockBuilder(JsonFile::class) + ->disableOriginalConstructor() + ->setMethods([]) + ->getMock() + ); + + $jsonFile->expects($this->once())->method('write')->willReturnCallback(function ($data) use ($registrationKey) { + $this->assertArrayHasKey('extra', $data); + $this->assertArrayHasKey($registrationKey, $data['extra']); + $this->assertCount(1, $data['extra'][$registrationKey]); + $this->assertContains('file.php', $data['extra'][$registrationKey]); + }); + + $reflectionClass = new \ReflectionClass($mockInstaller); + $reflectionMethod = $reflectionClass->getMethod('installProjectFiles'); + $reflectionMethod->setAccessible(true); + $reflectionMethod->invokeArgs($mockInstaller, [ + $recipeName, + $sourceRoot, + $destinationRoot, + '*.php', + $registrationKey, + $projectName, + ]); + + // perhaps theses tests are needlessly tightly coupled to the output + $this->assertCount(2, $messages); + $this->assertContains(sprintf('Installing %s files for recipe %s', $projectName, $recipeName), $messages[0]); + $this->assertContains('Skipping file.php (existing, but unchanged)', $messages[1]); + } + + public function testInstallProjectFilesExistsDifferent() + { + $recipeName = 'test'; + $sourceRoot = '/source'; + $destinationRoot = '/destination'; + $registrationKey = 'key'; + $projectName = 'test project'; + + $messages = []; + $io = $this->getMockBuilder(IOInterface::class) + ->setMethods([]) + ->getMock(); + $io->expects($this->exactly(2))->method('write')->willReturnCallback(function ($message) use (&$messages) { + $messages[] = $message; + }); + $composer = $this->getMockBuilder(Composer::class) + ->setMethods([ + 'getConfig', + ])->getMock(); + $composer->method('getConfig')->willReturn(new Config()); + + $filesystem = $this->getMockBuilder(Filesystem::class)->setMethods([])->getMock(); + $filesystem->expects($this->never())->method('copy'); + + $mockInstaller = $this->getMockBuilder(RecipeInstaller::class) + ->setConstructorArgs([ + $io, + $composer, + null, + $filesystem, + ]) + ->setMethods([ + 'getFileIterator', + 'getInstalledFiles', + 'fileExists', + 'fileGetContents', + 'getComposerFile', + ]) + ->getMock(); + $mockInstaller->method('getFileIterator')->willReturn([ + $sourceRoot . '/file.php.tmpl' => [], + ]); + $mockInstaller->method('fileExists')->willReturn(true); + $mockInstaller->expects($this->exactly(2))->method('fileGetContents')->willReturnOnConsecutiveCalls( + 'contents', 'different contents' + ); + $mockInstaller->method('getInstalledFiles')->willReturn([]); + $mockInstaller->method('getComposerFile')->willReturn( + $jsonFile = $this->getMockBuilder(JsonFile::class) + ->disableOriginalConstructor() + ->setMethods([]) + ->getMock() + ); + + $jsonFile->expects($this->once())->method('write')->willReturnCallback(function ($data) use ($registrationKey) { + $this->assertArrayHasKey('extra', $data); + $this->assertArrayHasKey($registrationKey, $data['extra']); + $this->assertCount(1, $data['extra'][$registrationKey]); + $this->assertContains('file.php', $data['extra'][$registrationKey]); + }); + + $reflectionClass = new \ReflectionClass($mockInstaller); + $reflectionMethod = $reflectionClass->getMethod('installProjectFiles'); + $reflectionMethod->setAccessible(true); + $reflectionMethod->invokeArgs($mockInstaller, [ + $recipeName, + $sourceRoot, + $destinationRoot, + '*.php', + $registrationKey, + $projectName, + ]); + + // perhaps theses tests are needlessly tightly coupled to the output + $this->assertCount(2, $messages); + $this->assertContains(sprintf('Installing %s files for recipe %s', $projectName, $recipeName), $messages[0]); + $this->assertContains('Skipping file.php (existing and modified in project)', $messages[1]); + } + + public function testInstallProjectFilesRemoved() + { + $recipeName = 'test'; + $sourceRoot = '/source'; + $destinationRoot = '/destination'; + $registrationKey = 'key'; + $projectName = 'test project'; + + $messages = []; + $io = $this->getMockBuilder(IOInterface::class) + ->setMethods([]) + ->getMock(); + $io->expects($this->exactly(2))->method('write')->willReturnCallback(function ($message) use (&$messages) { + $messages[] = $message; + }); + $composer = $this->getMockBuilder(Composer::class) + ->setMethods([ + 'getConfig', + ])->getMock(); + $composer->method('getConfig')->willReturn(new Config()); + + $filesystem = $this->getMockBuilder(Filesystem::class)->setMethods([])->getMock(); + $filesystem->expects($this->never())->method('copy'); + + $mockInstaller = $this->getMockBuilder(RecipeInstaller::class) + ->setConstructorArgs([ + $io, + $composer, + null, + $filesystem, + ]) + ->setMethods([ + 'getFileIterator', + 'getInstalledFiles', + 'fileExists', + 'fileGetContents', + 'getComposerFile', + ]) + ->getMock(); + $mockInstaller->method('getFileIterator')->willReturn([ + $sourceRoot . '/file.php.tmpl' => [], + ]); + $mockInstaller->method('fileExists')->willReturn(false); + $mockInstaller->expects($this->never())->method('fileGetContents'); + $mockInstaller->method('getInstalledFiles')->willReturn([ + 'file.php', + ]); + $mockInstaller->method('getComposerFile')->willReturn( + $jsonFile = $this->getMockBuilder(JsonFile::class) + ->disableOriginalConstructor() + ->setMethods([]) + ->getMock() + ); + + $jsonFile->expects($this->once())->method('write')->willReturnCallback(function ($data) use ($registrationKey) { + $this->assertArrayHasKey('extra', $data); + $this->assertArrayHasKey($registrationKey, $data['extra']); + $this->assertCount(1, $data['extra'][$registrationKey]); + $this->assertContains('file.php', $data['extra'][$registrationKey]); + }); + + $reflectionClass = new \ReflectionClass($mockInstaller); + $reflectionMethod = $reflectionClass->getMethod('installProjectFiles'); + $reflectionMethod->setAccessible(true); + $reflectionMethod->invokeArgs($mockInstaller, [ + $recipeName, + $sourceRoot, + $destinationRoot, + '*.php', + $registrationKey, + $projectName, + ]); + + // perhaps theses tests are needlessly tightly coupled to the output + $this->assertCount(2, $messages); + $this->assertContains(sprintf('Installing %s files for recipe %s', $projectName, $recipeName), $messages[0]); + $this->assertContains('Skipping file.php (previously installed)', $messages[1]); + } + + public function testInstallProjectFilesWithoutTmplExtension() + { + $recipeName = 'test'; + $sourceRoot = '/source'; + $destinationRoot = '/destination'; + $registrationKey = 'key'; + $projectName = 'test project'; + + $messages = []; + $io = $this->getMockBuilder(IOInterface::class) + ->setMethods([]) + ->getMock(); + $io->expects($this->exactly(2))->method('write')->willReturnCallback(function ($message) use (&$messages) { + $messages[] = $message; + }); + $composer = $this->getMockBuilder(Composer::class) + ->setMethods([ + 'getConfig', + ])->getMock(); + $composer->method('getConfig')->willReturn(new Config()); + + $filesystem = $this->getMockBuilder(Filesystem::class)->setMethods([])->getMock(); + $filesystem->expects($this->once())->method('ensureDirectoryExists')->with( + $destinationRoot + ); + $filesystem->expects($this->once())->method('copy')->with( + $sourceRoot . '/file.php', + $destinationRoot . '/file.php' + ); + + $mockInstaller = $this->getMockBuilder(RecipeInstaller::class) + ->setConstructorArgs([ + $io, + $composer, + null, + $filesystem, + ]) + ->setMethods([ + 'getFileIterator', + 'getInstalledFiles', + 'fileExists', + 'getComposerFile', + ]) + ->getMock(); + $mockInstaller->method('getFileIterator')->willReturn([ + $sourceRoot . '/file.php' => [], + ]); + $mockInstaller->method('fileExists')->willReturn(false); + $mockInstaller->method('getInstalledFiles')->willReturn([]); + $mockInstaller->method('getComposerFile')->willReturn( + $jsonFile = $this->getMockBuilder(JsonFile::class) + ->disableOriginalConstructor() + ->setMethods([]) + ->getMock() + ); + + $jsonFile->expects($this->once())->method('write')->willReturnCallback(function ($data) use ($registrationKey) { + $this->assertArrayHasKey('extra', $data); + $this->assertArrayHasKey($registrationKey, $data['extra']); + $this->assertCount(1, $data['extra'][$registrationKey]); + $this->assertContains('file.php', $data['extra'][$registrationKey]); + }); + + $reflectionClass = new \ReflectionClass($mockInstaller); + $reflectionMethod = $reflectionClass->getMethod('installProjectFiles'); + $reflectionMethod->setAccessible(true); + $reflectionMethod->invokeArgs($mockInstaller, [ + $recipeName, + $sourceRoot, + $destinationRoot, + '*.php', + $registrationKey, + $projectName, + ]); + + // perhaps theses tests are needlessly tightly coupled to the output + $this->assertCount(2, $messages); + $this->assertContains(sprintf('Installing %s files for recipe %s', $projectName, $recipeName), $messages[0]); + $this->assertContains('Copying file.php', $messages[1]); + } +}