diff --git a/CHANGELOG.md b/CHANGELOG.md index 3faacc3..2cb6b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: prepare Foxy `0.3` for PHP `8.3`, faster execution, safer fallbacks, updated tooling, and clearer docs. - fix: preserve plugin self-updates and clarify framework-agnostic Composer application support. - feat!: require Bun `^1.4.0`, npm `^12.0.2`, pnpm `^11.23.0`, or Yarn `^4.18.0` and remove legacy manager support. +- fix: run manager commands in the configured root directory without changing the PHP working directory, and prevent manager probes and npm dependency cleanup when manager execution is disabled. ## 0.2.0 January 24, 2026 diff --git a/README.md b/README.md index 5094d1e..42e61c7 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ - PHP 8.3 or later. - Composer 2.10.2 or later. -- One supported frontend manager: +- One supported frontend manager when manager execution is enabled: - Bun `^1.4.0`. - npm `^12.0.2` with Node.js `^22.22.2 || ^24.15.0 || >=26.0.0`. - pnpm `^11.23.0` with Node.js `>=22.13.0`. @@ -71,9 +71,12 @@ Selecting a manager explicitly is recommended for reproducible local and CI beha } ``` -Valid manager values are `bun`, `npm`, `pnpm`, and `yarn`. When `manager` is omitted, Foxy first looks for one -recognized native lockfile and then checks available executables. Configure the manager explicitly when the project -contains lockfiles from more than one manager. +Valid manager values are `bun`, `npm`, `pnpm`, and `yarn`. When manager execution is enabled and `manager` is omitted, +Foxy first looks for one recognized native lockfile and then checks available executables. Configure the manager +explicitly when the project contains lockfiles from more than one manager. + +When `run-asset-manager` is `false`, Foxy does not require or probe a manager binary. Automatic selection uses the +single recognized lockfile when present, or npm as the manifest adapter when no lockfile exists. ## Quick start diff --git a/UPGRADE.md b/UPGRADE.md index 616e3cf..f1c5612 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -10,7 +10,7 @@ Before updating, ensure the environment provides: - PHP 8.3 or later. - Composer 2.10.2 or later. -- One supported frontend manager: Bun `^1.4.0`, npm `^12.0.2`, pnpm `^11.23.0`, or Yarn `^4.18.0`. +- One supported frontend manager when manager execution is enabled: Bun `^1.4.0`, npm `^12.0.2`, pnpm `^11.23.0`, or Yarn `^4.18.0`. - For npm, Node.js `^22.22.2 || ^24.15.0 || >=26.0.0`. - For pnpm, Node.js `>=22.13.0`. - For Yarn, Node.js `>=18.12.0` on a release that still receives security updates. @@ -48,9 +48,9 @@ Library authors that keep Foxy in `require-dev` should update that constraint to ### Frontend manager selection -Foxy can select a manager automatically from one recognized native lockfile or an available executable. Multiple -recognized lockfiles require explicit selection. For predictable upgrades and CI runs, configure the manager and -commit its native lockfile: +When manager execution is enabled, Foxy can select a manager automatically from one recognized native lockfile or an +available executable. Multiple recognized lockfiles require explicit selection. For predictable upgrades and CI runs, +configure the manager and commit its native lockfile: ```json { @@ -186,4 +186,5 @@ pre-command contents of the root `composer.json`, which Composer may already hav when necessary, revert the root manifest after a failed operation. Setting `enabled=false` now bypasses manager discovery, fallback snapshots, package merging, and manager execution. -Setting `run-asset-manager=false` retains package merging but skips manager version validation and execution. +Setting `run-asset-manager=false` retains package merging but skips manager binary probing, version validation, +execution, and npm cleanup of existing `node_modules/@composer-asset/*` installations. diff --git a/docs/config.md b/docs/config.md index 4b3a5b0..283efe8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -55,7 +55,7 @@ manager-prefixed environment variable should contain the scalar value for the ac | `manager-install-options` | string or map | Empty | Appends options only to install commands. | | `manager-update-options` | string or map | Empty | Appends options only to update commands. | | `manager-timeout` | integer or map | No practical limit | Sets the manager process timeout in seconds. | -| `run-asset-manager` | boolean | `true` | Controls manager execution while retaining package merging. | +| `run-asset-manager` | boolean | `true` | Controls manager probing and execution while retaining package merging. | | `fallback-asset` | boolean | `true` | Restores `package.json` after asset processing fails. | | `fallback-composer` | boolean | `true` | Restores Composer lock and vendor state after asset solving fails. | | `composer-asset-dir` | string or `null` | `/php-forge/composer-asset/` | Sets the mock package directory. | @@ -93,11 +93,15 @@ Set the manager explicitly when local development and CI must always use the sam When `manager` is `null`, Foxy looks for one recognized native lockfile. Multiple recognized lockfiles require explicit selection. Without a lockfile, available executables are considered in this order: npm, pnpm, Yarn, and Bun. Commit the native lockfile generated by the selected manager. Foxy reports an error when an explicitly configured manager is -unknown or its executable is unavailable. +unknown, or when execution is enabled and its executable is unavailable. + +When `run-asset-manager` is `false`, automatic selection does not probe executables. Foxy uses the manager identified +by a single recognized lockfile, or npm as the manifest adapter when no lockfile exists. Multiple lockfiles still +require an explicit `manager` value. ## Manager version constraints -Foxy always validates the selected manager against its built-in supported constraint: +When manager execution is enabled, Foxy validates the selected manager against its built-in supported constraint: | Manager | Built-in constraint | | ------- | ------------------- | @@ -109,7 +113,7 @@ Foxy always validates the selected manager against its built-in supported constr The `manager-version` option adds another Composer constraint that is evaluated together with the built-in constraint. It can narrow the accepted versions for a project, but it cannot replace or widen Foxy's supported range. Foxy treats the reported value as one concrete release and validates it from `root-package-json-dir` before every -manager command. When `run-asset-manager` is `false`, Foxy neither executes nor validates the manager binary. +manager command. When `run-asset-manager` is `false`, Foxy neither probes, executes, nor validates the manager binary. Narrow the npm constraint for one project: @@ -191,7 +195,7 @@ Set a timeout in seconds for the frontend manager process: ## Package merging without manager execution -Disable manager validation and execution while continuing to update `package.json`: +Disable manager binary probing and execution while continuing to update `package.json`: ```json { @@ -203,6 +207,10 @@ Disable manager validation and execution while continuing to update `package.jso } ``` +In this manifest-only mode, Foxy does not probe manager binaries, run install or update commands, or remove existing +`node_modules/@composer-asset/*` installations during npm reconciliation. Generated Composer asset manifests and the +root `package.json` are still updated. + ## Fallbacks Both fallbacks are enabled by default. They can be controlled independently: diff --git a/docs/faqs.md b/docs/faqs.md index 18c5a55..a0ea006 100644 --- a/docs/faqs.md +++ b/docs/faqs.md @@ -36,9 +36,10 @@ different location. ## How does Foxy select a frontend manager? -Set `config.foxy.manager` to `bun`, `npm`, `pnpm`, or `yarn` for deterministic selection. When it is omitted, Foxy first -looks for one recognized native lockfile and then for an available manager executable. Multiple recognized lockfiles -require explicit selection. +Set `config.foxy.manager` to `bun`, `npm`, `pnpm`, or `yarn` for deterministic selection. When it is omitted and manager +execution is enabled, Foxy first looks for one recognized native lockfile and then for an available manager executable. +With execution disabled, it uses a single recognized lockfile or npm as the manifest adapter without probing binaries. +Multiple recognized lockfiles require explicit selection in either mode. Explicit selection and a committed native lockfile are recommended for CI. @@ -49,7 +50,7 @@ Check the following: 1. The package uses one of the documented activation methods. 2. Its `package.json` exists at the package root or configured Foxy root. 3. The root application's `enable-packages` configuration does not exclude it. -4. The selected frontend manager is installed and allowed by its configured version constraint. +4. When manager execution is enabled, the selected frontend manager is installed and allowed by its configured version constraint. Composer must run before a standalone frontend manager command because Foxy creates the local package representations during Composer install and update operations. @@ -76,8 +77,9 @@ operation; enabled fallbacks restore their captured state when asset solving fai ## Can Foxy update package.json without installing frontend dependencies? -Yes. Set `config.foxy.run-asset-manager=false`. Foxy will update the package definition but skip the external manager -validation and command. +Yes. Set `config.foxy.run-asset-manager=false`. Foxy will update the package definition without probing or validating +the external manager, running its commands, or removing existing `node_modules/@composer-asset/*` installations during +npm reconciliation. ## Why are a dependency's scripts or devDependencies not copied? diff --git a/docs/index.md b/docs/index.md index 8fa8dbb..ddb3158 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,6 +27,9 @@ package version only when the embedded package does not declare its own version. | Yarn | `^4.18.0`; Node.js `>=18.12.0` on a release that still receives security fixes | | Git | Required only for Git-based dependencies | +The frontend manager requirements apply only when manager execution is enabled. Manifest-only mode does not require a +manager binary. + ## Installation Composer plugins execute code during Composer operations. Authorize Foxy explicitly before installing it: @@ -40,9 +43,12 @@ The plugin is installed in the configured Composer vendor directory, normally `v ## Manager selection -Set `config.foxy.manager` to `bun`, `npm`, `pnpm`, or `yarn` when reproducible manager selection is required. When the -option is omitted, Foxy looks for one recognized native lockfile and then for an available manager executable. Multiple -recognized lockfiles require explicit selection. +Set `config.foxy.manager` to `bun`, `npm`, `pnpm`, or `yarn` when reproducible manager selection is required. When +manager execution is enabled and the option is omitted, Foxy looks for one recognized native lockfile and then for an +available manager executable. Multiple recognized lockfiles require explicit selection. + +When `run-asset-manager` is `false`, Foxy selects from a single lockfile or uses npm as the manifest adapter without +probing executables. Commit the selected manager's native lockfile and use the same explicit manager in local development and CI. diff --git a/src/Asset/AbstractAssetManager.php b/src/Asset/AbstractAssetManager.php index 242595e..50fe552 100644 --- a/src/Asset/AbstractAssetManager.php +++ b/src/Asset/AbstractAssetManager.php @@ -78,7 +78,10 @@ public function addDependencies(RootPackageInterface $rootPackage, array $depend $alreadyInstalledDependencies = $assetPackage->addNewDependencies($dependencies); - $this->actionWhenComposerDependenciesAreAlreadyInstalled($alreadyInstalledDependencies); + if ($this->config->isEnabled('run-asset-manager')) { + $this->actionWhenComposerDependenciesAreAlreadyInstalled($alreadyInstalledDependencies); + } + $this->io->write('Merging Composer dependencies in the asset package'); return $assetPackage->write(); @@ -129,61 +132,35 @@ public function run(): int $this->validate(); - $rootPackageDir = $this->getManagerWorkingDirectory(); + $managerWorkingDirectory = $this->getManagerWorkingDirectory(); + $updatable = $this->isUpdatable(); - $originalDir = null; - $changedDir = false; + $info = sprintf('%s %s dependencies', $updatable ? 'Updating' : 'Installing', $this->getName()); - if (null !== $rootPackageDir) { - $originalDir = getcwd(); + $this->io->write($info); - if (false === $originalDir) { - throw new RuntimeException('Unable to get the current working directory.'); - } + $timeout = ProcessExecutor::getTimeout(); - if (chdir($rootPackageDir) === false) { - throw new RuntimeException(sprintf('Unable to change working directory to "%s".', $rootPackageDir)); - } + /** @var int $managerTimeout */ + $managerTimeout = $this->config->get('manager-timeout', PHP_INT_MAX); - $changedDir = true; - } + ProcessExecutor::setTimeout($managerTimeout); try { - $updatable = $this->isUpdatable(); - - $info = sprintf('%s %s dependencies', $updatable ? 'Updating' : 'Installing', $this->getName()); - - $this->io->write($info); - - $timeout = ProcessExecutor::getTimeout(); - - /** @var int $managerTimeout */ - $managerTimeout = $this->config->get('manager-timeout', PHP_INT_MAX); - - ProcessExecutor::setTimeout($managerTimeout); - - try { - $cmd = $updatable ? $this->getUpdateCommand() : $this->getInstallCommand(); - $res = $this->executor->execute($cmd); - } catch (Throwable $exception) { - $this->restoreAfterFailure($exception); - - throw $exception; - } finally { - ProcessExecutor::setTimeout($timeout); - } + $cmd = $updatable ? $this->getUpdateCommand() : $this->getInstallCommand(); + $res = $this->executeManagerCommand($cmd, $managerWorkingDirectory); + } catch (Throwable $exception) { + $this->restoreAfterFailure($exception); - if (0 !== $res && null !== $this->fallback) { - $this->restoreAfterFailure( - new RuntimeException(sprintf('The asset manager exited with status code %d.', $res), $res), - ); - } + throw $exception; } finally { - if ($changedDir && chdir($originalDir) === false) { - throw new RuntimeException( - sprintf('Unable to restore working directory to "%s".', $originalDir), - ); - } + ProcessExecutor::setTimeout($timeout); + } + + if (0 !== $res && null !== $this->fallback) { + $this->restoreAfterFailure( + new RuntimeException(sprintf('The asset manager exited with status code %d.', $res), $res), + ); } return $res; @@ -353,6 +330,24 @@ protected function getVersion(): string|null return $this->version; } + /** + * Execute a manager command without changing the PHP process working directory. + */ + private function executeManagerCommand(string $command, string|null $workingDirectory): int + { + $outputHandler = function (string $type, string $buffer): void { + if ('err' === $type) { + $this->io->writeErrorRaw($buffer, false); + + return; + } + + $this->io->writeRaw($buffer, false); + }; + + return $this->executor->execute($command, $outputHandler, $workingDirectory); + } + private function getManagerWorkingDirectory(): string|null { $rootPackageDir = $this->config->get('root-package-json-dir'); diff --git a/src/Asset/AssetManagerFinder.php b/src/Asset/AssetManagerFinder.php index 05db163..7359549 100644 --- a/src/Asset/AssetManagerFinder.php +++ b/src/Asset/AssetManagerFinder.php @@ -35,11 +35,12 @@ public function addManager(AssetManagerInterface $manager): void * Find the asset manager. * * @param string|null $manager The name of the asset manager + * @param bool $checkAvailability Whether to check automatically selected manager availability * * @throws RuntimeException When the asset manager does not exist * @throws RuntimeException When the asset manager is not found */ - public function findManager(string|null $manager = null): AssetManagerInterface + public function findManager(string|null $manager = null, bool $checkAvailability = true): AssetManagerInterface { if (null !== $manager) { if (isset($this->managers[$manager])) { @@ -49,7 +50,7 @@ public function findManager(string|null $manager = null): AssetManagerInterface throw new RuntimeException(sprintf('The asset manager "%s" doesn\'t exist', $manager)); } - return $this->findAvailableManager(); + return $this->findAvailableManager($checkAvailability); } /** @@ -57,7 +58,7 @@ public function findManager(string|null $manager = null): AssetManagerInterface * * @throws RuntimeException When no asset manager is found */ - private function findAvailableManager(): AssetManagerInterface + private function findAvailableManager(bool $checkAvailability): AssetManagerInterface { $lockedManagers = []; @@ -73,7 +74,7 @@ private function findAvailableManager(): AssetManagerInterface } if (isset($lockedManagers[0])) { - if ($lockedManagers[0]->isAvailable()) { + if (!$checkAvailability || $lockedManagers[0]->isAvailable()) { return $lockedManagers[0]; } @@ -85,9 +86,9 @@ private function findAvailableManager(): AssetManagerInterface ); } - // Find the first available manager when no lockfile exists. + // Find the first manager when no lockfile exists, probing it only when requested. foreach ($this->managers as $manager) { - if ($manager->isAvailable()) { + if (!$checkAvailability || $manager->isAvailable()) { return $manager; } } diff --git a/src/Foxy.php b/src/Foxy.php index 8bb494c..21c4a67 100644 --- a/src/Foxy.php +++ b/src/Foxy.php @@ -197,7 +197,7 @@ private function getAssetManager( /** @var string|null $manager */ $manager = $config->get('manager'); - return $amf->findManager($manager); + return $amf->findManager($manager, $this->isEnabled('run-asset-manager')); } /** diff --git a/tests/Asset/AbstractAssetManagerTest.php b/tests/Asset/AbstractAssetManagerTest.php index e07519e..13a531d 100644 --- a/tests/Asset/AbstractAssetManagerTest.php +++ b/tests/Asset/AbstractAssetManagerTest.php @@ -49,8 +49,22 @@ public static function relativeRootPackageDirectories(): array ]; } + public function testActionHookIsSkippedWhenManagerExecutionIsDisabled(): void + { + $this->config = new Config([], ['run-asset-manager' => false]); + + $manager = $this->createManager(); + $manager->addDependencies( + $this->rootPackage, + ['@composer-asset/foo--bar' => 'path/foo/bar/package.json'], + ); + + self::assertNull($manager->getHandledDependencies()); + } + public function testActionHookRemainsExtensible(): void { + $this->config = new Config([], ['run-asset-manager' => true]); $this->io ->expects(self::once()) ->method('write') @@ -211,6 +225,51 @@ public function testRootPathsDoNotDuplicateDirectorySeparator(): void self::assertSame(DIRECTORY_SEPARATOR . 'node_modules', $manager->getNodeModulesPathForTest()); } + public function testRunStreamsOutputFromConfiguredRootDirectory(): void + { + $rootPackageDir = $this->cwd . DIRECTORY_SEPARATOR . 'web'; + $this->sfs->mkdir($rootPackageDir); + $this->config = new Config( + [], + ['root-package-json-dir' => $rootPackageDir, 'run-asset-manager' => true], + ); + $position = 0; + $executor = $this->createMock(ProcessExecutor::class); + $executor + ->expects(self::exactly(2)) + ->method('execute') + ->willReturnCallback( + static function (mixed $command, mixed &$output = null, mixed $cwd = null) use ( + &$position, + $rootPackageDir, + ): int { + self::assertSame($rootPackageDir, $cwd); + + if (0 === $position++) { + self::assertSame('inspectable --version', $command); + $output = '42.0.0'; + + return 0; + } + + self::assertSame('inspectable install', $command); + self::assertIsCallable($output); + + $output('out', 'standard output'); + $output('err', 'error output'); + + return 0; + }, + ); + + $this->io->expects(self::once())->method('writeRaw')->with('standard output', false); + $this->io->expects(self::once())->method('writeErrorRaw')->with('error output', false); + + $manager = new InspectableAssetManager($this->io, $this->config, $executor, $this->fs, $this->fallback); + + self::assertSame(0, $manager->run()); + } + public function testVersionCommandUsesConfiguredRootDirectory(): void { $rootPackageDir = $this->cwd . DIRECTORY_SEPARATOR . 'web'; diff --git a/tests/Asset/AssetManager.php b/tests/Asset/AssetManager.php index a34529d..845253a 100644 --- a/tests/Asset/AssetManager.php +++ b/tests/Asset/AssetManager.php @@ -491,6 +491,29 @@ public function testRunPreservesExitStatusWhenFallbackThrows(): void } } + public function testRunPreservesWorkingDirectoryWhenExecutorThrows(): void + { + $rootPackageDir = $this->cwd . DIRECTORY_SEPARATOR . 'root-package'; + $this->sfs->mkdir($rootPackageDir); + $originalCwd = getcwd(); + + $this->executor = new ThrowingProcessExecutorMock($this->io, $this->getValidVersion()); + $this->config = new Config( + [], + ['run-asset-manager' => true, 'root-package-json-dir' => $rootPackageDir], + ); + $this->fallback->expects(self::once())->method('restore'); + $this->manager = $this->getManager(); + + try { + $this->manager->run(); + self::fail('Expected the process execution to fail.'); + } catch (\RuntimeException $exception) { + self::assertSame('Process execution failed.', $exception->getMessage()); + self::assertSame($originalCwd, getcwd()); + } + } + public function testRunRejectsUnsupportedManagerVersion(): void { $this->config = new Config([], ['run-asset-manager' => true]); @@ -545,64 +568,37 @@ public function testRunRestoresTimeoutWhenExecutorThrows(): void } } - public function testRunRestoresWorkingDirectoryWhenExecutorThrows(): void + public function testRunUsesRelativeRootDirectoryWithoutChangingProcessDirectory(): void { - $rootPackageDir = $this->cwd . DIRECTORY_SEPARATOR . 'root-package'; + $configuredRootPackageDir = 'root-package'; + $rootPackageDir = $this->cwd . DIRECTORY_SEPARATOR . $configuredRootPackageDir; $this->sfs->mkdir($rootPackageDir); $originalCwd = getcwd(); - $this->executor = new ThrowingProcessExecutorMock($this->io, $this->getValidVersion()); $this->config = new Config( [], - ['run-asset-manager' => true, 'root-package-json-dir' => $rootPackageDir], + ['run-asset-manager' => true, 'root-package-json-dir' => $configuredRootPackageDir], ); - $this->fallback->expects(self::once())->method('restore'); $this->manager = $this->getManager(); - try { - $this->manager->run(); - self::fail('Expected the process execution to fail.'); - } catch (\RuntimeException $exception) { - self::assertSame('Process execution failed.', $exception->getMessage()); - self::assertSame($originalCwd, getcwd()); - } - } - - public function testRunWithChdirFailure(): void - { - $rootPackageDir = $this->cwd . DIRECTORY_SEPARATOR . 'root-package'; - $this->sfs->mkdir($rootPackageDir); - $originalCwd = getcwd(); - - $this->config = new Config( - [], - ['run-asset-manager' => true, 'root-package-json-dir' => $rootPackageDir], - ); - $this->manager = $this->getManager(); + file_put_contents($rootPackageDir . DIRECTORY_SEPARATOR . $this->manager->getPackageName(), '{}'); + file_put_contents($rootPackageDir . DIRECTORY_SEPARATOR . $this->manager->getLockPackageName(), '{}'); + $this->sfs->mkdir($rootPackageDir . DIRECTORY_SEPARATOR . 'node_modules'); - MockerState::addCondition('Foxy\\Asset', 'chdir', [$rootPackageDir], false); - $this->actionForTestRunForInstallCommand('install'); + $this->actionForTestRunForInstallCommand('update'); + $this->executor->addExpectedValues(0, 'ASSET MANAGER OUTPUT'); - try { - $this->getManager()->run(); - self::fail('Expected a runtime exception when chdir fails.'); - } catch (RuntimeException $exception) { - self::assertSame( - sprintf('Unable to change working directory to "%s".', $rootPackageDir), - $exception->getMessage(), - ); - self::assertSame( - $originalCwd, - getcwd(), - ); - } + self::assertSame(0, $this->manager->run()); + self::assertSame($this->getValidUpdateCommand(), $this->executor->getLastCommand()); + self::assertSame($rootPackageDir, $this->executor->getExecutedWorkingDirectory(0)); + self::assertSame($rootPackageDir, $this->executor->getExecutedWorkingDirectory(1)); + self::assertSame($originalCwd, getcwd()); } - public function testRunWithChdirRestoreFailure(): void + public function testRunWithAbsoluteRootDirectoryDoesNotReadCurrentWorkingDirectory(): void { $rootPackageDir = $this->cwd . DIRECTORY_SEPARATOR . 'root-package'; $this->sfs->mkdir($rootPackageDir); - $originalCwd = getcwd(); $this->config = new Config( [], @@ -610,33 +606,13 @@ public function testRunWithChdirRestoreFailure(): void ); $this->manager = $this->getManager(); - MockerState::addCondition('Foxy\\Asset', 'chdir', [$rootPackageDir], true); - MockerState::addCondition('Foxy\\Asset', 'chdir', [$originalCwd], false); - + MockerState::addCondition('Foxy\\Asset', 'getcwd', [], false); $this->actionForTestRunForInstallCommand('install'); $this->executor->addExpectedValues(0, 'ASSET MANAGER OUTPUT'); - try { - $this->manager->run(); - self::fail('Expected a runtime exception when restoring chdir fails.'); - } catch (RuntimeException $exception) { - self::assertSame( - sprintf('Unable to restore working directory to "%s".', $originalCwd), - $exception->getMessage(), - ); - self::assertSame( - $originalCwd, - getcwd(), - ); - self::assertSame( - $this->getValidInstallCommand(), - $this->executor->getLastCommand(), - ); - self::assertSame( - 'ASSET MANAGER OUTPUT', - $this->executor->getLastOutput(), - ); - } + self::assertSame(0, $this->manager->run()); + self::assertSame($rootPackageDir, $this->executor->getExecutedWorkingDirectory(0)); + self::assertSame($rootPackageDir, $this->executor->getExecutedWorkingDirectory(1)); } #[DataProvider('getEnabledRunAssetManagerData')] @@ -670,48 +646,19 @@ public function testRunWithDisableOption(): void self::assertNull($this->executor->getLastCommand()); } - public function testRunWithGetcwdFailure(): void - { - $rootPackageDir = $this->cwd . DIRECTORY_SEPARATOR . 'root-package'; - $this->sfs->mkdir($rootPackageDir); - $originalCwd = getcwd(); - - $this->config = new Config( - [], - ['run-asset-manager' => true, 'root-package-json-dir' => $rootPackageDir], - ); - $this->manager = $this->getManager(); - - MockerState::addCondition('Foxy\\Asset', 'getcwd', [], false); - $this->actionForTestRunForInstallCommand('install'); - - try { - $this->getManager()->run(); - self::fail('Expected a runtime exception when getcwd fails.'); - } catch (RuntimeException $exception) { - self::assertSame( - 'Unable to get the current working directory.', - $exception->getMessage(), - ); - self::assertSame( - $originalCwd, - getcwd(), - ); - } - } - - public function testRunWithoutCustomDirectoryDoesNotChangeWorkingDirectory(): void + public function testRunWithoutCustomDirectoryUsesCurrentWorkingDirectory(): void { $this->config = new Config([], ['run-asset-manager' => true]); $this->manager = $this->getManager(); - MockerState::addCondition('Foxy\\Asset', 'chdir', [$this->cwd], false); - $this->actionForTestRunForInstallCommand('install'); $this->executor->addExpectedValues(0, 'ASSET MANAGER OUTPUT'); self::assertSame(0, $this->manager->run()); self::assertSame($this->getValidInstallCommand(), $this->executor->getLastCommand()); + self::assertNull($this->executor->getExecutedWorkingDirectory(0)); + self::assertNull($this->executor->getExecutedWorkingDirectory(1)); + self::assertNull($this->executor->getExecutedCommand(2)); } public function testSetUpdatable(): void @@ -750,6 +697,8 @@ public function testSpecifyCustomDirectoryFromPackageJson(): void $originalCwd, getcwd(), ); + self::assertSame($rootPackageDir, $this->executor->getExecutedWorkingDirectory(0)); + self::assertSame($rootPackageDir, $this->executor->getExecutedWorkingDirectory(1)); } public function testSpecifyCustomDirectoryFromPackageJsonException(): void diff --git a/tests/Asset/AssetManagerFinderTest.php b/tests/Asset/AssetManagerFinderTest.php index 95ce85a..84dfde9 100644 --- a/tests/Asset/AssetManagerFinderTest.php +++ b/tests/Asset/AssetManagerFinderTest.php @@ -6,11 +6,21 @@ use Foxy\Asset\{AssetManagerFinder, AssetManagerInterface}; use Foxy\Exception\RuntimeException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class AssetManagerFinderTest extends TestCase { - public function testFindManagerRejectsMultipleLockFiles(): void + public static function availabilityChecks(): array + { + return [ + 'enabled' => [true], + 'disabled' => [false], + ]; + } + + #[DataProvider('availabilityChecks')] + public function testFindManagerRejectsMultipleLockFiles(bool $checkAvailability): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Multiple asset manager lock files were found'); @@ -25,7 +35,7 @@ public function testFindManagerRejectsMultipleLockFiles(): void $second->expects(self::once())->method('hasLockFile')->willReturn(true); $second->expects(self::never())->method('isAvailable'); - (new AssetManagerFinder([$first, $second]))->findManager(); + (new AssetManagerFinder([$first, $second]))->findManager(checkAvailability: $checkAvailability); } public function testFindManagerRejectsUnavailableManagerSelectedByLockFile(): void @@ -93,6 +103,36 @@ public function testFindManagerWithAutoManagerAndNoAvailableManager(): void $amf->findManager(); } + public function testFindManagerWithDisabledAvailabilityCheckUsesFirstManagerWithoutProbing(): void + { + $first = $this->createMock(AssetManagerInterface::class); + $first->expects(self::once())->method('getName')->willReturn('first'); + $first->expects(self::once())->method('hasLockFile')->willReturn(false); + $first->expects(self::never())->method('isAvailable'); + + $second = $this->createMock(AssetManagerInterface::class); + $second->expects(self::once())->method('getName')->willReturn('second'); + $second->expects(self::once())->method('hasLockFile')->willReturn(false); + $second->expects(self::never())->method('isAvailable'); + + $res = (new AssetManagerFinder([$first, $second]))->findManager(checkAvailability: false); + + self::assertSame($first, $res); + } + + public function testFindManagerWithDisabledAvailabilityCheckUsesLockFileWithoutProbing(): void + { + $am = $this->createMock(AssetManagerInterface::class); + + $am->expects(self::once())->method('getName')->willReturn('foo'); + $am->expects(self::once())->method('hasLockFile')->willReturn(true); + $am->expects(self::never())->method('isAvailable'); + + $res = (new AssetManagerFinder([$am]))->findManager(checkAvailability: false); + + self::assertSame($am, $res); + } + public function testFindManagerWithInvalidManager(): void { $this->expectException(RuntimeException::class); diff --git a/tests/Asset/NpmAssetManagerTest.php b/tests/Asset/NpmAssetManagerTest.php index a78442c..0d0dc37 100644 --- a/tests/Asset/NpmAssetManagerTest.php +++ b/tests/Asset/NpmAssetManagerTest.php @@ -15,11 +15,45 @@ final class NpmAssetManagerTest extends AssetManager { + public function testExistingDependencyCleanupIsSkippedWhenManagerExecutionIsDisabled(): void + { + $rootPackageDir = $this->cwd . DIRECTORY_SEPARATOR . 'web'; + $this->sfs->mkdir($rootPackageDir); + $this->config = new Config( + [], + ['root-package-json-dir' => $rootPackageDir, 'run-asset-manager' => false], + ); + $this->manager = $this->getManager(); + + file_put_contents( + $rootPackageDir . DIRECTORY_SEPARATOR . 'package.json', + '{"dependencies":{"@composer-asset/foo--bar":"file:../asset/foo/bar"}}', + ); + + $this->fs->expects(self::never())->method('remove'); + + $rootPackage = $this->createMock(RootPackageInterface::class); + $rootPackage->method('getLicense')->willReturn([]); + + $assetPackage = $this->manager->addDependencies( + $rootPackage, + [ + '@composer-asset/foo--bar' => $this->cwd . '/asset/foo/bar/package.json', + '@composer-asset/new--dependency' => $this->cwd . '/asset/new/dependency/package.json', + ], + ); + + self::assertArrayHasKey('@composer-asset/new--dependency', $assetPackage->getPackage()['dependencies']); + } + public function testExistingDependencyCleanupUsesConfiguredRootDirectory(): void { $rootPackageDir = $this->cwd . DIRECTORY_SEPARATOR . 'web'; $this->sfs->mkdir($rootPackageDir); - $this->config = new Config([], ['root-package-json-dir' => $rootPackageDir]); + $this->config = new Config( + [], + ['root-package-json-dir' => $rootPackageDir, 'run-asset-manager' => true], + ); $this->manager = $this->getManager(); file_put_contents( diff --git a/tests/Fixtures/Util/AbstractProcessExecutorMock.php b/tests/Fixtures/Util/AbstractProcessExecutorMock.php index 5b245a3..3e4b84e 100644 --- a/tests/Fixtures/Util/AbstractProcessExecutorMock.php +++ b/tests/Fixtures/Util/AbstractProcessExecutorMock.php @@ -32,7 +32,7 @@ public function doExecute($command, &$output = null, string|null $cwd = null): i $expected = $this->expectedValues[$this->position] ?? [0, $output]; [$returnedCode, $output] = $expected; - $this->executedCommands[] = [$command, $returnedCode, $output]; + $this->executedCommands[] = [$command, $returnedCode, $output, $cwd]; ++$this->position; return $returnedCode; @@ -64,6 +64,14 @@ public function getExecutedReturnedCode(int $position): int|string|null return $this->getExecutedValue($position, 1); } + /** + * Get the working directory of an executed command. + */ + public function getExecutedWorkingDirectory(int $position): string|null + { + return $this->getExecutedValue($position, 3); + } + /** * Get the last executed command. */ @@ -88,6 +96,14 @@ public function getLastReturnedCode(): int|string|null return $this->getExecutedReturnedCode(count($this->executedCommands) - 1); } + /** + * Get the working directory of the last executed command. + */ + public function getLastWorkingDirectory(): string|null + { + return $this->getExecutedWorkingDirectory(count($this->executedCommands) - 1); + } + /** * Get the value of the executed command. * diff --git a/tests/Fixtures/Util/ProcessExecutorMockTest.php b/tests/Fixtures/Util/ProcessExecutorMockTest.php index b205d08..01c2b1c 100644 --- a/tests/Fixtures/Util/ProcessExecutorMockTest.php +++ b/tests/Fixtures/Util/ProcessExecutorMockTest.php @@ -16,23 +16,27 @@ public function testExecuteWithExpectedValues(): void $executor->addExpectedValues(42, 'TEST 2'); $executor->execute('run', $output); - $executor->execute('run2', $output2); + $executor->execute('run2', $output2, '/project'); self::assertSame('run', $executor->getExecutedCommand(0)); self::assertSame(0, $executor->getExecutedReturnedCode(0)); self::assertSame('TEST', $executor->getExecutedOutput(0)); + self::assertNull($executor->getExecutedWorkingDirectory(0)); self::assertSame('run2', $executor->getExecutedCommand(1)); self::assertSame(42, $executor->getExecutedReturnedCode(1)); self::assertSame('TEST 2', $executor->getExecutedOutput(1)); + self::assertSame('/project', $executor->getExecutedWorkingDirectory(1)); self::assertNull($executor->getExecutedCommand(2)); self::assertNull($executor->getExecutedReturnedCode(2)); self::assertNull($executor->getExecutedOutput(2)); + self::assertNull($executor->getExecutedWorkingDirectory(2)); self::assertSame('run2', $executor->getLastCommand()); self::assertSame(42, $executor->getLastReturnedCode()); self::assertSame('TEST 2', $executor->getLastOutput()); + self::assertSame('/project', $executor->getLastWorkingDirectory()); self::assertSame('TEST', $output); self::assertSame('TEST 2', $output2); @@ -47,14 +51,17 @@ public function testExecuteWithoutExpectedValues(): void self::assertSame('run', $executor->getExecutedCommand(0)); self::assertEquals(0, $executor->getExecutedReturnedCode(0)); self::assertNull($executor->getExecutedOutput(0)); + self::assertNull($executor->getExecutedWorkingDirectory(0)); self::assertNull($executor->getExecutedCommand(1)); self::assertNull($executor->getExecutedReturnedCode(1)); self::assertNull($executor->getExecutedOutput(1)); + self::assertNull($executor->getExecutedWorkingDirectory(1)); self::assertSame('run', $executor->getLastCommand()); self::assertEquals(0, $executor->getLastReturnedCode()); self::assertNull($executor->getLastOutput()); + self::assertNull($executor->getLastWorkingDirectory()); self::assertNull($output); } diff --git a/tests/FoxyTest.php b/tests/FoxyTest.php index 14a7658..62edd61 100644 --- a/tests/FoxyTest.php +++ b/tests/FoxyTest.php @@ -14,7 +14,8 @@ use Composer\Package\{Package, RootPackageInterface}; use Composer\Repository\RepositoryManager; use Composer\Script\{Event, ScriptEvents}; -use Foxy\Asset\{AbstractAssetManager, AssetManagerInterface}; +use Composer\Util\{Filesystem, ProcessExecutor}; +use Foxy\Asset\{AbstractAssetManager, AssetManagerInterface, NpmManager}; use Foxy\Config\Config as FoxyConfig; use Foxy\Exception\RuntimeException; use Foxy\Fallback\AssetFallback; @@ -252,6 +253,33 @@ public function testActivateWithInvalidManager(): void $foxy->activate($this->composer, $this->io); } + public function testAutomaticManagerDiscoveryDoesNotProbeBinariesWhenExecutionIsDisabled(): void + { + $config = new FoxyConfig( + [], + [ + 'root-package-json-dir' => __DIR__ . '/Fixtures/package/global', + 'run-asset-manager' => false, + ], + ); + $executor = $this->createMock(ProcessExecutor::class); + $executor->expects(self::never())->method('execute'); + + $foxy = new Foxy(); + $reflection = new ReflectionClass($foxy); + $reflection->getProperty('config')->setValue($foxy, $config); + + $manager = $reflection->getMethod('getAssetManager')->invoke( + $foxy, + $this->io, + $config, + $executor, + $this->createMock(Filesystem::class), + ); + + self::assertInstanceOf(NpmManager::class, $manager); + } + public function testConfigurationFlagsRemainCompatibleDuringPluginSelfUpdate(): void { $config = $this