From 2c517b012ee0095fba3aa71e305d110017fb0292 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Thu, 2 Apr 2026 10:22:56 -0600 Subject: [PATCH] Add compatibility: blueprint support for major version upgrade gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `compatibility:` field to plugin/theme blueprints.yaml that allows authors to declare which Grav versions they've tested on: compatibility: grav: ['1.7', '1.8'] api: ['1.0'] When absent, compatibility is inferred from dependencies (grav >=1.8 means 1.8-only, otherwise assumes 1.7). This gates major upgrades (1.7→1.8) by blocking if enabled plugins aren't marked as 1.8-compatible. Core changes: - Install.php: detectIncompatiblePackages() in preflight checks - SafeUpgradeService: same detection for admin pre-download checks - SelfupgradeCommand: interactive handling (disable/continue/abort) - GPM Local/Package: computed compatibility property from blueprints - CLI badges: IndexCommand, InfoCommand, UpdateCommand show 1.7/1.8 badges Co-Authored-By: Claude Opus 4.6 (1M context) --- system/src/Grav/Common/GPM/Local/Package.php | 52 +++++ .../Common/Upgrade/SafeUpgradeService.php | 177 ++++++++++++++++ system/src/Grav/Console/Gpm/IndexCommand.php | 35 +++- system/src/Grav/Console/Gpm/InfoCommand.php | 29 ++- .../Grav/Console/Gpm/SelfupgradeCommand.php | 66 +++++- system/src/Grav/Console/Gpm/UpdateCommand.php | 18 +- system/src/Grav/Installer/Install.php | 163 +++++++++++++++ .../Console/Gpm/SelfupgradeCommandTest.php | 105 ++++++++++ .../Installer/InstallCompatibilityTest.php | 191 ++++++++++++++++++ 9 files changed, 830 insertions(+), 6 deletions(-) create mode 100644 tests/unit/Grav/Installer/InstallCompatibilityTest.php diff --git a/system/src/Grav/Common/GPM/Local/Package.php b/system/src/Grav/Common/GPM/Local/Package.php index cfecb7101..594db0997 100644 --- a/system/src/Grav/Common/GPM/Local/Package.php +++ b/system/src/Grav/Common/GPM/Local/Package.php @@ -39,6 +39,7 @@ class Package extends BasePackage $this->data->set('description_html', $html_description); $this->data->set('description_plain', strip_tags((string) $html_description)); $this->data->set('symlink', is_link(USER_DIR . $package_type . DS . $this->__get('slug'))); + $this->data->set('compatibility', $this->resolveCompatibility($data)); } /** @@ -48,4 +49,55 @@ class Package extends BasePackage { return (bool)$this->settings['enabled']; } + + /** + * Resolve the compatibility metadata for this package. + * + * Reads explicit `compatibility.grav` / `compatibility.api` from the blueprint. + * When absent, infers Grav compatibility from the `dependencies` array. + * + * @param Data $data Blueprint data + * @return array{grav: string[], api: string[]} + */ + protected function resolveCompatibility(Data $data): array + { + $raw = $data->get('compatibility'); + + if (is_array($raw) && isset($raw['grav']) && is_array($raw['grav'])) { + return [ + 'grav' => array_map('strval', $raw['grav']), + 'api' => isset($raw['api']) && is_array($raw['api']) ? array_map('strval', $raw['api']) : [], + ]; + } + + return $this->inferCompatibility($data->get('dependencies') ?? []); + } + + /** + * Infer Grav compatibility from the dependencies array. + * + * @param array $dependencies + * @return array{grav: string[], api: string[]} + */ + protected function inferCompatibility(array $dependencies): array + { + foreach ($dependencies as $dep) { + if (!is_array($dep) || ($dep['name'] ?? '') !== 'grav') { + continue; + } + $version = $dep['version'] ?? ''; + + if (!preg_match('/(\d+\.\d+(?:\.\d+)?)/', $version, $m)) { + continue; + } + + if (version_compare($m[1], '1.8', '>=')) { + return ['grav' => ['1.8'], 'api' => []]; + } + + return ['grav' => ['1.7'], 'api' => []]; + } + + return ['grav' => ['1.7'], 'api' => []]; + } } diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index 930b72d3c..b4d5ed951 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -184,10 +184,19 @@ class SafeUpgradeService $warnings[] = 'Potential Monolog logger API incompatibilities detected.'; } + $incompatible = ['blocking' => [], 'warnings' => [], 'target' => '']; + if ($isMajorMinorUpgrade && $targetVersion !== null) { + $incompatible = $this->detectIncompatiblePackages($targetVersion); + if (!empty($incompatible['blocking'])) { + $warnings[] = 'Some enabled plugins/themes have not been marked as compatible with Grav ' . $incompatible['target'] . '.'; + } + } + return [ 'plugins_pending' => $pending, 'psr_log_conflicts' => $psrLogConflicts, 'monolog_conflicts' => $monologConflicts, + 'incompatible_packages' => $incompatible, 'warnings' => $warnings, 'is_major_minor_upgrade' => $isMajorMinorUpgrade, ]; @@ -836,6 +845,174 @@ class SafeUpgradeService return $conflicts; } + /** + * Detect installed plugins/themes not compatible with the target Grav version. + * + * @param string $targetVersion Target Grav version (e.g. '1.8.0') + * @return array{blocking: array, warnings: array, target: string} + */ + protected function detectIncompatiblePackages(string $targetVersion): array + { + $parts = explode('.', $targetVersion); + $targetMajorMinor = ($parts[0] ?? '1') . '.' . ($parts[1] ?? '7'); + + $blocking = []; + $warnings = []; + + $pluginDirs = glob($this->rootPath . '/user/plugins/*', GLOB_ONLYDIR) ?: []; + foreach ($pluginDirs as $dir) { + $slug = basename($dir); + $compat = $this->readBlueprintCompatibility($dir); + + if (in_array($targetMajorMinor, $compat['grav'], true)) { + continue; + } + + $version = $this->readBlueprintVersion($dir) ?? 'unknown'; + $enabled = $this->isPluginEnabled($slug); + + $entry = [ + 'type' => 'plugin', + 'version' => $version, + 'compatibility' => $compat, + 'enabled' => $enabled, + ]; + + if ($enabled) { + $blocking[$slug] = $entry; + } else { + $warnings[$slug] = $entry; + } + } + + $themeDirs = glob($this->rootPath . '/user/themes/*', GLOB_ONLYDIR) ?: []; + foreach ($themeDirs as $dir) { + $slug = basename($dir); + $compat = $this->readBlueprintCompatibility($dir); + + if (in_array($targetMajorMinor, $compat['grav'], true)) { + continue; + } + + $version = $this->readBlueprintVersion($dir) ?? 'unknown'; + $active = $this->isThemeEnabled($slug); + + $entry = [ + 'type' => 'theme', + 'version' => $version, + 'compatibility' => $compat, + 'enabled' => $active, + ]; + + if ($active) { + $blocking[$slug] = $entry; + } else { + $warnings[$slug] = $entry; + } + } + + return [ + 'blocking' => $blocking, + 'warnings' => $warnings, + 'target' => $targetMajorMinor, + ]; + } + + /** + * Read the compatible Grav versions from a package's blueprints.yaml. + * + * @param string $dir Package directory + * @return array{grav: string[], api: string[]} + */ + protected function readBlueprintCompatibility(string $dir): array + { + $file = $dir . '/blueprints.yaml'; + if (!is_file($file)) { + return ['grav' => [], 'api' => []]; + } + + try { + $contents = @file_get_contents($file); + if ($contents === false) { + return ['grav' => [], 'api' => []]; + } + $data = Yaml::parse($contents); + if (!is_array($data)) { + return ['grav' => [], 'api' => []]; + } + + if (isset($data['compatibility']['grav']) && is_array($data['compatibility']['grav'])) { + return [ + 'grav' => array_map('strval', $data['compatibility']['grav']), + 'api' => isset($data['compatibility']['api']) && is_array($data['compatibility']['api']) + ? array_map('strval', $data['compatibility']['api']) + : [], + ]; + } + + return $this->inferCompatibleVersions($data['dependencies'] ?? []); + } catch (\Throwable $e) { + return ['grav' => [], 'api' => []]; + } + } + + /** + * Infer compatible Grav versions from a package's dependency list. + * + * @param array $dependencies + * @return array{grav: string[], api: string[]} + */ + protected function inferCompatibleVersions(array $dependencies): array + { + foreach ($dependencies as $dep) { + if (!is_array($dep) || ($dep['name'] ?? '') !== 'grav') { + continue; + } + $version = $dep['version'] ?? ''; + + if (!preg_match('/(\d+\.\d+(?:\.\d+)?)/', $version, $m)) { + continue; + } + + if (version_compare($m[1], '1.8', '>=')) { + return ['grav' => ['1.8'], 'api' => []]; + } + + return ['grav' => ['1.7'], 'api' => []]; + } + + return ['grav' => ['1.7'], 'api' => []]; + } + + /** + * Read the version string from a package's blueprints.yaml. + * + * @param string $dir Package directory + * @return string|null + */ + protected function readBlueprintVersion(string $dir): ?string + { + $file = $dir . '/blueprints.yaml'; + if (!is_file($file)) { + return null; + } + + try { + $contents = @file_get_contents($file); + if ($contents === false) { + return null; + } + $data = Yaml::parse($contents); + if (is_array($data) && isset($data['version'])) { + return (string)$data['version']; + } + } catch (\Throwable $e) { + // ignore + } + + return null; + } + /** * Ensure directories flagged for ignoring get hydrated from the current installation. * diff --git a/system/src/Grav/Console/Gpm/IndexCommand.php b/system/src/Grav/Console/Gpm/IndexCommand.php index d655ac5f8..5669271d6 100644 --- a/system/src/Grav/Console/Gpm/IndexCommand.php +++ b/system/src/Grav/Console/Gpm/IndexCommand.php @@ -141,7 +141,7 @@ class IndexCommand extends GpmCommand if (!empty($packages)) { $io->section('Packages table'); $table = new Table($io); - $table->setHeaders(['Count', 'Name', 'Slug', 'Version', 'Installed', 'Enabled']); + $table->setHeaders(['Count', 'Name', 'Slug', 'Version', 'Compat', 'Installed', 'Enabled']); $index = 0; foreach ($packages as $slug => $package) { @@ -150,6 +150,7 @@ class IndexCommand extends GpmCommand 'Name' => '' . Utils::truncate($package->name, 20, false, ' ', '...') . ' ', 'Slug' => $slug, 'Version'=> $this->version($package), + 'Compat' => $this->compatBadges($package), 'Installed' => $this->installed($package), 'Enabled' => $this->enabled($package), ]; @@ -231,6 +232,38 @@ class IndexCommand extends GpmCommand return $result; } + /** + * @param Package $package + * @return string + */ + private function compatBadges(Package $package): string + { + $type = ucfirst(preg_replace('/s$/', '', $package->package_type)); + $method = 'is' . $type . 'Installed'; + $installed = $this->gpm->{$method}($package->slug); + + if ($installed) { + $local = $this->gpm->{'getInstalled' . $type}($package->slug); + $compat = $local->compatibility ?? null; + } else { + $compat = $package->compatibility ?? null; + } + + if (!is_array($compat) || empty($compat['grav'])) { + return '1.7'; + } + + $badges = []; + if (in_array('1.7', $compat['grav'], true)) { + $badges[] = '1.7'; + } + if (in_array('1.8', $compat['grav'], true)) { + $badges[] = '1.8'; + } + + return implode(' ', $badges); + } + /** * @param Packages $data * @return Packages diff --git a/system/src/Grav/Console/Gpm/InfoCommand.php b/system/src/Grav/Console/Gpm/InfoCommand.php index bf440eaec..56d2a017e 100644 --- a/system/src/Grav/Console/Gpm/InfoCommand.php +++ b/system/src/Grav/Console/Gpm/InfoCommand.php @@ -131,9 +131,36 @@ class InfoCommand extends GpmCommand } } + // Display compatibility badges $type = rtrim((string) $foundPackage->package_type, 's'); - $updatable = $this->gpm->{'is' . $type . 'Updatable'}($foundPackage->slug); $installed = $this->gpm->{'is' . $type . 'Installed'}($foundPackage->slug); + if ($installed) { + $local = $this->gpm->{'getInstalled' . $type}($foundPackage->slug); + $compat = $local->compatibility ?? null; + } else { + $compat = $foundPackage->compatibility ?? null; + } + + $compatStr = ''; + if (is_array($compat) && !empty($compat['grav'])) { + $badges = []; + if (in_array('1.7', $compat['grav'], true)) { + $badges[] = '1.7'; + } + if (in_array('1.8', $compat['grav'], true)) { + $badges[] = '1.8'; + } + $compatStr = implode(' ', $badges); + } else { + $compatStr = '1.7'; + } + $io->writeln('' . str_pad('Compatibility', 12) . ': ' . $compatStr); + + if (is_array($compat) && !empty($compat['api'])) { + $io->writeln('' . str_pad('API', 12) . ': ' . implode(', ', $compat['api'])); + } + + $updatable = $this->gpm->{'is' . $type . 'Updatable'}($foundPackage->slug); // display current version if installed and different if ($installed && $updatable) { diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php index e2c5030b0..b29364022 100644 --- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php +++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php @@ -396,6 +396,9 @@ class SelfupgradeCommand extends GpmCommand $conflicts = $preflight['psr_log_conflicts'] ?? []; $monologConflicts = $preflight['monolog_conflicts'] ?? []; $warnings = $preflight['warnings'] ?? []; + $incompatible = $preflight['incompatible_packages'] ?? []; + $incompatibleBlocking = $incompatible['blocking'] ?? []; + $incompatibleTarget = $incompatible['target'] ?? ''; $isMajorMinorUpgrade = $preflight['is_major_minor_upgrade'] ?? null; if ($isMajorMinorUpgrade === null && $this->upgrader) { $local = $this->upgrader->getLocalVersion(); @@ -420,17 +423,22 @@ class SelfupgradeCommand extends GpmCommand } } - if ($blocking && empty($pending)) { + // Filter out the incompatible-packages blocker (handled separately below) + $filteredBlocking = array_filter($blocking, static function ($reason) { + return !stripos($reason, 'not been marked as compatible'); + }); + + if ($filteredBlocking && empty($pending)) { $io->newLine(); $io->writeln('Upgrade blocked:'); - foreach ($blocking as $reason) { + foreach ($filteredBlocking as $reason) { $io->writeln(' - ' . $reason); } return false; } - if (empty($pending) && empty($conflicts) && empty($monologConflicts)) { + if (empty($pending) && empty($conflicts) && empty($monologConflicts) && empty($incompatibleBlocking)) { return true; } @@ -467,6 +475,58 @@ class SelfupgradeCommand extends GpmCommand $io->writeln(' › Proceeding despite pending plugin/theme updates.'); } + // Handle incompatible packages + if ($incompatibleBlocking) { + $io->newLine(); + $io->writeln('The following enabled plugins/themes are not marked as compatible with Grav ' . $incompatibleTarget . ':'); + foreach ($incompatibleBlocking as $slug => $info) { + $type = $info['type'] ?? 'plugin'; + $ver = $info['version'] ?? 'unknown'; + $gravCompat = implode(', ', $info['compatibility']['grav'] ?? ['?']); + $io->writeln(sprintf(' - %s (%s v%s) — compatible with: %s', $slug, $type, $ver, $gravCompat)); + } + $io->writeln(' › Plugins/themes must be marked as compatible with Grav ' . $incompatibleTarget . ' before upgrading.'); + $io->writeln(' Either update the plugins, or disable them to proceed.'); + + $choice = $this->all_yes ? 'abort' : $io->choice( + 'How would you like to proceed?', + ['disable', 'continue', 'abort'], + 'abort' + ); + + if ($choice === 'abort') { + $io->writeln('Aborting self-upgrade. Update or disable incompatible plugins first.'); + + return false; + } + + /** @var \Grav\Common\Recovery\RecoveryManager $recovery */ + $recovery = Grav::instance()['recovery']; + + if ($choice === 'disable') { + foreach (array_keys($incompatibleBlocking) as $slug) { + $recovery->disablePlugin($slug, ['message' => 'Disabled before upgrade — not marked as compatible with Grav ' . $incompatibleTarget]); + $io->writeln(sprintf(' - Disabled %s.', $slug)); + } + $io->writeln('Continuing with incompatible plugins disabled.'); + } else { + Install::allowIncompatibleOverride(true); + $io->writeln(' › Proceeding despite incompatible plugins/themes.'); + } + } + + // Show incompatible warnings (disabled packages) — informational only + $incompatibleWarnings = $incompatible['warnings'] ?? []; + if ($incompatibleWarnings) { + $io->newLine(); + $io->writeln('Disabled plugins/themes not yet compatible with Grav ' . $incompatibleTarget . ' (will not block upgrade):'); + foreach ($incompatibleWarnings as $slug => $info) { + $type = $info['type'] ?? 'plugin'; + $ver = $info['version'] ?? 'unknown'; + $io->writeln(sprintf(' - %s (%s v%s)', $slug, $type, $ver)); + } + } + $handled = $this->handleConflicts( $conflicts, static function (SymfonyStyle $io, array $conflicts): void { diff --git a/system/src/Grav/Console/Gpm/UpdateCommand.php b/system/src/Grav/Console/Gpm/UpdateCommand.php index d8e9fbfe5..15ea1c196 100644 --- a/system/src/Grav/Console/Gpm/UpdateCommand.php +++ b/system/src/Grav/Console/Gpm/UpdateCommand.php @@ -211,13 +211,29 @@ class UpdateCommand extends GpmCommand $package->available = $package->version; } + // Build compatibility badges + $compat = $package->compatibility ?? null; + $compatStr = ''; + if (is_array($compat) && !empty($compat['grav'])) { + $badges = []; + if (in_array('1.7', $compat['grav'], true)) { + $badges[] = '1.7'; + } + if (in_array('1.8', $compat['grav'], true)) { + $badges[] = '1.8'; + } + $compatStr = ' ' . implode(' ', $badges); + } + $io->writeln( // index str_pad((string)$index++, 2, '0', STR_PAD_LEFT) . '. ' . // name '' . str_pad((string) $package->name, 15) . ' ' . // version - "[v{$package->version} -> v{$package->available}]" + "[v{$package->version} -> v{$package->available}]" . + // compat badges + $compatStr ); $slugs[] = $slug; } diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php index afed9c968..858c6153d 100644 --- a/system/src/Grav/Installer/Install.php +++ b/system/src/Grav/Installer/Install.php @@ -165,6 +165,8 @@ final class Install private static $forceSafeUpgrade = null; /** @var bool */ private static $allowPendingOverride = false; + /** @var bool */ + private static $allowIncompatibleOverride = false; /** @var int|null */ private static $snapshotLimit = null; /** @var callable|null */ @@ -208,6 +210,15 @@ final class Install } } + public static function allowIncompatibleOverride(?bool $state = true): void + { + if ($state === null) { + self::$allowIncompatibleOverride = false; + } else { + self::$allowIncompatibleOverride = (bool)$state; + } + } + private function ensureLocation(): void { if (null === $this->location) { @@ -954,6 +965,7 @@ ERR; 'psr_log_conflicts' => [], 'monolog_conflicts' => [], 'plugins_pending' => [], + 'incompatible_packages' => [], 'is_major_minor_upgrade' => $this->isMajorMinorUpgrade($targetVersion), 'blocking' => [], ]; @@ -970,6 +982,15 @@ ERR; } } + if ($report['is_major_minor_upgrade']) { + $report['incompatible_packages'] = $this->detectIncompatiblePackages($targetVersion); + + if (!empty($report['incompatible_packages']['blocking']) && !self::$allowIncompatibleOverride) { + $target = $report['incompatible_packages']['target']; + $report['blocking'][] = 'Some enabled plugins/themes have not been marked as compatible with Grav ' . $target . '. Disable them before continuing.'; + } + } + $elapsed = microtime(true) - $start; $this->relayProgress('initializing', sprintf('Preflight checks complete in %.3fs.', $elapsed), null); @@ -1295,6 +1316,148 @@ ERR; return true; } + /** + * Detect installed plugins/themes not compatible with the target Grav version. + * + * @param string $targetVersion Target Grav version (e.g. '1.8.0') + * @return array{blocking: array, warnings: array, target: string} + */ + private function detectIncompatiblePackages(string $targetVersion): array + { + $parts = explode('.', $targetVersion); + $targetMajorMinor = ($parts[0] ?? '1') . '.' . ($parts[1] ?? '7'); + + $blocking = []; + $warnings = []; + $scanRoot = GRAV_ROOT ?: getcwd(); + + // Scan plugins + $pluginDirs = glob($scanRoot . '/user/plugins/*', GLOB_ONLYDIR) ?: []; + foreach ($pluginDirs as $dir) { + $slug = basename($dir); + $compat = $this->readBlueprintCompatibility($dir); + + if (in_array($targetMajorMinor, $compat['grav'], true)) { + continue; + } + + $version = $this->readBlueprintVersion($dir) ?? 'unknown'; + $enabled = $this->isPluginEnabled($slug); + + $entry = [ + 'type' => 'plugin', + 'version' => $version, + 'compatibility' => $compat, + 'enabled' => $enabled, + ]; + + if ($enabled) { + $blocking[$slug] = $entry; + } else { + $warnings[$slug] = $entry; + } + } + + // Scan themes + $themeDirs = glob($scanRoot . '/user/themes/*', GLOB_ONLYDIR) ?: []; + foreach ($themeDirs as $dir) { + $slug = basename($dir); + $compat = $this->readBlueprintCompatibility($dir); + + if (in_array($targetMajorMinor, $compat['grav'], true)) { + continue; + } + + $version = $this->readBlueprintVersion($dir) ?? 'unknown'; + $active = $this->isThemeEnabled($slug); + + $entry = [ + 'type' => 'theme', + 'version' => $version, + 'compatibility' => $compat, + 'enabled' => $active, + ]; + + if ($active) { + $blocking[$slug] = $entry; + } else { + $warnings[$slug] = $entry; + } + } + + return [ + 'blocking' => $blocking, + 'warnings' => $warnings, + 'target' => $targetMajorMinor, + ]; + } + + /** + * Read the compatible Grav versions from a package's blueprints.yaml. + * + * @param string $dir Package directory + * @return array{grav: string[], api: string[]} + */ + private function readBlueprintCompatibility(string $dir): array + { + $file = $dir . '/blueprints.yaml'; + if (!is_file($file)) { + return ['grav' => [], 'api' => []]; + } + + try { + $contents = @file_get_contents($file); + if ($contents === false) { + return ['grav' => [], 'api' => []]; + } + $data = Yaml::parse($contents); + if (!is_array($data)) { + return ['grav' => [], 'api' => []]; + } + + if (isset($data['compatibility']['grav']) && is_array($data['compatibility']['grav'])) { + return [ + 'grav' => array_map('strval', $data['compatibility']['grav']), + 'api' => isset($data['compatibility']['api']) && is_array($data['compatibility']['api']) + ? array_map('strval', $data['compatibility']['api']) + : [], + ]; + } + + return $this->inferCompatibleVersions($data['dependencies'] ?? []); + } catch (Throwable $e) { + return ['grav' => [], 'api' => []]; + } + } + + /** + * Infer compatible Grav versions from a package's dependency list. + * + * @param array $dependencies + * @return array{grav: string[], api: string[]} + */ + private function inferCompatibleVersions(array $dependencies): array + { + foreach ($dependencies as $dep) { + if (!is_array($dep) || ($dep['name'] ?? '') !== 'grav') { + continue; + } + $version = $dep['version'] ?? ''; + + if (!preg_match('/(\d+\.\d+(?:\.\d+)?)/', $version, $m)) { + continue; + } + + if (version_compare($m[1], '1.8', '>=')) { + return ['grav' => ['1.8'], 'api' => []]; + } + + return ['grav' => ['1.7'], 'api' => []]; + } + + return ['grav' => ['1.7'], 'api' => []]; + } + private function isGpmPackagePublished($package): bool { if (is_object($package) && method_exists($package, 'getData')) { diff --git a/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php index bf6260add..5c4e6b2eb 100644 --- a/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php +++ b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php @@ -124,6 +124,111 @@ class SelfupgradeCommandTest extends \PHPUnit\Framework\TestCase self::assertStringContainsString('Proceeding with potential psr/log incompatibilities still active.', $output); } + public function testHandlePreflightReportBlocksOnIncompatiblePackages(): void + { + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command); + $this->setAllYes($command, true); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => [], + 'monolog_conflicts' => [], + 'warnings' => [], + 'is_major_minor_upgrade' => true, + 'blocking' => [], + 'incompatible_packages' => [ + 'blocking' => [ + 'old-plugin' => [ + 'type' => 'plugin', + 'version' => '1.0.0', + 'compatibility' => ['grav' => ['1.7'], 'api' => []], + 'enabled' => true, + ], + ], + 'warnings' => [], + 'target' => '1.8', + ], + ]); + + self::assertFalse($result); + $output = implode("\n", $style->messages); + self::assertStringContainsString('old-plugin', $output); + self::assertStringContainsString('not marked as compatible', $output); + } + + public function testHandlePreflightReportDisablesIncompatibleWhenRequested(): void + { + $gravFactory = Fixtures::get('grav'); + $grav = $gravFactory(); + $stub = new class { + public $disabled = []; + public function disablePlugin(string $slug, array $context = []): void + { + $this->disabled[] = $slug; + } + }; + $grav['recovery'] = $stub; + + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command, ['disable']); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => [], + 'monolog_conflicts' => [], + 'warnings' => [], + 'is_major_minor_upgrade' => true, + 'blocking' => [], + 'incompatible_packages' => [ + 'blocking' => [ + 'old-plugin' => [ + 'type' => 'plugin', + 'version' => '1.0.0', + 'compatibility' => ['grav' => ['1.7'], 'api' => []], + 'enabled' => true, + ], + ], + 'warnings' => [], + 'target' => '1.8', + ], + ]); + + self::assertTrue($result); + self::assertSame(['old-plugin'], $stub->disabled); + } + + public function testHandlePreflightReportContinuesWithIncompatibleOverride(): void + { + $command = new TestSelfupgradeCommand(); + [$style] = $this->injectIo($command, ['continue']); + + $result = $command->runHandle([ + 'plugins_pending' => [], + 'psr_log_conflicts' => [], + 'monolog_conflicts' => [], + 'warnings' => [], + 'is_major_minor_upgrade' => true, + 'blocking' => [], + 'incompatible_packages' => [ + 'blocking' => [ + 'old-plugin' => [ + 'type' => 'plugin', + 'version' => '1.0.0', + 'compatibility' => ['grav' => ['1.7'], 'api' => []], + 'enabled' => true, + ], + ], + 'warnings' => [], + 'target' => '1.8', + ], + ]); + + self::assertTrue($result); + $output = implode("\n", $style->messages); + self::assertStringContainsString('Proceeding despite incompatible', $output); + } + /** * @param TestSelfupgradeCommand $command * @param array $responses diff --git a/tests/unit/Grav/Installer/InstallCompatibilityTest.php b/tests/unit/Grav/Installer/InstallCompatibilityTest.php new file mode 100644 index 000000000..d4591dc51 --- /dev/null +++ b/tests/unit/Grav/Installer/InstallCompatibilityTest.php @@ -0,0 +1,191 @@ +tmpDir = sys_get_temp_dir() . '/grav-compat-test-' . uniqid(); + mkdir($this->tmpDir . '/user/plugins', 0755, true); + mkdir($this->tmpDir . '/user/themes', 0755, true); + mkdir($this->tmpDir . '/user/config/plugins', 0755, true); + } + + protected function tearDown(): void + { + if (is_dir($this->tmpDir)) { + Folder::delete($this->tmpDir); + } + } + + public function testReadBlueprintCompatibilityExplicit(): void + { + $dir = $this->createPlugin('test-plugin', [ + 'name' => 'Test Plugin', + 'version' => '1.0.0', + 'compatibility' => [ + 'grav' => ['1.7', '1.8'], + 'api' => ['1.0'], + ], + ]); + + $result = $this->callMethod('readBlueprintCompatibility', [$dir]); + self::assertSame(['1.7', '1.8'], $result['grav']); + self::assertSame(['1.0'], $result['api']); + } + + public function testReadBlueprintCompatibilityInferredFrom18Dep(): void + { + $dir = $this->createPlugin('test-18', [ + 'name' => 'Test 1.8', + 'version' => '2.0.0', + 'dependencies' => [ + ['name' => 'grav', 'version' => '>=1.8.0'], + ], + ]); + + $result = $this->callMethod('readBlueprintCompatibility', [$dir]); + self::assertSame(['1.8'], $result['grav']); + self::assertSame([], $result['api']); + } + + public function testReadBlueprintCompatibilityInferredFrom17Dep(): void + { + $dir = $this->createPlugin('test-17', [ + 'name' => 'Test 1.7', + 'version' => '1.0.0', + 'dependencies' => [ + ['name' => 'grav', 'version' => '>=1.7.0'], + ], + ]); + + $result = $this->callMethod('readBlueprintCompatibility', [$dir]); + self::assertSame(['1.7'], $result['grav']); + } + + public function testReadBlueprintCompatibilityNoDependency(): void + { + $dir = $this->createPlugin('test-none', [ + 'name' => 'Test None', + 'version' => '1.0.0', + ]); + + $result = $this->callMethod('readBlueprintCompatibility', [$dir]); + self::assertSame(['1.7'], $result['grav']); + } + + public function testReadBlueprintCompatibilityMissingFile(): void + { + $dir = $this->tmpDir . '/user/plugins/no-blueprint'; + mkdir($dir, 0755, true); + + $result = $this->callMethod('readBlueprintCompatibility', [$dir]); + self::assertSame([], $result['grav']); + } + + public function testInferCompatibleVersionsVariousConstraints(): void + { + $install = Install::instance(); + + // >=1.8.0 + $result = $this->callMethod('inferCompatibleVersions', [[['name' => 'grav', 'version' => '>=1.8.0']]]); + self::assertSame(['1.8'], $result['grav']); + + // ~1.8 + $result = $this->callMethod('inferCompatibleVersions', [[['name' => 'grav', 'version' => '~1.8']]]); + self::assertSame(['1.8'], $result['grav']); + + // >=1.7.0,<2.0 + $result = $this->callMethod('inferCompatibleVersions', [[['name' => 'grav', 'version' => '>=1.7.0,<2.0']]]); + self::assertSame(['1.7'], $result['grav']); + + // No grav dep + $result = $this->callMethod('inferCompatibleVersions', [[['name' => 'form', 'version' => '>=6.0']]]); + self::assertSame(['1.7'], $result['grav']); + + // Empty + $result = $this->callMethod('inferCompatibleVersions', [[]]); + self::assertSame(['1.7'], $result['grav']); + } + + public function testDetectIncompatiblePackagesBlocksEnabled(): void + { + $this->createPlugin('incompatible-enabled', [ + 'name' => 'Incompatible Enabled', + 'version' => '1.0.0', + 'compatibility' => ['grav' => ['1.7']], + ]); + // Plugin enabled by default (no config file = enabled) + + $result = $this->callMethod('detectIncompatiblePackages', ['1.8.0']); + self::assertArrayHasKey('incompatible-enabled', $result['blocking']); + self::assertSame('1.8', $result['target']); + } + + public function testDetectIncompatiblePackagesWarnsDisabled(): void + { + $this->createPlugin('incompatible-disabled', [ + 'name' => 'Incompatible Disabled', + 'version' => '1.0.0', + 'compatibility' => ['grav' => ['1.7']], + ]); + // Disable it + file_put_contents( + $this->tmpDir . '/user/config/plugins/incompatible-disabled.yaml', + "enabled: false\n" + ); + + $result = $this->callMethod('detectIncompatiblePackages', ['1.8.0']); + self::assertArrayHasKey('incompatible-disabled', $result['warnings']); + self::assertEmpty($result['blocking']); + } + + public function testDetectIncompatiblePackagesPassesCompatible(): void + { + $this->createPlugin('compatible-plugin', [ + 'name' => 'Compatible Plugin', + 'version' => '2.0.0', + 'compatibility' => ['grav' => ['1.7', '1.8']], + ]); + + $result = $this->callMethod('detectIncompatiblePackages', ['1.8.0']); + self::assertArrayNotHasKey('compatible-plugin', $result['blocking']); + self::assertArrayNotHasKey('compatible-plugin', $result['warnings']); + } + + /** + * Create a plugin directory with a blueprints.yaml file. + */ + private function createPlugin(string $slug, array $blueprint): string + { + $dir = $this->tmpDir . '/user/plugins/' . $slug; + mkdir($dir, 0755, true); + + $yaml = \Symfony\Component\Yaml\Yaml::dump($blueprint, 4); + file_put_contents($dir . '/blueprints.yaml', $yaml); + + return $dir; + } + + /** + * Call a private/protected method on the Install singleton via reflection. + * We temporarily override GRAV_ROOT for the detection methods. + */ + private function callMethod(string $method, array $args): mixed + { + $install = Install::instance(); + $ref = new \ReflectionMethod($install, $method); + $ref->setAccessible(true); + + // For methods that use GRAV_ROOT, we need to define it if not set + if (!defined('GRAV_ROOT')) { + define('GRAV_ROOT', $this->tmpDir); + } + + return $ref->invoke($install, ...$args); + } +}