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]);
+ }
+}