diff --git a/src/Configurator.php b/src/Configurator.php index da957f0bd..56976823b 100644 --- a/src/Configurator.php +++ b/src/Configurator.php @@ -25,6 +25,7 @@ class Configurator private $io; private $options; private $configurators; + private $postInstallConfigurators; private $cache; public function __construct(Composer $composer, IOInterface $io, Options $options) @@ -45,6 +46,9 @@ public function __construct(Composer $composer, IOInterface $io, Options $option 'dockerfile' => Configurator\DockerfileConfigurator::class, 'docker-compose' => Configurator\DockerComposeConfigurator::class, ]; + $this->postInstallConfigurators = [ + 'add-lines' => Configurator\AddLinesConfigurator::class, + ]; } public function install(Recipe $recipe, Lock $lock, array $options = []) @@ -57,11 +61,25 @@ public function install(Recipe $recipe, Lock $lock, array $options = []) } } + /** + * Run after all recipes have been installed to run post-install configurators. + */ + public function postInstall(Recipe $recipe, Lock $lock, array $options = []) + { + $manifest = $recipe->getManifest(); + foreach (array_keys($this->postInstallConfigurators) as $key) { + if (isset($manifest[$key])) { + $this->get($key)->configure($recipe, $manifest[$key], $lock, $options); + } + } + } + public function populateUpdate(RecipeUpdate $recipeUpdate): void { $originalManifest = $recipeUpdate->getOriginalRecipe()->getManifest(); $newManifest = $recipeUpdate->getNewRecipe()->getManifest(); - foreach (array_keys($this->configurators) as $key) { + $allConfigurators = array_merge($this->configurators, $this->postInstallConfigurators); + foreach (array_keys($allConfigurators) as $key) { if (!isset($originalManifest[$key]) && !isset($newManifest[$key])) { continue; } @@ -73,7 +91,10 @@ public function populateUpdate(RecipeUpdate $recipeUpdate): void public function unconfigure(Recipe $recipe, Lock $lock) { $manifest = $recipe->getManifest(); - foreach (array_keys($this->configurators) as $key) { + + $allConfigurators = array_merge($this->configurators, $this->postInstallConfigurators); + + foreach (array_keys($allConfigurators) as $key) { if (isset($manifest[$key])) { $this->get($key)->unconfigure($recipe, $manifest[$key], $lock); } @@ -82,7 +103,7 @@ public function unconfigure(Recipe $recipe, Lock $lock) private function get($key): AbstractConfigurator { - if (!isset($this->configurators[$key])) { + if (!isset($this->configurators[$key]) && !isset($this->postInstallConfigurators[$key])) { throw new \InvalidArgumentException(sprintf('Unknown configurator "%s".', $key)); } @@ -90,7 +111,7 @@ private function get($key): AbstractConfigurator return $this->cache[$key]; } - $class = $this->configurators[$key]; + $class = isset($this->configurators[$key]) ? $this->configurators[$key] : $this->postInstallConfigurators[$key]; return $this->cache[$key] = new $class($this->composer, $this->io, $this->options); } diff --git a/src/Configurator/AbstractConfigurator.php b/src/Configurator/AbstractConfigurator.php index 26711fcc0..aea4dda94 100644 --- a/src/Configurator/AbstractConfigurator.php +++ b/src/Configurator/AbstractConfigurator.php @@ -43,7 +43,7 @@ abstract public function unconfigure(Recipe $recipe, $config, Lock $lock); abstract public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void; - protected function write($messages) + protected function write($messages, $verbosity = IOInterface::VERBOSE) { if (!\is_array($messages)) { $messages = [$messages]; @@ -51,7 +51,7 @@ protected function write($messages) foreach ($messages as $i => $message) { $messages[$i] = ' '.$message; } - $this->io->writeError($messages, true, IOInterface::VERBOSE); + $this->io->writeError($messages, true, $verbosity); } protected function isFileMarked(Recipe $recipe, string $file): bool diff --git a/src/Configurator/AddLinesConfigurator.php b/src/Configurator/AddLinesConfigurator.php new file mode 100644 index 000000000..c7794742e --- /dev/null +++ b/src/Configurator/AddLinesConfigurator.php @@ -0,0 +1,230 @@ + + * @author Ryan Weaver + */ +class AddLinesConfigurator extends AbstractConfigurator +{ + private const POSITION_TOP = 'top'; + private const POSITION_BOTTOM = 'bottom'; + private const POSITION_AFTER_TARGET = 'after_target'; + + private const VALID_POSITIONS = [ + self::POSITION_TOP, + self::POSITION_BOTTOM, + self::POSITION_AFTER_TARGET, + ]; + + public function configure(Recipe $recipe, $config, Lock $lock, array $options = []): void + { + foreach ($config as $patch) { + if (!isset($patch['file'])) { + $this->write(sprintf('The "file" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName())); + + continue; + } + + if (isset($patch['requires']) && !$this->isPackageInstalled($patch['requires'])) { + continue; + } + + if (!isset($patch['content'])) { + $this->write(sprintf('The "content" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName())); + + continue; + } + $content = $patch['content']; + + $file = $this->path->concatenate([$this->options->get('root-dir'), $patch['file']]); + $warnIfMissing = isset($patch['warn_if_missing']) && $patch['warn_if_missing']; + if (!is_file($file)) { + $this->write([ + sprintf('Could not add lines to file %s as it does not exist. Missing lines:', $patch['file']), + '"""', + $content, + '"""', + '', + ], $warnIfMissing ? IOInterface::NORMAL : IOInterface::VERBOSE); + + continue; + } + + $this->write(sprintf('Patching file "%s"', $patch['file'])); + + if (!isset($patch['position'])) { + $this->write(sprintf('The "position" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName())); + + continue; + } + $position = $patch['position']; + if (!\in_array($position, self::VALID_POSITIONS, true)) { + $this->write(sprintf('The "position" key must be one of "%s" for the "add-lines" configurator for recipe "%s". Skipping', implode('", "', self::VALID_POSITIONS), $recipe->getName())); + + continue; + } + + if (self::POSITION_AFTER_TARGET === $position && !isset($patch['target'])) { + $this->write(sprintf('The "target" key is required when "position" is "%s" for the "add-lines" configurator for recipe "%s". Skipping', self::POSITION_AFTER_TARGET, $recipe->getName())); + + continue; + } + $target = isset($patch['target']) ? $patch['target'] : null; + + $this->patchFile($file, $content, $position, $target, $warnIfMissing); + } + } + + public function unconfigure(Recipe $recipe, $config, Lock $lock): void + { + foreach ($config as $patch) { + if (!isset($patch['file'])) { + $this->write(sprintf('The "file" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName())); + + continue; + } + + // Ignore "requires": the target packages may have just become uninstalled. + // Checking for a "content" match is enough. + + $file = $this->path->concatenate([$this->options->get('root-dir'), $patch['file']]); + if (!is_file($file)) { + continue; + } + + if (!isset($patch['content'])) { + $this->write(sprintf('The "content" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName())); + + continue; + } + $value = $patch['content']; + + $this->unPatchFile($file, $value); + } + } + + public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void + { + $originalConfig = array_filter($originalConfig, function ($item) { + return !isset($item['requires']) || $this->isPackageInstalled($item['requires']); + }); + $newConfig = array_filter($newConfig, function ($item) { + return !isset($item['requires']) || $this->isPackageInstalled($item['requires']); + }); + + $filterDuplicates = function (array $sourceConfig, array $comparisonConfig) { + $filtered = []; + foreach ($sourceConfig as $sourceItem) { + $found = false; + foreach ($comparisonConfig as $comparisonItem) { + if ($sourceItem['file'] === $comparisonItem['file'] && $sourceItem['content'] === $comparisonItem['content']) { + $found = true; + break; + } + } + if (!$found) { + $filtered[] = $sourceItem; + } + } + + return $filtered; + }; + + // remove any config where the file+value is the same before & after + $filteredOriginalConfig = $filterDuplicates($originalConfig, $newConfig); + $filteredNewConfig = $filterDuplicates($newConfig, $originalConfig); + + $this->unconfigure($recipeUpdate->getOriginalRecipe(), $filteredOriginalConfig, $recipeUpdate->getLock()); + $this->configure($recipeUpdate->getNewRecipe(), $filteredNewConfig, $recipeUpdate->getLock()); + } + + private function patchFile(string $file, string $value, string $position, ?string $target, bool $warnIfMissing) + { + $fileContents = file_get_contents($file); + + if (false !== strpos($fileContents, $value)) { + return; // already includes value, skip + } + + switch ($position) { + case self::POSITION_BOTTOM: + $fileContents .= "\n".$value; + + break; + case self::POSITION_TOP: + $fileContents = $value."\n".$fileContents; + + break; + case self::POSITION_AFTER_TARGET: + $lines = explode("\n", $fileContents); + $targetFound = false; + foreach ($lines as $key => $line) { + if (false !== strpos($line, $target)) { + array_splice($lines, $key + 1, 0, $value); + $targetFound = true; + + break; + } + } + $fileContents = implode("\n", $lines); + + if (!$targetFound) { + $this->write([ + sprintf('Could not add lines after "%s" as no such string was found in "%s". Missing lines:', $target, $file), + '"""', + $value, + '"""', + '', + ], $warnIfMissing ? IOInterface::NORMAL : IOInterface::VERBOSE); + } + + break; + } + + file_put_contents($file, $fileContents); + } + + private function unPatchFile(string $file, $value) + { + $fileContents = file_get_contents($file); + + if (false === strpos($fileContents, $value)) { + return; // value already gone! + } + + if (false !== strpos($fileContents, "\n".$value)) { + $value = "\n".$value; + } elseif (false !== strpos($fileContents, $value."\n")) { + $value = $value."\n"; + } + + $position = strpos($fileContents, $value); + $fileContents = substr_replace($fileContents, '', $position, \strlen($value)); + + file_put_contents($file, $fileContents); + } + + private function isPackageInstalled($packages): bool + { + if (\is_string($packages)) { + $packages = [$packages]; + } + + $installedRepo = $this->composer->getRepositoryManager()->getLocalRepository(); + + foreach ($packages as $packageName) { + if (null === $installedRepo->findPackage($packageName, '*')) { + return false; + } + } + + return true; + } +} diff --git a/src/Flex.php b/src/Flex.php index 005c53acf..17703259d 100644 --- a/src/Flex.php +++ b/src/Flex.php @@ -473,6 +473,7 @@ public function install(Event $event) $installContribs = $this->composer->getPackage()->getExtra()['symfony']['allow-contrib'] ?? false; $manifest = null; $originalComposerJsonHash = $this->getComposerJsonHash(); + $postInstallRecipes = []; foreach ($recipes as $recipe) { if ('install' === $recipe->getJob() && !$installContribs && $recipe->isContrib()) { $warning = $this->io->isInteractive() ? 'WARNING' : 'IGNORING'; @@ -519,6 +520,7 @@ function ($value) { switch ($recipe->getJob()) { case 'install': + $postInstallRecipes[] = $recipe; $this->io->writeError(sprintf(' - Configuring %s', $this->formatOrigin($recipe))); $this->configurator->install($recipe, $this->lock, [ 'force' => $event instanceof UpdateEvent && $event->force(), @@ -542,6 +544,12 @@ function ($value) { } } + foreach ($postInstallRecipes as $recipe) { + $this->configurator->postInstall($recipe, $this->lock, [ + 'force' => $event instanceof UpdateEvent && $event->force(), + ]); + } + if (null !== $manifest) { array_unshift( $this->postInstallOutput, @@ -572,17 +580,12 @@ private function synchronizePackageJson(string $rootDir) $rootDir = realpath($rootDir); $vendorDir = trim((new Filesystem())->makePathRelative($this->config->get('vendor-dir'), $rootDir), '/'); - $synchronizer = new PackageJsonSynchronizer($rootDir, $vendorDir); + $executor = new ScriptExecutor($this->composer, $this->io, $this->options); + $synchronizer = new PackageJsonSynchronizer($rootDir, $vendorDir, $executor); if ($synchronizer->shouldSynchronize()) { $lockData = $this->composer->getLocker()->getLockData(); - if (method_exists($synchronizer, 'addPackageJsonLink') && 'string' === (new \ReflectionParameter([$synchronizer, 'addPackageJsonLink'], 'phpPackage'))->getType()->getName()) { - // support for smooth upgrades from older flex versions - $lockData['packages'] = array_column($lockData['packages'] ?? [], 'name'); - $lockData['packages-dev'] = array_column($lockData['packages-dev'] ?? [], 'name'); - } - if ($synchronizer->synchronize(array_merge($lockData['packages'] ?? [], $lockData['packages-dev'] ?? []))) { $this->io->writeError('Synchronizing package.json with PHP packages'); $this->io->writeError('Don\'t forget to run npm install --force or yarn install --force to refresh your JavaScript dependencies!'); @@ -773,7 +776,7 @@ public function fetchRecipes(array $operations, bool $reset): array $job = method_exists($operation, 'getOperationType') ? $operation->getOperationType() : $operation->getJobType(); if (!isset($manifests[$name]) && isset($data['conflicts'][$name])) { - $this->io->writeError(sprintf(' - Skipping recipe for %s: all versions of the recipe conflict with your package versions.', $name), true, IOInterface::VERBOSE); + $this->io->writeError(sprintf(' - Skipping recipe for %s: all versions of the recipe conflict with your package versions.', $name)); continue; } @@ -784,7 +787,7 @@ public function fetchRecipes(array $operations, bool $reset): array if (!isset($newManifests[$name])) { // no older recipe found - $this->io->writeError(sprintf(' - Skipping recipe for %s: all versions of the recipe conflict with your package versions.', $name), true, IOInterface::VERBOSE); + $this->io->writeError(sprintf(' - Skipping recipe for %s: all versions of the recipe conflict with your package versions.', $name)); continue 2; } diff --git a/src/PackageJsonSynchronizer.php b/src/PackageJsonSynchronizer.php index fb0255d3f..35f181658 100644 --- a/src/PackageJsonSynchronizer.php +++ b/src/PackageJsonSynchronizer.php @@ -24,22 +24,30 @@ class PackageJsonSynchronizer { private $rootDir; private $vendorDir; + private $scriptExecutor; private $versionParser; - public function __construct(string $rootDir, string $vendorDir = 'vendor') + public function __construct(string $rootDir, string $vendorDir, ScriptExecutor $scriptExecutor) { $this->rootDir = $rootDir; $this->vendorDir = $vendorDir; + $this->scriptExecutor = $scriptExecutor; $this->versionParser = new VersionParser(); } public function shouldSynchronize(): bool { - return $this->rootDir && file_exists($this->rootDir.'/package.json'); + return $this->rootDir && (file_exists($this->rootDir.'/package.json') || file_exists($this->rootDir.'/importmap.php')); } public function synchronize(array $phpPackages): bool { + if (file_exists($this->rootDir.'/importmap.php')) { + $this->synchronizeForAssetMapper($phpPackages); + + return false; + } + try { JsonFile::parseJson(file_get_contents($this->rootDir.'/package.json')); } catch (ParsingException $e) { @@ -51,28 +59,35 @@ public function synchronize(array $phpPackages): bool $dependencies = []; - foreach ($phpPackages as $k => $phpPackage) { - if (\is_string($phpPackage)) { - // support for smooth upgrades from older flex versions - $phpPackages[$k] = $phpPackage = [ - 'name' => $phpPackage, - 'keywords' => ['symfony-ux'], - ]; - } - - foreach ($this->resolvePackageDependencies($phpPackage) as $dependency => $constraint) { + $phpPackages = $this->normalizePhpPackages($phpPackages); + foreach ($phpPackages as $phpPackage) { + foreach ($this->resolvePackageJsonDependencies($phpPackage) as $dependency => $constraint) { $dependencies[$dependency][$phpPackage['name']] = $constraint; } } - $didChangePackageJson = $this->registerDependencies($dependencies) || $didChangePackageJson; + $didChangePackageJson = $this->registerDependenciesInPackageJson($dependencies) || $didChangePackageJson; // Register controllers and entrypoints in controllers.json - $this->registerWebpackResources($phpPackages); + $this->updateControllersJsonFile($phpPackages); return $didChangePackageJson; } + private function synchronizeForAssetMapper(array $phpPackages): void + { + $importMapEntries = []; + $phpPackages = $this->normalizePhpPackages($phpPackages); + foreach ($phpPackages as $phpPackage) { + foreach ($this->resolveImportMapPackages($phpPackage) as $name => $dependencyConfig) { + $importMapEntries[$name] = $dependencyConfig; + } + } + + $this->updateImportMap($importMapEntries); + $this->updateControllersJsonFile($phpPackages); + } + private function removeObsoletePackageJsonLinks(): bool { $didChangePackageJson = false; @@ -102,7 +117,7 @@ private function removeObsoletePackageJsonLinks(): bool return $didChangePackageJson; } - private function resolvePackageDependencies($phpPackage): array + private function resolvePackageJsonDependencies($phpPackage): array { $dependencies = []; @@ -110,7 +125,9 @@ private function resolvePackageDependencies($phpPackage): array return $dependencies; } - $dependencies['@'.$phpPackage['name']] = 'file:'.substr($packageJson->getPath(), 1 + \strlen($this->rootDir), -13); + if ($packageJson->read()['symfony']['needsPackageAsADependency'] ?? true) { + $dependencies['@'.$phpPackage['name']] = 'file:'.substr($packageJson->getPath(), 1 + \strlen($this->rootDir), -13); + } foreach ($packageJson->read()['peerDependencies'] ?? [] as $peerDependency => $constraint) { $dependencies[$peerDependency] = $constraint; @@ -119,7 +136,48 @@ private function resolvePackageDependencies($phpPackage): array return $dependencies; } - private function registerDependencies(array $flexDependencies): bool + private function resolveImportMapPackages($phpPackage): array + { + if (!$packageJson = $this->resolvePackageJson($phpPackage)) { + return []; + } + + $dependencies = []; + + foreach ($packageJson->read()['symfony']['importmap'] ?? [] as $importMapName => $constraintConfig) { + if (\is_array($constraintConfig)) { + $constraint = $constraintConfig['version'] ?? []; + $preload = $constraintConfig['preload'] ?? false; + $package = $constraintConfig['package'] ?? $importMapName; + } else { + $constraint = $constraintConfig; + $preload = false; + $package = $importMapName; + } + + if (0 === strpos($constraint, 'path:')) { + $path = substr($constraint, 5); + $path = str_replace('%PACKAGE%', \dirname($packageJson->getPath()), $path); + + $dependencies[$importMapName] = [ + 'path' => $path, + 'preload' => $preload, + ]; + + continue; + } + + $dependencies[$importMapName] = [ + 'version' => $constraint, + 'package' => $package, + 'preload' => $preload, + ]; + } + + return $dependencies; + } + + private function registerDependenciesInPackageJson(array $flexDependencies): bool { $didChangePackageJson = false; @@ -180,7 +238,59 @@ private function shouldUpdateConstraint(string $existingConstraint, string $cons } } - private function registerWebpackResources(array $phpPackages) + /** + * @param array $importMapEntries + */ + private function updateImportMap(array $importMapEntries): void + { + if (!$importMapEntries) { + return; + } + + $importMapData = include $this->rootDir.'/importmap.php'; + + foreach ($importMapEntries as $name => $importMapEntry) { + if (isset($importMapData[$name])) { + continue; + } + + if (isset($importMapEntry['path'])) { + $arguments = [$name, '--path='.$importMapEntry['path']]; + if ($importMapEntry['preload']) { + $arguments[] = '--preload'; + } + $this->scriptExecutor->execute( + 'symfony-cmd', + 'importmap:require', + $arguments + ); + + continue; + } + + if (isset($importMapEntry['version'])) { + $packageName = $importMapEntry['package'].'@'.$importMapEntry['version']; + if ($importMapEntry['package'] !== $name) { + $packageName .= '='.$name; + } + $arguments = [$packageName]; + if ($importMapEntry['preload']) { + $arguments[] = '--preload'; + } + $this->scriptExecutor->execute( + 'symfony-cmd', + 'importmap:require', + $arguments + ); + + continue; + } + + throw new \InvalidArgumentException(sprintf('Invalid importmap entry: "%s".', var_export($importMapEntry, true))); + } + } + + private function updateControllersJsonFile(array $phpPackages) { if (!file_exists($controllersJsonPath = $this->rootDir.'/assets/controllers.json')) { return; @@ -263,4 +373,19 @@ private function resolvePackageJson(array $phpPackage): ?JsonFile return null; } + + private function normalizePhpPackages(array $phpPackages): array + { + foreach ($phpPackages as $k => $phpPackage) { + if (\is_string($phpPackage)) { + // support for smooth upgrades from older flex versions + $phpPackages[$k] = $phpPackage = [ + 'name' => $phpPackage, + 'keywords' => ['symfony-ux'], + ]; + } + } + + return $phpPackages; + } } diff --git a/src/ScriptExecutor.php b/src/ScriptExecutor.php index 3acba7270..d61694735 100644 --- a/src/ScriptExecutor.php +++ b/src/ScriptExecutor.php @@ -42,10 +42,10 @@ public function __construct(Composer $composer, IOInterface $io, Options $option /** * @throws ScriptExecutionException if the executed command returns a non-0 exit code */ - public function execute(string $type, string $cmd) + public function execute(string $type, string $cmd, array $arguments = []) { $parsedCmd = $this->options->expandTargetDir($cmd); - if (null === $expandedCmd = $this->expandCmd($type, $parsedCmd)) { + if (null === $expandedCmd = $this->expandCmd($type, $parsedCmd, $arguments)) { return; } @@ -77,13 +77,13 @@ public function execute(string $type, string $cmd) } } - private function expandCmd(string $type, string $cmd) + private function expandCmd(string $type, string $cmd, array $arguments) { switch ($type) { case 'symfony-cmd': - return $this->expandSymfonyCmd($cmd); + return $this->expandSymfonyCmd($cmd, $arguments); case 'php-script': - return $this->expandPhpScript($cmd); + return $this->expandPhpScript($cmd, $arguments); case 'script': return $cmd; default: @@ -91,7 +91,7 @@ private function expandCmd(string $type, string $cmd) } } - private function expandSymfonyCmd(string $cmd) + private function expandSymfonyCmd(string $cmd, array $arguments) { $repo = $this->composer->getRepositoryManager()->getLocalRepository(); if (!$repo->findPackage('symfony/console', class_exists(MatchAllConstraint::class) ? new MatchAllConstraint() : new EmptyConstraint())) { @@ -105,10 +105,10 @@ private function expandSymfonyCmd(string $cmd) $console .= ' --ansi'; } - return $this->expandPhpScript($console.' '.$cmd); + return $this->expandPhpScript($console.' '.$cmd, $arguments); } - private function expandPhpScript(string $cmd): string + private function expandPhpScript(string $cmd, array $scriptArguments): string { $phpFinder = new PhpExecutableFinder(); if (!$php = $phpFinder->find(false)) { @@ -117,7 +117,7 @@ private function expandPhpScript(string $cmd): string $arguments = $phpFinder->findArguments(); - if ($env = (string) (getenv('COMPOSER_ORIGINAL_INIS'))) { + if ($env = (string) getenv('COMPOSER_ORIGINAL_INIS')) { $paths = explode(\PATH_SEPARATOR, $env); $ini = array_shift($paths); } else { @@ -133,7 +133,8 @@ private function expandPhpScript(string $cmd): string } $phpArgs = implode(' ', array_map([ProcessExecutor::class, 'escape'], $arguments)); + $scriptArgs = implode(' ', array_map([ProcessExecutor::class, 'escape'], $scriptArguments)); - return ProcessExecutor::escape($php).($phpArgs ? ' '.$phpArgs : '').' '.$cmd; + return ProcessExecutor::escape($php).($phpArgs ? ' '.$phpArgs : '').' '.$cmd.($scriptArgs ? ' '.$scriptArgs : ''); } } diff --git a/tests/Configurator/AddLinesConfiguratorTest.php b/tests/Configurator/AddLinesConfiguratorTest.php new file mode 100644 index 000000000..2e9179787 --- /dev/null +++ b/tests/Configurator/AddLinesConfiguratorTest.php @@ -0,0 +1,591 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Flex\Tests\Configurator; + +use Composer\Composer; +use Composer\IO\IOInterface; +use Composer\Package\Package; +use Composer\Repository\InstalledRepositoryInterface; +use Composer\Repository\RepositoryManager; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Flex\Configurator\AddLinesConfigurator; +use Symfony\Flex\Lock; +use Symfony\Flex\Options; +use Symfony\Flex\Recipe; +use Symfony\Flex\Update\RecipeUpdate; + +class AddLinesConfiguratorTest extends TestCase +{ + protected function setUp(): void + { + $filesystem = new Filesystem(); + $filesystem->remove(FLEX_TEST_DIR); + } + + public function testFileDoesNotExistSkipped() + { + $this->runConfigure([ + ['file' => 'non-existent.php', 'content' => ''], + ]); + $this->assertFileDoesNotExist(FLEX_TEST_DIR.'/non-existent.php'); + } + + public function testLinesAddedToTopOfFile() + { + $this->saveFile('assets/app.js', <<runConfigure([ + [ + 'file' => 'assets/app.js', + 'position' => 'top', + 'content' => "import './bootstrap';", + ], + ]); + $actualContents = $this->readFile('assets/app.js'); + $this->assertSame(<<saveFile('assets/app.js', <<runConfigure([ + [ + 'file' => 'assets/app.js', + 'position' => 'bottom', + 'content' => "import './bootstrap';", + ], + ]); + $actualContents = $this->readFile('assets/app.js'); + $this->assertSame(<<saveFile('webpack.config.js', <<runConfigure([ + [ + 'file' => 'webpack.config.js', + 'position' => 'after_target', + 'target' => '.addEntry(\'app\', \'./assets/app.js\')', + 'content' => <<readFile('webpack.config.js'); + $this->assertSame(<<saveFile('webpack.config.js', $originalContent); + + $this->runConfigure([ + [ + 'file' => 'webpack.config.js', + 'position' => 'after_target', + 'target' => '.addEntry(\'app\', \'./assets/app.js\')', + 'content' => <<assertSame($originalContent, $this->readFile('webpack.config.js')); + } + + public function testPatchIgnoredIfValueAlreadyExists() + { + $originalContents = <<saveFile('assets/app.js', $originalContents); + + $this->runConfigure([ + [ + 'file' => 'assets/app.js', + 'position' => 'top', + 'content' => "import './bootstrap';", + ], + ]); + $actualContents = $this->readFile('assets/app.js'); + $this->assertSame($originalContents, $actualContents); + } + + public function testLinesAddedToMultipleFiles() + { + $this->saveFile('assets/app.js', <<saveFile('assets/bootstrap.js', <<runConfigure([ + [ + 'file' => 'assets/app.js', + 'position' => 'top', + 'content' => "import './bootstrap';", + ], + [ + 'file' => 'assets/bootstrap.js', + 'position' => 'bottom', + 'content' => "console.log('on the bottom');", + ], + ]); + + $this->assertSame(<<readFile('assets/app.js')); + + $this->assertSame(<<readFile('assets/bootstrap.js')); + } + + public function testLineSkippedIfRequiredPackageMissing() + { + $this->saveFile('assets/app.js', <<createComposerMockWithPackagesInstalled([]); + $this->runConfigure([ + [ + 'file' => 'assets/app.js', + 'position' => 'top', + 'content' => "import './bootstrap';", + 'requires' => 'symfony/invented-package', + ], + ], $composer); + $actualContents = $this->readFile('assets/app.js'); + $this->assertSame(<<saveFile('assets/app.js', <<createComposerMockWithPackagesInstalled([ + 'symfony/installed-package', + ]); + + $this->runConfigure([ + [ + 'file' => 'assets/app.js', + 'position' => 'top', + 'content' => "import './bootstrap';", + 'requires' => 'symfony/installed-package', + ], + ], $composer); + + $actualContents = $this->readFile('assets/app.js'); + $this->assertSame(<<saveFile('assets/app.js', $originalContents); + + $this->runUnconfigure([ + [ + 'file' => 'assets/app.js', + 'content' => $value, + ], + ]); + $actualContents = $this->readFile('assets/app.js'); + $this->assertSame($expectedContents, $actualContents); + } + + public function getUnconfigureTests() + { + yield 'found_middle' => [ + << [ + << [ + << [ + << [ + << $contents) { + $this->saveFile($filename, $contents); + } + + $composer = $this->createComposerMockWithPackagesInstalled([ + 'symfony/installed-package', + ]); + $configurator = $this->createConfigurator($composer); + $recipe = $this->getMockBuilder(Recipe::class)->disableOriginalConstructor()->getMock(); + $lock = $this->getMockBuilder(Lock::class)->disableOriginalConstructor()->getMock(); + + $recipeUpdate = new RecipeUpdate($recipe, $recipe, $lock, FLEX_TEST_DIR); + $configurator->update($recipeUpdate, $originalConfig, $newConfig); + + foreach ($expectedFiles as $filename => $contents) { + $this->assertSame($contents, $this->readFile($filename)); + } + } + + public function getUpdateTests() + { + $appJs = << [ + ['assets/app.js' => $appJs], + [ + ['file' => 'assets/app.js', 'position' => 'top', 'content' => "import './bootstrap';"], + ], + [ + ['file' => 'assets/app.js', 'position' => 'top', 'content' => "import './stimulus_bootstrap';"], + ], + ['assets/app.js' => << [ + ['assets/app.js' => $appJs], + [ + ['file' => 'assets/app.js', 'position' => 'top', 'content' => "import * as Turbo from '@hotwired/turbo';"], + ], + [ + ['file' => 'assets/app.js', 'position' => 'top', 'content' => "import * as Turbo from '@hotwired/turbo';"], + ], + ['assets/app.js' => $appJs], + ]; + + yield 'different_files_unconfigures_old_and_configures_new' => [ + ['assets/app.js' => $appJs, 'assets/bootstrap.js' => $bootstrapJs], + [ + ['file' => 'assets/app.js', 'position' => 'top', 'content' => "import * as Turbo from '@hotwired/turbo';"], + ], + [ + ['file' => 'assets/bootstrap.js', 'position' => 'top', 'content' => "import * as Turbo from '@hotwired/turbo';"], + ], + [ + 'assets/app.js' => << << [ + ['assets/app.js' => $appJs], + [ + ['file' => 'assets/app.js', 'position' => 'top', 'content' => "import './bootstrap';", 'requires' => 'symfony/not-installed'], + ], + [ + ['file' => 'assets/app.js', 'position' => 'top', 'content' => "import './stimulus_bootstrap';", 'requires' => 'symfony/not-installed'], + ], + ['assets/app.js' => $appJs], + ]; + + yield 'recipe_changes_are_applied_if_required_package_installed' => [ + ['assets/app.js' => $appJs], + [ + ['file' => 'assets/app.js', 'position' => 'top', 'content' => "import './bootstrap';", 'requires' => 'symfony/installed-package'], + ], + [ + ['file' => 'assets/app.js', 'position' => 'top', 'content' => "import './stimulus_bootstrap';", 'requires' => 'symfony/installed-package'], + ], + ['assets/app.js' => <<createConfigurator($composer); + + $recipe = $this->getMockBuilder(Recipe::class)->disableOriginalConstructor()->getMock(); + $lock = $this->getMockBuilder(Lock::class)->disableOriginalConstructor()->getMock(); + $configurator->configure($recipe, $config, $lock); + } + + private function runUnconfigure(array $config) + { + $configurator = $this->createConfigurator(); + + $recipe = $this->getMockBuilder(Recipe::class)->disableOriginalConstructor()->getMock(); + $lock = $this->getMockBuilder(Lock::class)->disableOriginalConstructor()->getMock(); + $configurator->unconfigure($recipe, $config, $lock); + } + + private function createConfigurator(Composer $composer = null) + { + return new AddLinesConfigurator( + $composer ?: $this->getMockBuilder(Composer::class)->getMock(), + $this->getMockBuilder(IOInterface::class)->getMock(), + new Options(['config-dir' => 'config', 'root-dir' => FLEX_TEST_DIR]) + ); + } + + private function saveFile(string $filename, string $contents) + { + $path = FLEX_TEST_DIR.'/'.$filename; + if (!file_exists(\dirname($path))) { + @mkdir(\dirname($path), 0777, true); + } + file_put_contents($path, $contents); + } + + private function readFile(string $filename): string + { + return file_get_contents(FLEX_TEST_DIR.'/'.$filename); + } + + private function createComposerMockWithPackagesInstalled(array $packages) + { + $repository = $this->getMockBuilder(InstalledRepositoryInterface::class)->getMock(); + $repository->expects($this->any()) + ->method('findPackage') + ->willReturnCallback(function ($name) use ($packages) { + if (\in_array($name, $packages)) { + return new Package($name, '1.0.0', '1.0.0'); + } + + return null; + }); + $repositoryManager = $this->getMockBuilder(RepositoryManager::class)->disableOriginalConstructor()->getMock(); + $repositoryManager->expects($this->any()) + ->method('getLocalRepository') + ->willReturn($repository); + $composer = $this->getMockBuilder(Composer::class)->getMock(); + $composer->expects($this->any()) + ->method('getRepositoryManager') + ->willReturn($repositoryManager); + + return $composer; + } +} diff --git a/tests/Fixtures/packageJson/vendor/symfony/new-package/assets/package.json b/tests/Fixtures/packageJson/vendor/symfony/new-package/assets/package.json index fd4c931b9..dd711bf72 100644 --- a/tests/Fixtures/packageJson/vendor/symfony/new-package/assets/package.json +++ b/tests/Fixtures/packageJson/vendor/symfony/new-package/assets/package.json @@ -9,7 +9,14 @@ } } }, - "entrypoints": ["admin.js"] + "entrypoints": ["admin.js"], + "importmap": { + "@hotcake": "^1.9.0", + "@symfony/new-package": { + "version": "path:%PACKAGE%/dist/loader.js", + "preload": true + } + } }, "peerDependencies": { "@hotcookies": "^1.1" diff --git a/tests/Fixtures/packageJson/vendor/symfony/package-no-file-package/assets/package.json b/tests/Fixtures/packageJson/vendor/symfony/package-no-file-package/assets/package.json new file mode 100644 index 000000000..8ee08e8b6 --- /dev/null +++ b/tests/Fixtures/packageJson/vendor/symfony/package-no-file-package/assets/package.json @@ -0,0 +1,8 @@ +{ + "symfony": { + "needsPackageAsADependency": false + }, + "peerDependencies": { + "@hotcookies": "^1.1" + } +} diff --git a/tests/PackageJsonSynchronizerTest.php b/tests/PackageJsonSynchronizerTest.php index 36472e25d..d45db03fa 100644 --- a/tests/PackageJsonSynchronizerTest.php +++ b/tests/PackageJsonSynchronizerTest.php @@ -14,18 +14,25 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Filesystem\Filesystem; use Symfony\Flex\PackageJsonSynchronizer; +use Symfony\Flex\ScriptExecutor; class PackageJsonSynchronizerTest extends TestCase { private $tempDir; private $synchronizer; + private $scriptExecutor; protected function setUp(): void { $this->tempDir = sys_get_temp_dir().'/flex-package-json-'.substr(md5(uniqid('', true)), 0, 6); (new Filesystem())->mirror(__DIR__.'/Fixtures/packageJson', $this->tempDir); + $this->scriptExecutor = $this->createMock(ScriptExecutor::class); - $this->synchronizer = new PackageJsonSynchronizer($this->tempDir, 'vendor'); + $this->synchronizer = new PackageJsonSynchronizer( + $this->tempDir, + 'vendor', + $this->scriptExecutor + ); } protected function tearDown(): void @@ -250,6 +257,8 @@ public function testStricterConstraintsAreKeptNonMatchingAreReplaced() { (new Filesystem())->copy($this->tempDir.'/stricter_constraints_package.json', $this->tempDir.'/package.json', true); + (new Filesystem())->copy($this->tempDir.'/stricter_constraints_package.json', $this->tempDir.'/package.json', true); + $this->synchronizer->synchronize([ [ 'name' => 'symfony/existing-package', @@ -275,4 +284,104 @@ public function testStricterConstraintsAreKeptNonMatchingAreReplaced() json_decode(file_get_contents($this->tempDir.'/package.json'), true) ); } + + public function testSynchronizePackageWithoutNeedingFilePackage() + { + $this->synchronizer->synchronize([ + [ + 'name' => 'symfony/existing-package', + 'keywords' => ['symfony-ux'], + ], + [ + 'name' => 'symfony/package-no-file-package', + 'keywords' => ['symfony-ux'], + ], + ]); + + // Should keep existing package references and config and add the new package, while keeping the formatting + $this->assertSame( + '{ + "name": "symfony/fixture", + "devDependencies": { + "@hotdogs": "^2", + "@symfony/existing-package": "file:vendor/symfony/existing-package/Resources/assets", + "@symfony/stimulus-bridge": "^1.0.0", + "stimulus": "^1.1.1" + }, + "browserslist": [ + "defaults" + ] +}', + trim(file_get_contents($this->tempDir.'/package.json')) + ); + } + + public function testSynchronizeAssetMapperNewPackage() + { + file_put_contents($this->tempDir.'/importmap.php', 'tempDir.'/vendor/symfony/new-package/assets/dist/loader.js'; + $this->scriptExecutor->expects($this->exactly(2)) + ->method('execute') + ->withConsecutive( + ['symfony-cmd', 'importmap:require', ['@hotcake@^1.9.0']], + ['symfony-cmd', 'importmap:require', ['@symfony/new-package', '--path='.$fileModulePath, '--preload']] + ); + + $this->synchronizer->synchronize([ + [ + 'name' => 'symfony/existing-package', + 'keywords' => ['symfony-ux'], + ], + [ + 'name' => 'symfony/new-package', + 'keywords' => ['symfony-ux'], + ], + ]); + + // package.json exists, but should remain untouched because importmap.php was found + $this->assertSame( + '{ + "name": "symfony/fixture", + "devDependencies": { + "@symfony/stimulus-bridge": "^1.0.0", + "stimulus": "^1.1.1", + "@symfony/existing-package": "file:vendor/symfony/existing-package/Resources/assets" + }, + "browserslist": [ + "defaults" + ] +}', + trim(file_get_contents($this->tempDir.'/package.json')) + ); + + // controllers.json updated like normal + $this->assertSame( + [ + 'controllers' => [ + '@symfony/existing-package' => [ + 'mock' => [ + 'enabled' => false, + 'fetch' => 'eager', + 'autoimport' => [ + '@symfony/existing-package/dist/style.css' => false, + '@symfony/existing-package/dist/new-style.css' => true, + ], + ], + ], + '@symfony/new-package' => [ + 'new' => [ + 'enabled' => true, + 'fetch' => 'lazy', + 'autoimport' => [ + '@symfony/new-package/dist/style.css' => true, + ], + ], + ], + ], + 'entrypoints' => ['admin.js'], + ], + json_decode(file_get_contents($this->tempDir.'/assets/controllers.json'), true) + ); + } }