diff --git a/Makefile b/Makefile index 34c6a1d..6de61c0 100644 --- a/Makefile +++ b/Makefile @@ -18,5 +18,5 @@ install: .PHONY: test coverage open-coverage lint install test/bin/php-cs-fixer: - curl -sSL http://cs.sensiolabs.org/download/php-cs-fixer-v2.phar -o test/bin/php-cs-fixer + curl -sSL https://cs.sensiolabs.org/download/php-cs-fixer-v2.phar -o test/bin/php-cs-fixer chmod +x test/bin/php-cs-fixer diff --git a/README.md b/README.md index 23600ae..43fab7b 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,31 @@ a dependency. [npm]: https://npmjs.org/ [update]: https://npmjs.org/doc/update.html +## Configuration + +The following configuration can be added to `composer.json` `extra/npm-bridge` +to customize the behaviour on a per-package basis. Values in the root package +will not currently impact any dependency packages that also use +`composer-npm-bridge` - each package must define its own options. + +Key|Description +-|- +`timeout`|Specify a custom timeout for the installation. The default is 300 seconds. +`optional`|Skip instead of throwing an exception if `npm` is not found when processing the package. + + ... + "extra": { + "npm-bridge": { + "timeout": 9000, + "optional": true + }, + ... + } + +To disable `composer-npm-bridge` entirely for a single run, set `COMPOSER_NPM_BRIDGE_DISABLE=1` on the environment. + + COMPOSER_NPM_BRIDGE_DISABLE=1 composer install + ## Caveats Because NPM dependencies are installed underneath the root directory of the diff --git a/src/NpmBridge.php b/src/NpmBridge.php index a626d3f..afe5467 100644 --- a/src/NpmBridge.php +++ b/src/NpmBridge.php @@ -13,6 +13,10 @@ */ class NpmBridge { + const EXTRA_KEY = 'npm-bridge'; + const EXTRA_KEY_OPTIONAL = 'optional'; + const EXTRA_KEY_TIMEOUT = 'timeout'; + /** * Construct a new Composer NPM bridge plugin. * @@ -47,8 +51,12 @@ public function install(Composer $composer, bool $isDevMode = true) 'Installing NPM dependencies for root project' ); - if ($this->isDependantPackage($composer->getPackage(), $isDevMode)) { - $this->client->install(null, $isDevMode); + $package = $composer->getPackage(); + if ($this->isDependantPackage($package, $isDevMode)) { + if (!$this->shouldSkipPackage($package)) { + $this->configureClient($package); + $this->client->install(null, $isDevMode); + } } else { $this->io->write('Nothing to install'); } @@ -70,9 +78,13 @@ public function update(Composer $composer) 'Updating NPM dependencies for root project' ); - if ($this->isDependantPackage($composer->getPackage(), true)) { - $this->client->update(); - $this->client->install(null, true); + $package = $composer->getPackage(); + if ($this->isDependantPackage($package, true)) { + if (!$this->shouldSkipPackage($package)) { + $this->configureClient($package); + $this->client->update(); + $this->client->install(null, true); + } } else { $this->io->write('Nothing to update'); } @@ -119,6 +131,10 @@ private function installForVendors($composer) if (count($packages) > 0) { foreach ($packages as $package) { + if ($this->shouldSkipPackage($package)) { + continue; + } + $this->io->write( sprintf( 'Installing NPM dependencies for %s', @@ -126,6 +142,7 @@ private function installForVendors($composer) ) ); + $this->configureClient($package); $this->client->install( $composer->getInstallationManager() ->getInstallPath($package), @@ -137,6 +154,38 @@ private function installForVendors($composer) } } + private function configureClient(PackageInterface $package) + { + $extra = $package->getExtra(); + // Issue #13 - npm can take a while, so allow a custom timeout + if (isset($extra[self::EXTRA_KEY][self::EXTRA_KEY_TIMEOUT])) { + $this->client->setTimeout(intval($extra[self::EXTRA_KEY][self::EXTRA_KEY_TIMEOUT])); + } else { + $this->client->setTimeout(null); + } + } + + private function shouldSkipPackage(PackageInterface $package) + { + if ($this->client->valid()) { + return false; + } + + $extra = $package->getExtra(); + if (!empty($extra[self::EXTRA_KEY][self::EXTRA_KEY_OPTIONAL])) { + $this->io->write( + sprintf( + 'Skipping optional NPM dependencies for %s as npm is unavailable', + $package->getPrettyName() + ) + ); + + return true; + } + + return false; + } + private $io; private $vendorFinder; private $client; diff --git a/src/NpmBridgePlugin.php b/src/NpmBridgePlugin.php index a9a9d75..c292273 100644 --- a/src/NpmBridgePlugin.php +++ b/src/NpmBridgePlugin.php @@ -52,9 +52,15 @@ class_exists(NpmVendorFinder::class); */ public static function getSubscribedEvents(): array { + // Issue #18 - disable if ENV set + if (!empty(getenv('COMPOSER_NPM_BRIDGE_DISABLE'))) { + return []; + } + + // Increased priority to ensure we run before custom installers which are usually default priority return [ - ScriptEvents::POST_INSTALL_CMD => 'onPostInstallCmd', - ScriptEvents::POST_UPDATE_CMD => 'onPostUpdateCmd', + ScriptEvents::POST_INSTALL_CMD => ['onPostInstallCmd', 1], + ScriptEvents::POST_UPDATE_CMD => ['onPostUpdateCmd', 1], ]; } diff --git a/src/NpmClient.php b/src/NpmClient.php index 199cdd3..e1ffcf9 100644 --- a/src/NpmClient.php +++ b/src/NpmClient.php @@ -43,6 +43,7 @@ public function __construct( $this->executableFinder = $executableFinder; $this->getcwd = $getcwd; $this->chdir = $chdir; + $this->timeout = null; } /** @@ -78,6 +79,32 @@ public function update(string $path = null) $this->executeNpm(['update'], $path); } + /** + * Set timeout. Null to use default + * + * @param int|null $timeout + */ + public function setTimeout($timeout) + { + $this->timeout = $timeout; + } + + /** + * Check is npm is available + * + * @return bool + */ + public function valid() + { + try { + $this->npmPath(); + } catch (NpmNotFoundException $err) { + return false; + } + + return true; + } + private function executeNpm($arguments, $workingDirectoryPath) { array_unshift($arguments, $this->npmPath()); @@ -88,7 +115,14 @@ private function executeNpm($arguments, $workingDirectoryPath) call_user_func($this->chdir, $workingDirectoryPath); } + if (null !== $this->timeout) { + $oldTimeout = $this->processExecutor->getTimeout(); + $this->processExecutor->setTimeout($this->timeout); + } $exitCode = $this->processExecutor->execute($command); + if (null !== $this->timeout) { + $this->processExecutor->setTimeout($oldTimeout); + } if (null !== $workingDirectoryPath) { call_user_func($this->chdir, $previousWorkingDirectoryPath); diff --git a/test/suite/NpmBridgePluginTest.php b/test/suite/NpmBridgePluginTest.php index d411d82..9d129a9 100644 --- a/test/suite/NpmBridgePluginTest.php +++ b/test/suite/NpmBridgePluginTest.php @@ -47,10 +47,17 @@ public function testActivate() public function testGetSubscribedEvents() { + putenv('COMPOSER_NPM_BRIDGE_DISABLE=1'); + $this->assertSame( + [], + $this->plugin->getSubscribedEvents() + ); + putenv('COMPOSER_NPM_BRIDGE_DISABLE='); + $this->assertSame( [ - ScriptEvents::POST_INSTALL_CMD => 'onPostInstallCmd', - ScriptEvents::POST_UPDATE_CMD => 'onPostUpdateCmd', + ScriptEvents::POST_INSTALL_CMD => ['onPostInstallCmd', 1], + ScriptEvents::POST_UPDATE_CMD => ['onPostUpdateCmd', 1], ], $this->plugin->getSubscribedEvents() ); diff --git a/test/suite/NpmBridgeTest.php b/test/suite/NpmBridgeTest.php index 7d5d84a..51d2265 100644 --- a/test/suite/NpmBridgeTest.php +++ b/test/suite/NpmBridgeTest.php @@ -6,6 +6,7 @@ use Composer\Package\Link; use Composer\Package\Package; use Composer\Package\RootPackage; +use Eloquent\Composer\NpmBridge\Exception\NpmNotFoundException; use Eloquent\Phony\Phpunit\Phony; use PHPUnit\Framework\TestCase; @@ -16,6 +17,7 @@ protected function setUp() $this->io = Phony::mock('Composer\IO\IOInterface'); $this->vendorFinder = Phony::mock('Eloquent\Composer\NpmBridge\NpmVendorFinder'); $this->client = Phony::mock('Eloquent\Composer\NpmBridge\NpmClient'); + $this->client->valid->returns(true); $this->bridge = new NpmBridge($this->io->get(), $this->vendorFinder->get(), $this->client->get()); $this->composer = new Composer(); @@ -23,6 +25,9 @@ protected function setUp() $this->rootPackage = new RootPackage('vendor/package', '1.0.0.0', '1.0.0'); $this->packageA = new Package('vendorA/packageA', '1.0.0.0', '1.0.0'); $this->packageB = new Package('vendorB/packageB', '1.0.0.0', '1.0.0'); + $this->packageC = new Package('vendorC/packageC', '1.0.0.0', '1.0.0'); + + $this->packageC->setExtra([NpmBridge::EXTRA_KEY => [NpmBridge::EXTRA_KEY_OPTIONAL => true, NpmBridge::EXTRA_KEY_TIMEOUT => 900]]); $this->linkRoot1 = new Link('vendor/package', 'vendorX/packageX'); $this->linkRoot2 = new Link('vendor/package', 'vendorY/packageY'); @@ -31,6 +36,7 @@ protected function setUp() $this->installationManager = Phony::mock('Composer\Installer\InstallationManager'); $this->installationManager->getInstallPath->with($this->packageA)->returns('/path/to/install/a'); $this->installationManager->getInstallPath->with($this->packageB)->returns('/path/to/install/b'); + $this->installationManager->getInstallPath->with($this->packageC)->returns('/path/to/install/c'); $this->composer->setPackage($this->rootPackage); $this->composer->setInstallationManager($this->installationManager->get()); @@ -39,34 +45,48 @@ protected function setUp() public function testInstall() { $this->rootPackage->setRequires([$this->linkRoot1, $this->linkRoot2, $this->linkRoot3]); - $this->vendorFinder->find->with($this->composer, $this->bridge)->returns([$this->packageA, $this->packageB]); + $this->vendorFinder->find->with($this->composer, $this->bridge)->returns([$this->packageA, $this->packageB, $this->packageC]); $this->bridge->install($this->composer); Phony::inOrder( $this->io->write->calledWith('Installing NPM dependencies for root project'), + $this->client->valid->calledWith(), $this->client->install->calledWith(null, true), $this->io->write->calledWith('Installing NPM dependencies for Composer dependencies'), + $this->client->valid->calledWith(), $this->io->write->calledWith('Installing NPM dependencies for vendorA/packageA'), $this->client->install->calledWith('/path/to/install/a', false), + $this->client->valid->calledWith(), $this->io->write->calledWith('Installing NPM dependencies for vendorB/packageB'), - $this->client->install->calledWith('/path/to/install/b', false) + $this->client->install->calledWith('/path/to/install/b', false), + $this->client->valid->calledWith(), + $this->io->write->calledWith('Installing NPM dependencies for vendorC/packageC'), + $this->client->setTimeout->calledWith(900), + $this->client->install->calledWith('/path/to/install/c', false) ); } public function testInstallProductionMode() { $this->rootPackage->setRequires([$this->linkRoot1, $this->linkRoot2, $this->linkRoot3]); - $this->vendorFinder->find->with($this->composer, $this->bridge)->returns([$this->packageA, $this->packageB]); + $this->vendorFinder->find->with($this->composer, $this->bridge)->returns([$this->packageA, $this->packageB, $this->packageC]); $this->bridge->install($this->composer, false); Phony::inOrder( $this->io->write->calledWith('Installing NPM dependencies for root project'), + $this->client->valid->calledWith(), $this->client->install->calledWith(null, false), $this->io->write->calledWith('Installing NPM dependencies for Composer dependencies'), + $this->client->valid->calledWith(), $this->io->write->calledWith('Installing NPM dependencies for vendorA/packageA'), $this->client->install->calledWith('/path/to/install/a', false), + $this->client->valid->calledWith(), $this->io->write->calledWith('Installing NPM dependencies for vendorB/packageB'), - $this->client->install->calledWith('/path/to/install/b', false) + $this->client->install->calledWith('/path/to/install/b', false), + $this->client->valid->calledWith(), + $this->io->write->calledWith('Installing NPM dependencies for vendorC/packageC'), + $this->client->setTimeout->calledWith(900), + $this->client->install->calledWith('/path/to/install/c', false) ); } @@ -76,6 +96,7 @@ public function testInstallRootDevDependenciesInDevMode() $this->vendorFinder->find->with($this->composer, $this->bridge)->returns([]); $this->bridge->install($this->composer, true); + $this->client->valid->calledWith(); $this->client->install->calledWith(null, true); } @@ -102,6 +123,31 @@ public function testInstallNothing() ); } + public function testInstallOptional() + { + $this->client->valid->returns(false); + $this->client->install->with('/path/to/install/a')->throws(NpmNotFoundException::class); + $this->client->install->with('/path/to/install/c')->returns(); + + $this->rootPackage->setRequires([$this->linkRoot1, $this->linkRoot2]); + $this->vendorFinder->find->with($this->composer, $this->bridge)->returns([$this->packageC, $this->packageA]); + try { + $this->bridge->install($this->composer); + } catch (Exception $err) { + } + + Phony::inOrder( + $this->io->write->calledWith('Installing NPM dependencies for root project'), + $this->io->write->calledWith('Nothing to install'), + $this->io->write->calledWith('Installing NPM dependencies for Composer dependencies'), + $this->client->valid->calledWith(), + $this->io->write->calledWith('Skipping optional NPM dependencies for vendorC/packageC as npm is unavailable'), + $this->client->valid->calledWith(), + $this->io->write->calledWith('Installing NPM dependencies for vendorA/packageA'), + $this->client->install->calledWith('/path/to/install/a', false) + ); + } + public function testUpdate() { $this->rootPackage->setRequires([$this->linkRoot1, $this->linkRoot2, $this->linkRoot3]); @@ -110,11 +156,14 @@ public function testUpdate() Phony::inOrder( $this->io->write->calledWith('Updating NPM dependencies for root project'), + $this->client->valid->calledWith(), $this->client->update->calledWith(), $this->client->install->calledWith(null, true), $this->io->write->calledWith('Installing NPM dependencies for Composer dependencies'), + $this->client->valid->calledWith(), $this->io->write->calledWith('Installing NPM dependencies for vendorA/packageA'), $this->client->install->calledWith('/path/to/install/a', false), + $this->client->valid->calledWith(), $this->io->write->calledWith('Installing NPM dependencies for vendorB/packageB'), $this->client->install->calledWith('/path/to/install/b', false) ); diff --git a/test/suite/NpmClientTest.php b/test/suite/NpmClientTest.php index 501883e..2e3637e 100644 --- a/test/suite/NpmClientTest.php +++ b/test/suite/NpmClientTest.php @@ -17,23 +17,47 @@ protected function setUp() new NpmClient($this->processExecutor->get(), $this->executableFinder->get(), $this->getcwd, $this->chdir); $this->processExecutor->execute->returns(0); + Phony::onStatic($this->processExecutor)->getTimeout->returns(300); + Phony::onStatic($this->processExecutor)->setTimeout->returns(); $this->executableFinder->find->with('npm')->returns('/path/to/npm'); $this->getcwd->returns('/path/to/cwd'); } public function testInstall() { - $this->assertNull($this->client->install('/path/to/project')); + $this->assertNull($this->client->install()); + Phony::inOrder( + $this->executableFinder->find->calledWith('npm'), + $this->processExecutor->execute->calledWith("'/path/to/npm' 'install'") + ); + $this->chdir->never()->returned(); + $this->processExecutor->setTimeout->never()->returned(); + } + + public function testInstallWorking() + { $this->assertNull($this->client->install('/path/to/project')); Phony::inOrder( $this->executableFinder->find->calledWith('npm'), $this->chdir->calledWith('/path/to/project'), $this->processExecutor->execute->calledWith("'/path/to/npm' 'install'"), - $this->chdir->calledWith('/path/to/cwd'), - $this->chdir->calledWith('/path/to/project'), - $this->processExecutor->execute->calledWith("'/path/to/npm' 'install'"), $this->chdir->calledWith('/path/to/cwd') ); + $this->processExecutor->setTimeout->never()->returned(); + } + + public function testInstallTimeout() + { + $this->assertNull($this->client->setTimeout(900)); + $this->assertNull($this->client->install()); + Phony::inOrder( + $this->executableFinder->find->calledWith('npm'), + Phony::onStatic($this->processExecutor)->getTimeout->calledWith(), + Phony::onStatic($this->processExecutor)->setTimeout->calledWith(900), + $this->processExecutor->execute->calledWith("'/path/to/npm' 'install'"), + Phony::onStatic($this->processExecutor)->setTimeout->calledWith(300) + ); + $this->chdir->never()->returned(); } public function testInstallProductionMode() @@ -65,17 +89,39 @@ public function testInstallFailureCommandFailed() public function testUpdate() { - $this->assertNull($this->client->update('/path/to/project')); + $this->assertNull($this->client->update()); + Phony::inOrder( + $this->executableFinder->find->calledWith('npm'), + $this->processExecutor->execute->calledWith("'/path/to/npm' 'update'") + ); + $this->chdir->never()->returned(); + $this->processExecutor->setTimeout->never()->returned(); + } + + public function testUpdateWorking() + { $this->assertNull($this->client->update('/path/to/project')); Phony::inOrder( $this->executableFinder->find->calledWith('npm'), $this->chdir->calledWith('/path/to/project'), $this->processExecutor->execute->calledWith("'/path/to/npm' 'update'"), - $this->chdir->calledWith('/path/to/cwd'), - $this->chdir->calledWith('/path/to/project'), - $this->processExecutor->execute->calledWith("'/path/to/npm' 'update'"), $this->chdir->calledWith('/path/to/cwd') ); + $this->processExecutor->setTimeout->never()->returned(); + } + + public function testUpdateTimeout() + { + $this->assertNull($this->client->setTimeout(900)); + $this->assertNull($this->client->update()); + Phony::inOrder( + $this->executableFinder->find->calledWith('npm'), + Phony::onStatic($this->processExecutor)->getTimeout->calledWith(), + Phony::onStatic($this->processExecutor)->setTimeout->calledWith(900), + $this->processExecutor->execute->calledWith("'/path/to/npm' 'update'"), + Phony::onStatic($this->processExecutor)->setTimeout->calledWith(300) + ); + $this->chdir->never()->returned(); } public function testUpdateFailureNpmNotFound() @@ -93,4 +139,19 @@ public function testUpdateFailureCommandFailed() $this->expectException('Eloquent\Composer\NpmBridge\Exception\NpmCommandFailedException'); $this->client->update('/path/to/project'); } + + public function testValid() + { + $this->executableFinder->find->with('npm')->returns(null); + $this->assertSame( + false, + $this->client->valid() + ); + + $this->executableFinder->find->with('npm')->returns('/path/to/npm'); + $this->assertSame( + true, + $this->client->valid() + ); + } }