Add compatibility: blueprint support for major version upgrade gating

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) <noreply@anthropic.com>
This commit is contained in:
Andy Miller
2026-04-02 10:22:56 -06:00
parent b7ae93bfc1
commit 2c517b012e
9 changed files with 830 additions and 6 deletions

View File

@@ -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' => []];
}
}

View File

@@ -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.
*

View File

@@ -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' => '<cyan>' . Utils::truncate($package->name, 20, false, ' ', '...') . '</cyan> ',
'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 '<blue>1.7</blue>';
}
$badges = [];
if (in_array('1.7', $compat['grav'], true)) {
$badges[] = '<blue>1.7</blue>';
}
if (in_array('1.8', $compat['grav'], true)) {
$badges[] = '<green>1.8</green>';
}
return implode(' ', $badges);
}
/**
* @param Packages $data
* @return Packages

View File

@@ -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[] = '<blue>1.7</blue>';
}
if (in_array('1.8', $compat['grav'], true)) {
$badges[] = '<green>1.8</green>';
}
$compatStr = implode(' ', $badges);
} else {
$compatStr = '<blue>1.7</blue>';
}
$io->writeln('<green>' . str_pad('Compatibility', 12) . ':</green> ' . $compatStr);
if (is_array($compat) && !empty($compat['api'])) {
$io->writeln('<green>' . str_pad('API', 12) . ':</green> ' . implode(', ', $compat['api']));
}
$updatable = $this->gpm->{'is' . $type . 'Updatable'}($foundPackage->slug);
// display current version if installed and different
if ($installed && $updatable) {

View File

@@ -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('<red>Upgrade blocked:</red>');
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('<yellow>The following enabled plugins/themes are not marked as compatible with Grav ' . $incompatibleTarget . ':</yellow>');
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('<cyan>Disabled plugins/themes not yet compatible with Grav ' . $incompatibleTarget . ' (will not block upgrade):</cyan>');
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 {

View File

@@ -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[] = '<blue>1.7</blue>';
}
if (in_array('1.8', $compat['grav'], true)) {
$badges[] = '<green>1.8</green>';
}
$compatStr = ' ' . implode(' ', $badges);
}
$io->writeln(
// index
str_pad((string)$index++, 2, '0', STR_PAD_LEFT) . '. ' .
// name
'<cyan>' . str_pad((string) $package->name, 15) . '</cyan> ' .
// version
"[v<magenta>{$package->version}</magenta> -> v<green>{$package->available}</green>]"
"[v<magenta>{$package->version}</magenta> -> v<green>{$package->available}</green>]" .
// compat badges
$compatStr
);
$slugs[] = $slug;
}

View File

@@ -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')) {

View File

@@ -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<int, mixed> $responses

View File

@@ -0,0 +1,191 @@
<?php
use Grav\Common\Filesystem\Folder;
use Grav\Installer\Install;
class InstallCompatibilityTest extends \PHPUnit\Framework\TestCase
{
private string $tmpDir;
protected function setUp(): void
{
$this->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);
}
}