diff --git a/.github/workflows/magento-compatibility.yml b/.github/workflows/magento-compatibility.yml index a4b8fdf6..ba40e351 100644 --- a/.github/workflows/magento-compatibility.yml +++ b/.github/workflows/magento-compatibility.yml @@ -55,12 +55,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: path: mageforge - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: ${{ matrix.php-version }} extensions: mbstring, intl, gd, xml, soap, zip, bcmath, pdo_mysql, curl, sockets @@ -68,7 +68,7 @@ jobs: - name: Cache Composer packages id: composer-cache - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: path: ~/.composer/cache/files key: ${{ runner.os }}-composer-${{ matrix.magento-version }}-${{ hashFiles('**/composer.json') }} @@ -147,6 +147,7 @@ jobs: bin/magento mageforge:theme:inspector --help bin/magento mageforge:hyva:compatibility:check --help bin/magento mageforge:hyva:tokens --help + bin/magento mageforge:dependencies:update --help echo "Verify command aliases work:" bin/magento m:s:v --help @@ -162,6 +163,7 @@ jobs: bin/magento frontend:clean --help bin/magento hyva:check --help bin/magento hyva:tokens --help + bin/magento dependencies:update --help - name: Test Summary run: | diff --git a/docs/commands_reference.md b/docs/commands_reference.md index 118a1f5a..3d5e1da8 100644 --- a/docs/commands_reference.md +++ b/docs/commands_reference.md @@ -4,17 +4,18 @@ Complete reference of all CLI commands provided by the MageForge module. ## Quick Overview -| Group | Command | Description | Aliases | -| ---------- | ------------------------------------ | --------------------------------------------------- | ---------------- | -| **Theme** | `mageforge:theme:list` | List all available Magento themes | `frontend:list` | -| **Theme** | `mageforge:theme:build` | Build selected themes (CSS/TailwindCSS) | `frontend:build` | -| **Theme** | `mageforge:theme:watch` | Watch theme files and auto-rebuild | `frontend:watch` | -| **Theme** | `mageforge:theme:clean` | Clean static files and cache directories | `frontend:clean` | -| **Theme** | `mageforge:theme:inspector` | Manage Frontend Inspector (enable/disable/status) | — | -| **Hyvä** | `mageforge:hyva:tokens` | Generate Hyvä design tokens | `hyva:tokens` | -| **Hyvä** | `mageforge:hyva:compatibility:check` | Check modules for Hyvä compatibility issues | `hyva:check` | -| **System** | `mageforge:system:version` | Show current and latest module version | `system:version` | -| **System** | `mageforge:system:check` | Display system information (PHP, Node.js, DB, etc.) | `system:check` | +| Group | Command | Description | Aliases | +| ---------------- | ------------------------------------ | --------------------------------------------------- | --------------------- | +| **Theme** | `mageforge:theme:list` | List all available Magento themes | `frontend:list` | +| **Theme** | `mageforge:theme:build` | Build selected themes (CSS/TailwindCSS) | `frontend:build` | +| **Theme** | `mageforge:theme:watch` | Watch theme files and auto-rebuild | `frontend:watch` | +| **Theme** | `mageforge:theme:clean` | Clean static files and cache directories | `frontend:clean` | +| **Theme** | `mageforge:theme:inspector` | Manage Frontend Inspector (enable/disable/status) | — | +| **Dependencies** | `mageforge:dependencies:update` | Update the Node.js dependencies of themes | `dependencies:update` | +| **Hyvä** | `mageforge:hyva:tokens` | Generate Hyvä design tokens | `hyva:tokens` | +| **Hyvä** | `mageforge:hyva:compatibility:check` | Check modules for Hyvä compatibility issues | `hyva:check` | +| **System** | `mageforge:system:version` | Show current and latest module version | `system:version` | +| **System** | `mageforge:system:check` | Display system information (PHP, Node.js, DB, etc.) | `system:check` | --- @@ -124,6 +125,41 @@ bin/magento mageforge:theme:inspector status --- +## Dependencies Commands + +### `mageforge:dependencies:update` + +Updates the Node.js dependencies (npm packages) of one or more themes. Works with all themes that ship their own `package.json`, e.g. Hyvä and TailwindCSS themes (`web/tailwind/package.json`). For standard Magento themes (e.g. `Magento/luma`) the Node.js setup in the Magento root is updated instead, since that is what builds their assets. + +```bash +bin/magento mageforge:dependencies:update [ ...] +bin/magento mageforge:dependencies:update Vendor/theme +bin/magento mageforge:dependencies:update Vendor/theme --dry-run +bin/magento mageforge:dependencies:update Vendor/theme --latest +``` + +**Arguments:** + +- `themeCodes` — One or more theme codes in format `Vendor/theme`. Accepts wildcards like `Vendor/*`. If omitted, an interactive prompt lets you select themes. + +**Options:** + +- `--dry-run` — Only show the outdated packages without changing anything. +- `-l, --latest` — Update packages beyond their semver ranges to the latest available versions. This rewrites `package.json` and may pull in breaking changes. + +**Behavior:** + +- Shows a table of all outdated packages (current, wanted, latest version and dependency type) per theme. +- By default runs a safe `npm update`: packages are only updated within the semver ranges defined in `package.json` (`package.json` itself stays untouched). +- With `--latest`, outdated packages are updated to their latest released versions, grouped by dependency type (`dependencies`, `devDependencies`, ...). +- Installs `node_modules` first if missing, so the report is meaningful. +- Themes installed in `vendor/` (managed by Composer) are skipped with an explanation. +- Standard Magento themes (Luma-based, no own `package.json`) fall back to the `package.json` in the Magento root (copied from `package.json.sample`), which powers their Grunt build. The root setup is shared, so it is updated at most once per run; if the Magento root has no `package.json`, the theme is skipped with a setup hint. Note that with `--latest` this major-bumps Grunt tooling used by **all** standard themes. +- Other themes without their own `package.json` are skipped. +- After a successful update, a hint reminds you to rebuild the affected themes with `mageforge:theme:build`. + +--- + ## Hyvä Commands ### `mageforge:hyva:tokens` @@ -210,6 +246,7 @@ mageforge:theme:build → Build theme assets mageforge:theme:watch → Watch & auto-rebuild mageforge:theme:clean → Clean static files mageforge:theme:inspector → Manage inspector tool +mageforge:dependencies:update → Update theme Node.js dependencies mageforge:hyva:tokens → Generate Hyvä design tokens mageforge:hyva:compatibility:check → Check Hyvä compatibility mageforge:system:version → Show module version diff --git a/src/Console/Command/Dependencies/UpdateCommand.php b/src/Console/Command/Dependencies/UpdateCommand.php new file mode 100644 index 00000000..2147e89b --- /dev/null +++ b/src/Console/Command/Dependencies/UpdateCommand.php @@ -0,0 +1,353 @@ +setName($this->getCommandName('dependencies', 'update')) + ->setDescription('Update the Node.js dependencies of one or more themes') + ->addArgument( + 'themeCodes', + InputArgument::IS_ARRAY, + 'Theme codes to update (format: Vendor/theme, Vendor, ...)', + ) + ->addOption( + 'latest', + 'l', + InputOption::VALUE_NONE, + 'Update packages beyond their semver ranges to the latest versions (updates package.json)', + ) + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show outdated packages without changing anything') + ->setAliases(['dependencies:update']); + } + + /** + * Execute command. + * + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function executeCommand(InputInterface $input, OutputInterface $output): int + { + $latest = (bool) $input->getOption('latest'); + $dryRun = (bool) $input->getOption('dry-run'); + + if ($dryRun) { + $this->io->note('DRY RUN MODE: No dependencies will be changed'); + } + + $themeCodes = $this->resolveThemeCodes($input, $output); + + if ($themeCodes === null) { + return Cli::RETURN_SUCCESS; + } + + $startTime = microtime(true); + + [$updatedThemes, $skippedThemes, $failedThemes] = $this->processThemes($themeCodes, $latest, $dryRun, $output); + + $this->displaySummary($updatedThemes, $skippedThemes, $failedThemes, $dryRun, microtime(true) - $startTime); + + if (empty($updatedThemes) && !empty($failedThemes)) { + return Cli::RETURN_FAILURE; + } + + return Cli::RETURN_SUCCESS; + } + + /** + * Resolve which themes to update based on input + * + * @param InputInterface $input + * @param OutputInterface $output + * @return array|null Array of theme codes or null to exit + */ + private function resolveThemeCodes(InputInterface $input, OutputInterface $output): ?array + { + /** @var array $themeCodes */ + $themeCodes = $input->getArgument('themeCodes'); + + if (!empty($themeCodes)) { + $themeCodes = $this->resolveVendorThemes($themeCodes, $this->themeList); + + // If wildcards matched nothing and no other explicit themes remain + if (empty($themeCodes)) { + return null; + } + + return $themeCodes; + } + + return $this->selectThemesInteractively($output); + } + + /** + * Select themes interactively + * + * @param OutputInterface $output + * @return array|null + */ + private function selectThemesInteractively(OutputInterface $output): ?array + { + $themes = $this->themeList->getAllThemes(); + $options = array_values(array_map(static fn($theme) => $theme->getCode(), $themes)); + + if (!$this->isInteractiveTerminal($output)) { + $this->displayAvailableThemes($themes); + return null; + } + + return $this->promptForThemes($options, $themes); + } + + /** + * Display available themes for non-interactive environments + * + * @param \Magento\Theme\Model\Theme[] $themes + * @return void + */ + private function displayAvailableThemes(array $themes): void + { + $this->io->warning('No theme specified. Available themes:'); + + if (empty($themes)) { + $this->io->info('No themes found.'); + return; + } + + foreach ($themes as $theme) { + $this->io->writeln(sprintf(' - %s (%s)', $theme->getCode(), $theme->getThemeTitle())); + } + + $this->io->newLine(); + $this->io->info('Usage: bin/magento mageforge:dependencies:update [...]'); + $this->io->info('Example: bin/magento mageforge:dependencies:update Vendor/theme'); + } + + /** + * Prompt user to select themes + * + * @param string[] $options + * @param \Magento\Theme\Model\Theme[] $themes + * @return string[]|null + */ + private function promptForThemes(array $options, array $themes): ?array + { + $this->setPromptEnvironment(); + + $themeCodesPrompt = new MultiSearchPrompt( + label: 'Select themes to update dependencies for', + options: static fn(string $value) => empty($value) + ? $options + : array_values(array_filter($options, static fn($option) => stripos($option, $value) !== false)), + placeholder: 'Type to search theme...', + hint: 'Type to search, arrow keys to navigate, Space to toggle, Enter to confirm', + required: false, + ); + + try { + $themeCodes = $themeCodesPrompt->prompt(); + \Laravel\Prompts\Prompt::terminal()->restoreTty(); + $this->resetPromptEnvironment(); + + if (empty($themeCodes)) { + $this->io->info('No themes selected.'); + return null; + } + + /** @var array $themeCodes */ + return $themeCodes; + } catch (\Exception $e) { + $this->resetPromptEnvironment(); + $this->io->error('Interactive mode failed: ' . $e->getMessage()); + $this->displayAvailableThemes($themes); + return null; + } + } + + /** + * Process the dependency update for all selected themes + * + * @param array $themeCodes + * @param bool $latest + * @param bool $dryRun + * @param OutputInterface $output + * @return array{array, array, array} [updatedThemes, skippedThemes, failedThemes] + */ + private function processThemes(array $themeCodes, bool $latest, bool $dryRun, OutputInterface $output): array + { + $isVerbose = $this->isVerbose($output); + $totalThemes = count($themeCodes); + $updatedThemes = []; + $skippedThemes = []; + $failedThemes = []; + + foreach ($themeCodes as $index => $themeCode) { + $currentTheme = (int) $index + 1; + + $validatedTheme = $this->validateTheme($themeCode, $failedThemes, $output); + + if ($validatedTheme === null) { + continue; + } + + if ($totalThemes > 1) { + $this->io->section(sprintf( + 'Updating dependencies %d of %d: %s', + $currentTheme, + $totalThemes, + $validatedTheme, + )); + } else { + $this->io->section(sprintf('Updating dependencies for theme: %s', $validatedTheme)); + } + + $themePath = (string) $this->themePath->getPath($validatedTheme); + + $result = $this->dependencyUpdater->updateThemeDependencies( + $validatedTheme, + $themePath, + $this->io, + $isVerbose, + $latest, + $dryRun, + ); + + match ($result) { + DependencyUpdateResult::Updated => $updatedThemes[] = $validatedTheme, + DependencyUpdateResult::Skipped => $skippedThemes[] = $validatedTheme, + DependencyUpdateResult::Failed => $failedThemes[] = $validatedTheme, + }; + } + + return [$updatedThemes, $skippedThemes, $failedThemes]; + } + + /** + * Validate theme exists, offering suggestions for invalid codes + * + * @param string $themeCode + * @param array $failedThemes + * @param OutputInterface $output + * @return string|null Theme code if valid or corrected, null if invalid + */ + private function validateTheme(string $themeCode, array &$failedThemes, OutputInterface $output): ?string + { + $themePath = $this->themePath->getPath($themeCode); + + if ($themePath === null) { + // Try to suggest similar themes + $correctedTheme = $this->handleInvalidThemeWithSuggestions($themeCode, $this->themeSuggester, $output); + + // If no theme was selected, mark as failed + if ($correctedTheme === null) { + $failedThemes[] = $themeCode; + return null; + } + + // Double-check the corrected theme exists + if ($this->themePath->getPath($correctedTheme) === null) { + $this->io->error(sprintf("Theme '%s' not found.", $correctedTheme)); + $failedThemes[] = $themeCode; + return null; + } + + $this->io->info("Using theme: $correctedTheme"); + return $correctedTheme; + } + + return $themeCode; + } + + /** + * Display summary of the update operation + * + * @param array $updatedThemes + * @param array $skippedThemes + * @param array $failedThemes + * @param bool $dryRun + * @param float $duration + * @return void + */ + private function displaySummary( + array $updatedThemes, + array $skippedThemes, + array $failedThemes, + bool $dryRun, + float $duration, + ): void { + $this->io->newLine(); + + if (!empty($updatedThemes)) { + $action = $dryRun ? 'Checked' : 'Updated'; + $this->io->success(sprintf( + '%s dependencies for %d theme(s) in %.2f seconds: %s', + $action, + count($updatedThemes), + $duration, + implode(', ', $updatedThemes), + )); + + if (!$dryRun) { + $this->io->note(sprintf('Rebuild the updated theme(s) to apply the changes: ' + . 'bin/magento mageforge:theme:build %s', implode(' ', $updatedThemes))); + } + } else { + $this->io->info('No theme dependencies were updated.'); + } + + if (!empty($skippedThemes)) { + $this->io->note(sprintf('Skipped %d theme(s): %s', count($skippedThemes), implode(', ', $skippedThemes))); + } + + if (!empty($failedThemes)) { + $this->io->warning(sprintf( + 'Failed to process %d theme(s): %s', + count($failedThemes), + implode(', ', $failedThemes), + )); + } + } +} diff --git a/src/Service/DependencyUpdateResult.php b/src/Service/DependencyUpdateResult.php new file mode 100644 index 00000000..127c269f --- /dev/null +++ b/src/Service/DependencyUpdateResult.php @@ -0,0 +1,26 @@ +isVendorTheme($themePath)) { + $io->warning(sprintf( + "Theme '%s' is installed in the vendor directory and is managed by Composer. Skipping.", + $themeCode, + )); + return DependencyUpdateResult::Skipped; + } + + $packageDirectories = $this->getPackageDirectories($themePath); + if (empty($packageDirectories)) { + return $this->updateMagentoRootDependencies($themeCode, $themePath, $io, $isVerbose, $latest, $dryRun); + } + + $result = true; + foreach ($packageDirectories as $directory) { + if (!$this->processPackageDirectory($directory, $io, $isVerbose, $latest, $dryRun)) { + $result = false; + } + } + + return $result ? DependencyUpdateResult::Updated : DependencyUpdateResult::Failed; + } + + /** + * Update the Magento root Node.js setup used to build standard Magento themes + * + * Standard Magento themes (e.g. Magento/luma) ship no own package.json; their + * assets are built with Grunt from the Node.js setup in the Magento root. That + * setup is shared by all standard themes, so it is processed at most once per + * run and the result is replayed for further standard themes. + * + * @param string $themeCode + * @param string $themePath + * @param SymfonyStyle $io + * @param bool $isVerbose + * @param bool $latest + * @param bool $dryRun + * @return DependencyUpdateResult + */ + private function updateMagentoRootDependencies( + string $themeCode, + string $themePath, + SymfonyStyle $io, + bool $isVerbose, + bool $latest, + bool $dryRun, + ): DependencyUpdateResult { + if (!$this->builderPool->getBuilder($themePath) instanceof MagentoStandardBuilder) { + $io->warning(sprintf("Theme '%s' has no own package.json. Skipping.", $themeCode)); + return DependencyUpdateResult::Skipped; + } + + $rootPath = rtrim($this->directoryList->getRoot(), '/'); + if (!$this->fileDriver->isExists($rootPath . '/' . self::PACKAGE_JSON)) { + $io->warning(sprintf( + "Theme '%s' is built from the Node.js setup in the Magento root, but the Magento root has " + . 'no package.json. Copy package.json.sample to package.json to set it up.', + $themeCode, + )); + return DependencyUpdateResult::Skipped; + } + + if ($this->magentoRootResult !== null) { + $io->writeln(sprintf( + " Theme '%s' uses the Magento root Node.js setup, which was already processed in this run.", + $themeCode, + )); + return $this->magentoRootResult; + } + + $io->text(sprintf( + "Theme '%s' has no own package.json. Updating the Magento root Node.js setup it is built from.", + $themeCode, + )); + + $this->magentoRootResult = $this->processPackageDirectory($rootPath, $io, $isVerbose, $latest, $dryRun) + ? DependencyUpdateResult::Updated + : DependencyUpdateResult::Failed; + + return $this->magentoRootResult; + } + + /** + * Get the theme-owned directories containing a package.json + * + * @param string $themePath + * @return array + */ + public function getPackageDirectories(string $themePath): array + { + $themePath = rtrim($themePath, '/'); + $candidates = [$themePath . '/' . self::TAILWIND_DIR, $themePath]; + + $directories = []; + foreach ($candidates as $candidate) { + if ($this->fileDriver->isExists($candidate . '/' . self::PACKAGE_JSON)) { + $directories[] = $candidate; + } + } + + return $directories; + } + + /** + * Check if the theme is installed in the Composer vendor directory + * + * Anchored to the vendor directory in the Magento root so that themes whose + * vendor name is literally "vendor" (e.g. app/design/frontend/vendor/theme) + * are not mistaken for Composer-managed themes. + * + * @param string $themePath + * @return bool + */ + public function isVendorTheme(string $themePath): bool + { + return str_starts_with($themePath, rtrim($this->directoryList->getRoot(), '/') . '/vendor/'); + } + + /** + * Report and update the outdated packages of a single package directory + * + * @param string $directory + * @param SymfonyStyle $io + * @param bool $isVerbose + * @param bool $latest + * @param bool $dryRun + * @return bool + */ + private function processPackageDirectory( + string $directory, + SymfonyStyle $io, + bool $isVerbose, + bool $latest, + bool $dryRun, + ): bool { + // Install first so the "current" column of the outdated report is meaningful + if (!$dryRun && !$this->fileDriver->isDirectory($directory . '/' . self::NODE_MODULES)) { + if ($isVerbose) { + $io->text('node_modules missing. Installing dependencies first...'); + } + if (!$this->nodePackageManager->installNodeModules($directory, $io, $isVerbose)) { + return false; + } + } + + $outdated = $this->nodePackageManager->getOutdatedPackages($directory); + if ($outdated === null) { + $io->warning(sprintf( + 'Could not check for outdated packages in %s. Make sure Node.js and npm are available.', + $directory, + )); + return false; + } + + if (empty($outdated)) { + $io->writeln(sprintf(' ✓ All packages in %s are up to date.', $directory)); + return true; + } + + $this->renderOutdatedTable($directory, $outdated, $io); + + if ($dryRun) { + $target = $latest ? 'the latest versions' : 'the versions within the semver ranges of package.json'; + $io->note(sprintf( + 'Dry run: would update %d package(s) in %s to %s.', + count($outdated), + $directory, + $target, + )); + return true; + } + + $success = $latest + ? $this->updateToLatest($directory, $outdated, $io, $isVerbose) + : $this->nodePackageManager->updatePackages($directory, $io, $isVerbose); + + if (!$success) { + return false; + } + + $this->reportRemainingOutdatedPackages($directory, $latest, $io); + + return true; + } + + /** + * Update all outdated packages to their latest versions, grouped by dependency type + * + * @param string $directory + * @param array $outdated + * @param SymfonyStyle $io + * @param bool $isVerbose + * @return bool + */ + private function updateToLatest(string $directory, array $outdated, SymfonyStyle $io, bool $isVerbose): bool + { + $packagesByType = []; + foreach ($outdated as $package) { + if ($package['latest'] === '-' || $package['latest'] === $package['current']) { + continue; + } + if (!$this->isSafePackageSpec($package['name'], $package['latest'])) { + $io->warning(sprintf( + 'Skipping package with unexpected name or version: %s@%s', + $package['name'], + $package['latest'], + )); + continue; + } + $packagesByType[$package['type']][$package['name']] = $package['latest']; + } + + $result = true; + foreach ($packagesByType as $type => $packages) { + if (!$this->nodePackageManager->installPackageVersions($directory, $type, $packages, $io, $isVerbose)) { + $result = false; + } + } + + return $result; + } + + /** + * Report packages that are still outdated after the update + * + * @param string $directory + * @param bool $latest + * @param SymfonyStyle $io + * @return void + */ + private function reportRemainingOutdatedPackages(string $directory, bool $latest, SymfonyStyle $io): void + { + $remaining = $this->nodePackageManager->getOutdatedPackages($directory); + + if ($remaining === null) { + $io->warning(sprintf('Could not verify the update result for %s.', $directory)); + return; + } + + if (empty($remaining)) { + $io->writeln(sprintf(' ✓ All packages in %s are now up to date.', $directory)); + return; + } + + if ($latest) { + $io->warning(sprintf( + '%d package(s) in %s are still outdated. Check the npm output for details.', + count($remaining), + $directory, + )); + return; + } + + $io->note(sprintf( + '%d package(s) in %s have newer versions outside their semver range. ' + . 'Run again with --latest to update them (may contain breaking changes).', + count($remaining), + $directory, + )); + } + + /** + * Render the outdated packages as a table + * + * @param string $directory + * @param array $outdated + * @param SymfonyStyle $io + * @return void + */ + private function renderOutdatedTable(string $directory, array $outdated, SymfonyStyle $io): void + { + $io->writeln(sprintf(' Outdated packages in %s:', $directory)); + + $rows = []; + foreach ($outdated as $package) { + $rows[] = [ + sprintf('%s', $package['name']), + $package['current'], + $package['wanted'], + $this->formatLatestVersion($package['current'], $package['latest']), + $package['type'], + ]; + } + + $io->table(['Package', 'Current', 'Wanted', 'Latest', 'Type'], $rows); + } + + /** + * Highlight the latest version in yellow when it is a major update + * + * @param string $current + * @param string $latest + * @return string + */ + private function formatLatestVersion(string $current, string $latest): string + { + $currentMajor = explode('.', $current)[0]; + $latestMajor = explode('.', $latest)[0]; + + if ($currentMajor !== $latestMajor) { + return sprintf('%s', $latest); + } + + return sprintf('%s', $latest); + } + + /** + * Validate that a package name and version are safe to pass to npm + * + * @param string $name + * @param string $version + * @return bool + */ + private function isSafePackageSpec(string $name, string $version): bool + { + return preg_match(self::PACKAGE_NAME_PATTERN, $name) === 1 && preg_match(self::VERSION_PATTERN, $version) === 1; + } +} diff --git a/src/Service/NodePackageManager.php b/src/Service/NodePackageManager.php index 30129f13..2fc88b3d 100644 --- a/src/Service/NodePackageManager.php +++ b/src/Service/NodePackageManager.php @@ -13,6 +13,16 @@ */ class NodePackageManager { + /** + * Map of npm dependency types to their install save flags + */ + private const SAVE_FLAGS = [ + 'dependencies' => '--save', + 'devDependencies' => '--save-dev', + 'optionalDependencies' => '--save-optional', + 'peerDependencies' => '--save-peer', + ]; + /** * @param Shell $shell * @param FileDriver $fileDriver @@ -92,6 +102,129 @@ public function isNodeModulesInSync(string $path): bool } } + /** + * Get a normalized list of outdated npm packages for the given directory + * + * The npm process exits with a non-zero code when outdated packages exist, so the + * exit code is neutralized with a trailing "true". A "|| true" must not be used here + * because the Magento shell command renderer converts "||" into a pipe that swallows + * the npm output. npm always prints a JSON object ("{}" when everything is up to + * date), so empty or invalid output signals a failed check rather than "up to date". + * + * @param string $path + * @return array|null + * Null when the outdated check itself failed + */ + public function getOutdatedPackages(string $path): ?array + { + try { + $output = $this->shell->execute('cd %s && npm outdated --json --long 2>/dev/null; true', [$path]); + } catch (\Exception $e) { + return null; + } + + $decoded = json_decode($output, true); + if (!is_array($decoded) || isset($decoded['error'])) { + return null; + } + + $packages = []; + foreach ($decoded as $name => $info) { + // npm may report a list of entries per package (one per dependent) + if (is_array($info) && array_is_list($info)) { + $info = $info[0] ?? null; + } + if (!is_array($info)) { + continue; + } + + $packages[] = [ + 'name' => (string) $name, + 'current' => $this->getStringValue($info, 'current'), + 'wanted' => $this->getStringValue($info, 'wanted'), + 'latest' => $this->getStringValue($info, 'latest'), + 'type' => $this->getStringValue($info, 'type', 'dependencies'), + ]; + } + + return $packages; + } + + /** + * Update npm packages within the version ranges defined in package.json + * + * Only node_modules and package-lock.json are updated; package.json is left untouched. + * + * @param string $path + * @param SymfonyStyle $io + * @param bool $isVerbose + * @return bool + */ + public function updatePackages(string $path, SymfonyStyle $io, bool $isVerbose): bool + { + try { + if ($isVerbose) { + $io->text('Running npm update...'); + } + $this->shell->execute('cd %s && npm update --quiet', [$path]); + if ($isVerbose) { + $io->success('Packages updated within their semver ranges.'); + } + return true; + } catch (\Exception $e) { + $io->error('Failed to update packages: ' . $e->getMessage()); + return false; + } + } + + /** + * Install specific package versions with the save flag matching the dependency type + * + * @param string $path + * @param string $dependencyType npm dependency type (e.g. 'dependencies', 'devDependencies') + * @param array $packageVersions Map of package name to version + * @param SymfonyStyle $io + * @param bool $isVerbose + * @return bool + */ + public function installPackageVersions( + string $path, + string $dependencyType, + array $packageVersions, + SymfonyStyle $io, + bool $isVerbose, + ): bool { + if (empty($packageVersions)) { + return true; + } + + $saveFlag = self::SAVE_FLAGS[$dependencyType] ?? '--save'; + + $specs = []; + foreach ($packageVersions as $name => $version) { + $specs[] = $name . '@' . $version; + } + + $placeholders = implode(' ', array_fill(0, count($specs), '%s')); + + try { + if ($isVerbose) { + $io->text(sprintf('Installing %s: %s', $dependencyType, implode(', ', $specs))); + } + $this->shell->execute( + sprintf('cd %%s && npm install %s --quiet %s', $saveFlag, $placeholders), + array_merge([$path], $specs), + ); + if ($isVerbose) { + $io->success(sprintf('Installed %d package(s) as %s.', count($specs), $dependencyType)); + } + return true; + } catch (\Exception $e) { + $io->error('Failed to install packages: ' . $e->getMessage()); + return false; + } + } + /** * Check for outdated npm packages and report them * @@ -113,4 +246,18 @@ public function checkOutdatedPackages(string $path, SymfonyStyle $io): void } } } + + /** + * Read a string value from decoded npm JSON output + * + * @param array $info + * @param string $key + * @param string $default + * @return string + */ + private function getStringValue(array $info, string $key, string $default = '-'): string + { + $value = $info[$key] ?? null; + return is_string($value) && $value !== '' ? $value : $default; + } } diff --git a/src/etc/di.xml b/src/etc/di.xml index 2fe48a73..1e700bd2 100644 --- a/src/etc/di.xml +++ b/src/etc/di.xml @@ -25,6 +25,8 @@ OpenForgeProject\MageForge\Console\Command\Theme\TokensCommand OpenForgeProject\MageForge\Console\Command\Dev\InspectorCommand + + OpenForgeProject\MageForge\Console\Command\Dependencies\UpdateCommand diff --git a/tests/Unit/Console/Command/Dependencies/UpdateCommandTest.php b/tests/Unit/Console/Command/Dependencies/UpdateCommandTest.php new file mode 100644 index 00000000..4cae095d --- /dev/null +++ b/tests/Unit/Console/Command/Dependencies/UpdateCommandTest.php @@ -0,0 +1,178 @@ +dependencyUpdater = $this->createMock(DependencyUpdater::class); + $this->themePath = $this->createMock(ThemePath::class); + $this->themeList = $this->createMock(ThemeList::class); + $this->themeSuggester = $this->createMock(ThemeSuggester::class); + $this->command = new UpdateCommand( + $this->dependencyUpdater, + $this->themePath, + $this->themeList, + $this->themeSuggester, + ); + } + + public function testCommandNameAndAlias(): void + { + $this->assertSame('mageforge:dependencies:update', $this->command->getName()); + $this->assertSame(['dependencies:update'], $this->command->getAliases()); + } + + public function testUpdatesSingleThemeAndSuggestsRebuild(): void + { + $this->themePath->method('getPath')->with('Vendor/theme')->willReturn('/path'); + $this->dependencyUpdater + ->expects($this->once()) + ->method('updateThemeDependencies') + ->with('Vendor/theme', '/path', $this->anything(), false, false, false) + ->willReturn(DependencyUpdateResult::Updated); + + $tester = new CommandTester($this->command); + $exitCode = $tester->execute(['themeCodes' => ['Vendor/theme']]); + + $this->assertSame(Cli::RETURN_SUCCESS, $exitCode); + $display = $tester->getDisplay(); + $this->assertStringContainsString('Updating dependencies for theme: Vendor/theme', $display); + $this->assertStringContainsString('Updated dependencies for 1 theme(s)', $display); + $this->assertStringContainsString('mageforge:theme:build Vendor/theme', $display); + } + + public function testPassesLatestAndDryRunOptionsToService(): void + { + $this->themePath->method('getPath')->willReturn('/path'); + $this->dependencyUpdater + ->expects($this->once()) + ->method('updateThemeDependencies') + ->with('Vendor/theme', '/path', $this->anything(), false, true, true) + ->willReturn(DependencyUpdateResult::Updated); + + $tester = new CommandTester($this->command); + $exitCode = $tester->execute([ + 'themeCodes' => ['Vendor/theme'], + '--latest' => true, + '--dry-run' => true, + ]); + + $this->assertSame(Cli::RETURN_SUCCESS, $exitCode); + $display = $tester->getDisplay(); + $this->assertStringContainsString('DRY RUN MODE: No dependencies will be changed', $display); + $this->assertStringContainsString('Checked dependencies for 1 theme(s)', $display); + $this->assertStringNotContainsString('mageforge:theme:build', $display); + } + + public function testProcessesMultipleThemesWithSectionsPerTheme(): void + { + $this->themePath->method('getPath')->willReturn('/path'); + $this->dependencyUpdater->method('updateThemeDependencies')->willReturn(DependencyUpdateResult::Updated); + + $tester = new CommandTester($this->command); + $exitCode = $tester->execute(['themeCodes' => ['Vendor/one', 'Vendor/two']]); + + $this->assertSame(Cli::RETURN_SUCCESS, $exitCode); + $display = $tester->getDisplay(); + $this->assertStringContainsString('Updating dependencies 1 of 2: Vendor/one', $display); + $this->assertStringContainsString('Updating dependencies 2 of 2: Vendor/two', $display); + $this->assertStringContainsString('Updated dependencies for 2 theme(s)', $display); + } + + public function testReturnsFailureWhenAllThemesFail(): void + { + $this->themePath->method('getPath')->willReturn('/path'); + $this->dependencyUpdater->method('updateThemeDependencies')->willReturn(DependencyUpdateResult::Failed); + + $tester = new CommandTester($this->command); + $exitCode = $tester->execute(['themeCodes' => ['Vendor/theme']]); + + $this->assertSame(Cli::RETURN_FAILURE, $exitCode); + $display = $tester->getDisplay(); + $this->assertStringContainsString('No theme dependencies were updated.', $display); + $this->assertStringContainsString('Failed to process 1 theme(s): Vendor/theme', $display); + } + + public function testReturnsSuccessWhenAllThemesAreSkipped(): void + { + $this->themePath->method('getPath')->willReturn('/path'); + $this->dependencyUpdater->method('updateThemeDependencies')->willReturn(DependencyUpdateResult::Skipped); + + $tester = new CommandTester($this->command); + $exitCode = $tester->execute(['themeCodes' => ['Vendor/theme']]); + + $this->assertSame(Cli::RETURN_SUCCESS, $exitCode); + $display = $tester->getDisplay(); + $this->assertStringContainsString('Skipped 1 theme(s): Vendor/theme', $display); + $this->assertStringNotContainsString('Failed to process', $display); + } + + public function testReportsInvalidThemeWithoutCallingService(): void + { + $this->themePath->method('getPath')->willReturn(null); + $this->themeSuggester->method('findSimilarThemes')->willReturn([]); + $this->dependencyUpdater->expects($this->never())->method('updateThemeDependencies'); + + $tester = new CommandTester($this->command); + $exitCode = $tester->execute(['themeCodes' => ['Vendor/unknown']]); + + $this->assertSame(Cli::RETURN_FAILURE, $exitCode); + $display = $tester->getDisplay(); + $this->assertStringContainsString("Theme 'Vendor/unknown' is not installed", $display); + $this->assertStringContainsString('Failed to process 1 theme(s): Vendor/unknown', $display); + } + + public function testListsAvailableThemesWhenNoThemeGivenNonInteractively(): void + { + $theme = new FakeThemeWithTitle('Vendor/theme', 'Vendor Theme'); + $this->themeList->method('getAllThemes')->willReturn([$theme]); + $this->dependencyUpdater->expects($this->never())->method('updateThemeDependencies'); + + $tester = new CommandTester($this->command); + $exitCode = $tester->execute([]); + + $this->assertSame(Cli::RETURN_SUCCESS, $exitCode); + $display = $tester->getDisplay(); + $this->assertStringContainsString('No theme specified. Available themes:', $display); + $this->assertStringContainsString('Vendor/theme', $display); + $this->assertStringContainsString('mageforge:dependencies:update ', $display); + } + + public function testContinuesWithRemainingThemesWhenOneFails(): void + { + $this->themePath->method('getPath')->willReturn('/path'); + $this->dependencyUpdater + ->method('updateThemeDependencies') + ->willReturnOnConsecutiveCalls(DependencyUpdateResult::Failed, DependencyUpdateResult::Updated); + + $tester = new CommandTester($this->command); + $exitCode = $tester->execute(['themeCodes' => ['Vendor/broken', 'Vendor/fine']]); + + $this->assertSame(Cli::RETURN_SUCCESS, $exitCode); + $display = $tester->getDisplay(); + $this->assertStringContainsString('Updated dependencies for 1 theme(s)', $display); + $this->assertStringContainsString('Failed to process 1 theme(s): Vendor/broken', $display); + } +} diff --git a/tests/Unit/Service/DependencyUpdaterTest.php b/tests/Unit/Service/DependencyUpdaterTest.php new file mode 100644 index 00000000..cf40d175 --- /dev/null +++ b/tests/Unit/Service/DependencyUpdaterTest.php @@ -0,0 +1,549 @@ +fileDriver = $this->createMock(File::class); + $this->nodePackageManager = $this->createMock(NodePackageManager::class); + $this->builderPool = $this->createMock(BuilderPool::class); + $this->directoryList = $this->createMock(DirectoryList::class); + $this->directoryList->method('getRoot')->willReturn('/magento-root'); + $this->io = $this->createMock(SymfonyStyle::class); + $this->updater = new DependencyUpdater( + $this->fileDriver, + $this->nodePackageManager, + $this->builderPool, + $this->directoryList, + ); + } + + private function actAsMagentoStandardTheme(): void + { + $this->builderPool->method('getBuilder')->willReturn($this->createMock(MagentoStandardBuilder::class)); + } + + /** + * @return array{name: string, current: string, wanted: string, latest: string, type: string} + */ + private function outdatedPackage( + string $name = 'tailwindcss', + string $current = '3.4.1', + string $wanted = '3.4.17', + string $latest = '4.1.5', + string $type = 'devDependencies', + ): array { + return [ + 'name' => $name, + 'current' => $current, + 'wanted' => $wanted, + 'latest' => $latest, + 'type' => $type, + ]; + } + + // ------------------------------------------------------------------------- + // getPackageDirectories / isVendorTheme + // ------------------------------------------------------------------------- + + public function testFindsPackageJsonInTailwindAndThemeRoot(): void + { + $this->fileDriver->method('isExists')->willReturnMap([ + ['/theme/web/tailwind/package.json', true], + ['/theme/package.json', true], + ]); + + $this->assertSame( + ['/theme/web/tailwind', '/theme'], + $this->updater->getPackageDirectories('/theme/'), + ); + } + + public function testReturnsEmptyListWhenThemeHasNoPackageJson(): void + { + $this->fileDriver->method('isExists')->willReturn(false); + + $this->assertSame([], $this->updater->getPackageDirectories('/theme')); + } + + public function testDetectsVendorThemes(): void + { + $this->assertTrue($this->updater->isVendorTheme('/magento-root/vendor/hyva-themes/magento2-default-theme')); + $this->assertFalse($this->updater->isVendorTheme('/magento-root/app/design/frontend/Vendor/theme')); + $this->assertFalse($this->updater->isVendorTheme('/magento-root/app/design/frontend/vendor/theme')); + } + + // ------------------------------------------------------------------------- + // updateThemeDependencies + // ------------------------------------------------------------------------- + + public function testSkipsVendorThemesWithWarning(): void + { + $this->io + ->expects($this->once()) + ->method('warning') + ->with($this->stringContains('managed by Composer')); + $this->nodePackageManager->expects($this->never())->method('getOutdatedPackages'); + + $this->assertSame(DependencyUpdateResult::Skipped, $this->updater->updateThemeDependencies( + 'Hyva/default', + '/magento-root/vendor/hyva-themes/magento2-default-theme', + $this->io, + false, + false, + false, + )); + } + + public function testSkipsNonStandardThemesWithoutOwnPackageJson(): void + { + $this->fileDriver->method('isExists')->willReturn(false); + $this->builderPool->method('getBuilder')->willReturn(null); + $this->io + ->expects($this->once()) + ->method('warning') + ->with($this->stringContains('has no own package.json')); + $this->nodePackageManager->expects($this->never())->method('getOutdatedPackages'); + + $this->assertSame(DependencyUpdateResult::Skipped, $this->updater->updateThemeDependencies( + 'Vendor/csstheme', + '/theme', + $this->io, + false, + false, + false, + )); + } + + public function testReportsUpToDatePackagesWithoutUpdating(): void + { + $this->fileDriver->method('isExists')->willReturnMap([ + ['/theme/web/tailwind/package.json', true], + ['/theme/package.json', false], + ]); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->nodePackageManager->method('getOutdatedPackages')->willReturn([]); + $this->nodePackageManager->expects($this->never())->method('updatePackages'); + $this->io + ->expects($this->once()) + ->method('writeln') + ->with($this->stringContains('are up to date')); + + $this->assertSame(DependencyUpdateResult::Updated, $this->updater->updateThemeDependencies( + 'Vendor/theme', + '/theme', + $this->io, + false, + false, + false, + )); + } + + public function testInstallsNodeModulesFirstWhenMissing(): void + { + $this->fileDriver->method('isExists')->willReturnMap([ + ['/theme/web/tailwind/package.json', true], + ['/theme/package.json', false], + ]); + $this->fileDriver->method('isDirectory')->with('/theme/web/tailwind/node_modules')->willReturn(false); + $this->nodePackageManager + ->expects($this->once()) + ->method('installNodeModules') + ->with('/theme/web/tailwind', $this->io, false) + ->willReturn(true); + $this->nodePackageManager->method('getOutdatedPackages')->willReturn([]); + + $this->assertSame(DependencyUpdateResult::Updated, $this->updater->updateThemeDependencies( + 'Vendor/theme', + '/theme', + $this->io, + false, + false, + false, + )); + } + + public function testFailsWhenInitialInstallFails(): void + { + $this->fileDriver->method('isExists')->willReturnMap([ + ['/theme/web/tailwind/package.json', true], + ['/theme/package.json', false], + ]); + $this->fileDriver->method('isDirectory')->willReturn(false); + $this->nodePackageManager->method('installNodeModules')->willReturn(false); + $this->nodePackageManager->expects($this->never())->method('getOutdatedPackages'); + + $this->assertSame(DependencyUpdateResult::Failed, $this->updater->updateThemeDependencies( + 'Vendor/theme', + '/theme', + $this->io, + false, + false, + false, + )); + } + + public function testUpdatesWithinSemverRangesByDefault(): void + { + $this->fileDriver->method('isExists')->willReturnMap([ + ['/theme/web/tailwind/package.json', true], + ['/theme/package.json', false], + ]); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->nodePackageManager + ->method('getOutdatedPackages') + ->willReturnOnConsecutiveCalls([$this->outdatedPackage()], []); + $this->nodePackageManager + ->expects($this->once()) + ->method('updatePackages') + ->with('/theme/web/tailwind') + ->willReturn(true); + $this->nodePackageManager->expects($this->never())->method('installPackageVersions'); + + $this->assertSame(DependencyUpdateResult::Updated, $this->updater->updateThemeDependencies( + 'Vendor/theme', + '/theme', + $this->io, + false, + false, + false, + )); + } + + public function testHintsAtLatestOptionWhenMajorUpdatesRemain(): void + { + $this->fileDriver->method('isExists')->willReturnMap([ + ['/theme/web/tailwind/package.json', true], + ['/theme/package.json', false], + ]); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->nodePackageManager + ->method('getOutdatedPackages') + ->willReturnOnConsecutiveCalls([$this->outdatedPackage()], [$this->outdatedPackage(current: '3.4.17')]); + $this->nodePackageManager->method('updatePackages')->willReturn(true); + $this->io + ->expects($this->once()) + ->method('note') + ->with($this->stringContains('--latest')); + + $this->assertSame(DependencyUpdateResult::Updated, $this->updater->updateThemeDependencies( + 'Vendor/theme', + '/theme', + $this->io, + false, + false, + false, + )); + } + + public function testDryRunOnlyReportsWithoutChangingAnything(): void + { + $this->fileDriver->method('isExists')->willReturnMap([ + ['/theme/web/tailwind/package.json', true], + ['/theme/package.json', false], + ]); + $this->nodePackageManager->method('getOutdatedPackages')->willReturn([$this->outdatedPackage()]); + $this->nodePackageManager->expects($this->never())->method('installNodeModules'); + $this->nodePackageManager->expects($this->never())->method('updatePackages'); + $this->nodePackageManager->expects($this->never())->method('installPackageVersions'); + $this->io + ->expects($this->once()) + ->method('note') + ->with($this->stringContains('Dry run: would update 1 package(s)')); + + $this->assertSame(DependencyUpdateResult::Updated, $this->updater->updateThemeDependencies( + 'Vendor/theme', + '/theme', + $this->io, + false, + false, + true, + )); + } + + public function testLatestModeInstallsPinnedVersionsGroupedByType(): void + { + $this->fileDriver->method('isExists')->willReturnMap([ + ['/theme/web/tailwind/package.json', true], + ['/theme/package.json', false], + ]); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->nodePackageManager + ->method('getOutdatedPackages') + ->willReturnOnConsecutiveCalls( + [ + $this->outdatedPackage(), + $this->outdatedPackage(name: 'postcss', current: '8.4.0', wanted: '8.5.0', latest: '8.5.0'), + $this->outdatedPackage(name: 'alpinejs', current: '3.13.0', latest: '3.14.9', type: 'dependencies'), + ], + [], + ); + $installedGroups = []; + $this->nodePackageManager + ->method('installPackageVersions') + ->willReturnCallback( + function (string $path, string $type, array $packages) use (&$installedGroups): bool { + $installedGroups[$type] = $packages; + $this->assertSame('/theme/web/tailwind', $path); + return true; + }, + ); + $this->nodePackageManager->expects($this->never())->method('updatePackages'); + + $this->assertSame(DependencyUpdateResult::Updated, $this->updater->updateThemeDependencies( + 'Vendor/theme', + '/theme', + $this->io, + false, + true, + false, + )); + $this->assertSame([ + 'devDependencies' => ['tailwindcss' => '4.1.5', 'postcss' => '8.5.0'], + 'dependencies' => ['alpinejs' => '3.14.9'], + ], $installedGroups); + } + + public function testLatestModeSkipsUnsafePackageSpecsWithWarning(): void + { + $this->fileDriver->method('isExists')->willReturnMap([ + ['/theme/web/tailwind/package.json', true], + ['/theme/package.json', false], + ]); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->nodePackageManager + ->method('getOutdatedPackages') + ->willReturnOnConsecutiveCalls( + [ + $this->outdatedPackage(name: 'evil; rm -rf /', latest: '1.0.0'), + $this->outdatedPackage(name: 'nolatest', latest: '-'), + ], + [], + ); + $this->nodePackageManager->expects($this->never())->method('installPackageVersions'); + $this->io + ->expects($this->once()) + ->method('warning') + ->with($this->stringContains('unexpected name or version')); + + $this->assertSame(DependencyUpdateResult::Updated, $this->updater->updateThemeDependencies( + 'Vendor/theme', + '/theme', + $this->io, + false, + true, + false, + )); + } + + public function testFailsWhenNpmUpdateFails(): void + { + $this->fileDriver->method('isExists')->willReturnMap([ + ['/theme/web/tailwind/package.json', true], + ['/theme/package.json', false], + ]); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->nodePackageManager->method('getOutdatedPackages')->willReturn([$this->outdatedPackage()]); + $this->nodePackageManager->method('updatePackages')->willReturn(false); + + $this->assertSame(DependencyUpdateResult::Failed, $this->updater->updateThemeDependencies( + 'Vendor/theme', + '/theme', + $this->io, + false, + false, + false, + )); + } + + public function testFailsWhenOutdatedCheckFails(): void + { + $this->fileDriver->method('isExists')->willReturnMap([ + ['/theme/web/tailwind/package.json', true], + ['/theme/package.json', false], + ]); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->nodePackageManager->method('getOutdatedPackages')->willReturn(null); + $this->nodePackageManager->expects($this->never())->method('updatePackages'); + $this->io + ->expects($this->once()) + ->method('warning') + ->with($this->stringContains('Could not check for outdated packages')); + + $this->assertSame(DependencyUpdateResult::Failed, $this->updater->updateThemeDependencies( + 'Vendor/theme', + '/theme', + $this->io, + false, + false, + false, + )); + } + + public function testWarnsWhenPostUpdateCheckFails(): void + { + $this->fileDriver->method('isExists')->willReturnMap([ + ['/theme/web/tailwind/package.json', true], + ['/theme/package.json', false], + ]); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->nodePackageManager + ->method('getOutdatedPackages') + ->willReturnOnConsecutiveCalls([$this->outdatedPackage()], null); + $this->nodePackageManager->method('updatePackages')->willReturn(true); + $this->io + ->expects($this->once()) + ->method('warning') + ->with($this->stringContains('Could not verify the update result')); + + $this->assertSame(DependencyUpdateResult::Updated, $this->updater->updateThemeDependencies( + 'Vendor/theme', + '/theme', + $this->io, + false, + false, + false, + )); + } + + // ------------------------------------------------------------------------- + // Magento root fallback for standard themes (Magento/luma, Magento/blank) + // ------------------------------------------------------------------------- + + public function testUpdatesMagentoRootForStandardThemes(): void + { + $this->actAsMagentoStandardTheme(); + $this->fileDriver->method('isExists')->willReturnMap([ + ['/luma/web/tailwind/package.json', false], + ['/luma/package.json', false], + ['/magento-root/package.json', true], + ]); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->nodePackageManager + ->expects($this->once()) + ->method('getOutdatedPackages') + ->with('/magento-root') + ->willReturn([]); + + $this->assertSame(DependencyUpdateResult::Updated, $this->updater->updateThemeDependencies( + 'Magento/luma', + '/luma', + $this->io, + false, + false, + false, + )); + } + + public function testSkipsStandardThemeWhenMagentoRootHasNoPackageJson(): void + { + $this->actAsMagentoStandardTheme(); + $this->fileDriver->method('isExists')->willReturn(false); + $this->nodePackageManager->expects($this->never())->method('getOutdatedPackages'); + $this->io + ->expects($this->once()) + ->method('warning') + ->with($this->stringContains('package.json.sample')); + + $this->assertSame(DependencyUpdateResult::Skipped, $this->updater->updateThemeDependencies( + 'Magento/luma', + '/luma', + $this->io, + false, + false, + false, + )); + } + + public function testUpdatesMagentoRootOnlyOncePerRun(): void + { + $this->actAsMagentoStandardTheme(); + $this->fileDriver->method('isExists')->willReturnMap([ + ['/luma/web/tailwind/package.json', false], + ['/luma/package.json', false], + ['/blank/web/tailwind/package.json', false], + ['/blank/package.json', false], + ['/magento-root/package.json', true], + ]); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->nodePackageManager + ->expects($this->once()) + ->method('getOutdatedPackages') + ->with('/magento-root') + ->willReturn([]); + + $this->assertSame(DependencyUpdateResult::Updated, $this->updater->updateThemeDependencies( + 'Magento/luma', + '/luma', + $this->io, + false, + false, + false, + )); + $this->assertSame(DependencyUpdateResult::Updated, $this->updater->updateThemeDependencies( + 'Magento/blank', + '/blank', + $this->io, + false, + false, + false, + )); + } + + public function testReplaysMagentoRootFailureForSubsequentStandardThemes(): void + { + $this->actAsMagentoStandardTheme(); + $this->fileDriver->method('isExists')->willReturnMap([ + ['/luma/web/tailwind/package.json', false], + ['/luma/package.json', false], + ['/blank/web/tailwind/package.json', false], + ['/blank/package.json', false], + ['/magento-root/package.json', true], + ]); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->nodePackageManager + ->expects($this->once()) + ->method('getOutdatedPackages') + ->with('/magento-root') + ->willReturn(null); + + $this->assertSame(DependencyUpdateResult::Failed, $this->updater->updateThemeDependencies( + 'Magento/luma', + '/luma', + $this->io, + false, + false, + false, + )); + $this->assertSame(DependencyUpdateResult::Failed, $this->updater->updateThemeDependencies( + 'Magento/blank', + '/blank', + $this->io, + false, + false, + false, + )); + } +} diff --git a/tests/Unit/Service/NodePackageManagerTest.php b/tests/Unit/Service/NodePackageManagerTest.php index d67e92d5..af5ee823 100644 --- a/tests/Unit/Service/NodePackageManagerTest.php +++ b/tests/Unit/Service/NodePackageManagerTest.php @@ -199,4 +199,210 @@ public function testReportsCheckFailureOnlyInVerboseMode(): void $this->packageManager->checkOutdatedPackages('/theme', $this->io); } + + // ------------------------------------------------------------------------- + // getOutdatedPackages + // ------------------------------------------------------------------------- + + public function testParsesOutdatedPackagesFromNpmJsonOutput(): void + { + $this->shell + ->expects($this->once()) + ->method('execute') + ->with('cd %s && npm outdated --json --long 2>/dev/null; true', ['/theme']) + ->willReturn(json_encode([ + 'tailwindcss' => [ + 'current' => '3.4.1', + 'wanted' => '3.4.17', + 'latest' => '4.1.5', + 'type' => 'devDependencies', + ], + 'alpinejs' => [ + 'current' => '3.13.0', + 'wanted' => '3.14.9', + 'latest' => '3.14.9', + 'type' => 'dependencies', + ], + ], JSON_THROW_ON_ERROR)); + + $packages = $this->packageManager->getOutdatedPackages('/theme'); + + $this->assertSame([ + [ + 'name' => 'tailwindcss', + 'current' => '3.4.1', + 'wanted' => '3.4.17', + 'latest' => '4.1.5', + 'type' => 'devDependencies', + ], + [ + 'name' => 'alpinejs', + 'current' => '3.13.0', + 'wanted' => '3.14.9', + 'latest' => '3.14.9', + 'type' => 'dependencies', + ], + ], $packages); + } + + public function testNormalizesListEntriesAndMissingFields(): void + { + $this->shell->method('execute')->willReturn(json_encode([ + 'postcss' => [ + ['wanted' => '8.5.0', 'latest' => '8.5.0'], + ['wanted' => '8.4.0', 'latest' => '8.5.0'], + ], + ], JSON_THROW_ON_ERROR)); + + $packages = $this->packageManager->getOutdatedPackages('/theme'); + + $this->assertSame([ + [ + 'name' => 'postcss', + 'current' => '-', + 'wanted' => '8.5.0', + 'latest' => '8.5.0', + 'type' => 'dependencies', + ], + ], $packages); + } + + public function testReturnsEmptyListWhenEverythingIsUpToDate(): void + { + $this->shell->method('execute')->willReturn('{}'); + + $this->assertSame([], $this->packageManager->getOutdatedPackages('/theme')); + } + + public function testReturnsNullOnEmptyOutput(): void + { + $this->shell->method('execute')->willReturn(''); + + $this->assertNull($this->packageManager->getOutdatedPackages('/theme')); + } + + public function testReturnsNullOnInvalidJsonOutput(): void + { + $this->shell->method('execute')->willReturn('npm ERR! something went wrong'); + + $this->assertNull($this->packageManager->getOutdatedPackages('/theme')); + } + + public function testReturnsNullOnNpmErrorJsonOutput(): void + { + $this->shell->method('execute')->willReturn(json_encode([ + 'error' => ['code' => 'ENOTFOUND', 'summary' => 'registry unreachable'], + ], JSON_THROW_ON_ERROR)); + + $this->assertNull($this->packageManager->getOutdatedPackages('/theme')); + } + + public function testReturnsNullWhenShellExecutionFails(): void + { + $this->shell->method('execute')->willThrowException(new \RuntimeException('npm not found')); + + $this->assertNull($this->packageManager->getOutdatedPackages('/theme')); + } + + // ------------------------------------------------------------------------- + // updatePackages + // ------------------------------------------------------------------------- + + public function testRunsNpmUpdate(): void + { + $this->shell + ->expects($this->once()) + ->method('execute') + ->with('cd %s && npm update --quiet', ['/theme']); + + $this->assertTrue($this->packageManager->updatePackages('/theme', $this->io, false)); + } + + public function testReportsSuccessInVerboseModeAfterUpdate(): void + { + $this->io->expects($this->once())->method('text')->with('Running npm update...'); + $this->io->expects($this->once())->method('success')->with('Packages updated within their semver ranges.'); + + $this->assertTrue($this->packageManager->updatePackages('/theme', $this->io, true)); + } + + public function testReturnsFalseAndPrintsErrorWhenUpdateFails(): void + { + $this->shell->method('execute')->willThrowException(new \RuntimeException('registry unreachable')); + $this->io + ->expects($this->once()) + ->method('error') + ->with('Failed to update packages: registry unreachable'); + + $this->assertFalse($this->packageManager->updatePackages('/theme', $this->io, false)); + } + + // ------------------------------------------------------------------------- + // installPackageVersions + // ------------------------------------------------------------------------- + + public function testInstallsPackagesWithSaveDevFlagForDevDependencies(): void + { + $this->shell + ->expects($this->once()) + ->method('execute') + ->with( + 'cd %s && npm install --save-dev --quiet %s %s', + ['/theme', 'tailwindcss@4.1.5', 'postcss@8.5.0'], + ); + + $this->assertTrue($this->packageManager->installPackageVersions( + '/theme', + 'devDependencies', + ['tailwindcss' => '4.1.5', 'postcss' => '8.5.0'], + $this->io, + false, + )); + } + + public function testInstallsPackagesWithSaveFlagForUnknownDependencyType(): void + { + $this->shell + ->expects($this->once()) + ->method('execute') + ->with('cd %s && npm install --save --quiet %s', ['/theme', 'alpinejs@3.14.9']); + + $this->assertTrue($this->packageManager->installPackageVersions( + '/theme', + 'somethingUnknown', + ['alpinejs' => '3.14.9'], + $this->io, + false, + )); + } + + public function testSkipsShellExecutionForEmptyPackageList(): void + { + $this->shell->expects($this->never())->method('execute'); + + $this->assertTrue($this->packageManager->installPackageVersions( + '/theme', + 'dependencies', + [], + $this->io, + false, + )); + } + + public function testReturnsFalseAndPrintsErrorWhenInstallOfVersionsFails(): void + { + $this->shell->method('execute')->willThrowException(new \RuntimeException('E404 not found')); + $this->io + ->expects($this->once()) + ->method('error') + ->with('Failed to install packages: E404 not found'); + + $this->assertFalse($this->packageManager->installPackageVersions( + '/theme', + 'dependencies', + ['alpinejs' => '3.14.9'], + $this->io, + false, + )); + } }