From 7d6a97a54e83779b06d4f99b4fdbb8fe24bdbf39 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 6 Nov 2026 12:51:03 +0100 Subject: [PATCH] refactor(compat): Freigabe-Bausteine generisch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReleaseStore, CheckReportStore, CheckReport, CheckStep und CompatibilityCheck liegen im Kern und arbeiten je Profil (Optionen lit_mcp__releases|_check_report; Divi behält Namen und Schlüssel divi_version). Divi hängt Katalog-, Format- und Schreibtest über Profile::extraSteps an. Ein roter Prüfbericht für den aktuellen Stand sperrt LockOnFailure-Profile bis zur nächsten grünen Prüfung; Sperrgründe nennen nur Schritt-Labels, Ausnahmen gehen ins Error-Log. Profile bekommt testedVersions, stateKey, extraSteps, manifestCheck und reportVersionKey und wird mit benannten Argumenten gebaut. Co-Authored-By: Claude Opus 5.5 --- .../2026-11-06-kompatibilitaetsschicht.md | 30 ++- .../Release => Compatibility}/CheckReport.php | 32 +-- src/Compatibility/CheckReportStore.php | 39 ++++ src/Compatibility/CheckStep.php | 68 ++++++ src/Compatibility/Compatibility.php | 95 +++++++-- src/Compatibility/CompatibilityCheck.php | 78 +++++++ src/Compatibility/Profile.php | 24 ++- src/Compatibility/ReleaseStore.php | 111 ++++++++++ src/Integrations/Divi/Admin/DiviTab.php | 23 +- .../Divi/Cli/DiviCheckCommand.php | 16 +- src/Integrations/Divi/DiviIntegration.php | 14 +- .../Divi/Release/CheckReportStore.php | 23 -- src/Integrations/Divi/Release/CheckStep.php | 62 ------ .../Divi/Release/CompatibilityCheck.php | 93 --------- .../Divi/Release/DiviCheckRoute.php | 5 +- .../Divi/Release/DiviCheckSteps.php | 61 ++++++ src/Integrations/Divi/Release/DiviProfile.php | 39 +++- .../Divi/Release/ReleaseResolver.php | 4 +- .../Divi/Release/ReleaseStore.php | 99 --------- src/Integrations/Divi/Release/WriteProbe.php | 5 +- src/Integrations/IntegrationRegistry.php | 5 +- src/Integrations/Pods/PodsCompatibility.php | 17 +- src/Plugin.php | 2 +- src/Uninstaller.php | 9 +- .../Unit/Abilities/CompatibilityGatesTest.php | 2 +- .../Unit/Compatibility/CapabilityGateTest.php | 18 +- .../Compatibility/CapabilityLockedTest.php | 2 +- tests/Unit/Compatibility/CheckReportTest.php | 197 ++++++++++++++++++ .../Compatibility/CompatibilityCheckTest.php | 163 +++++++++++++++ .../CompatibilityComponentTest.php | 30 +-- .../Unit/Compatibility/CompatibilityTest.php | 182 +++++++++++++--- tests/Unit/Compatibility/ProfileTest.php | 55 ++++- tests/Unit/Compatibility/ReleaseStoreTest.php | 89 ++++++++ .../Integrations/Divi/Admin/DiviTabTest.php | 11 +- .../Divi/Cli/DiviCheckCommandTest.php | 17 +- .../Divi/Release/CheckReportTest.php | 61 ------ .../Divi/Release/CompatibilityCheckTest.php | 92 -------- .../Divi/Release/DiviCheckRouteTest.php | 6 +- .../Divi/Release/DiviCheckStepsTest.php | 124 +++++++++++ .../Divi/Release/DiviProfileTest.php | 21 +- .../Divi/Release/ReleaseResolverTest.php | 2 +- .../Divi/Release/ReleaseStoreTest.php | 54 ----- .../Divi/Release/WriteProbeTest.php | 13 +- .../Integrations/IntegrationRegistryTest.php | 4 +- .../Pods/PodsCompatibilityTest.php | 1 + tests/Unit/PluginTest.php | 8 +- tests/Unit/UninstallTest.php | 5 +- tests/Unit/UninstallerTest.php | 5 +- 48 files changed, 1469 insertions(+), 647 deletions(-) rename src/{Integrations/Divi/Release => Compatibility}/CheckReport.php (67%) create mode 100644 src/Compatibility/CheckReportStore.php create mode 100644 src/Compatibility/CheckStep.php create mode 100644 src/Compatibility/CompatibilityCheck.php create mode 100644 src/Compatibility/ReleaseStore.php delete mode 100644 src/Integrations/Divi/Release/CheckReportStore.php delete mode 100644 src/Integrations/Divi/Release/CheckStep.php delete mode 100644 src/Integrations/Divi/Release/CompatibilityCheck.php create mode 100644 src/Integrations/Divi/Release/DiviCheckSteps.php delete mode 100644 src/Integrations/Divi/Release/ReleaseStore.php create mode 100644 tests/Unit/Compatibility/CheckReportTest.php create mode 100644 tests/Unit/Compatibility/CompatibilityCheckTest.php create mode 100644 tests/Unit/Compatibility/ReleaseStoreTest.php delete mode 100644 tests/Unit/Integrations/Divi/Release/CheckReportTest.php delete mode 100644 tests/Unit/Integrations/Divi/Release/CompatibilityCheckTest.php create mode 100644 tests/Unit/Integrations/Divi/Release/DiviCheckStepsTest.php delete mode 100644 tests/Unit/Integrations/Divi/Release/ReleaseStoreTest.php diff --git a/docs/superpowers/plans/2026-11-06-kompatibilitaetsschicht.md b/docs/superpowers/plans/2026-11-06-kompatibilitaetsschicht.md index 367d970..fa46e30 100644 --- a/docs/superpowers/plans/2026-11-06-kompatibilitaetsschicht.md +++ b/docs/superpowers/plans/2026-11-06-kompatibilitaetsschicht.md @@ -46,10 +46,27 @@ mechanisch (Namespace + `use`), keine Verhaltensänderung. Keine Commits durch U | 1.12 | `PodsFeature` (`pods-schema-read`, `pods-field-read`, `pods-field-write`, `pods-field-permissions`), `PodsCompatibility::profile()` (LockOnFailure, PODS_VERSION, min 2.8, Calls mit Argumentzahlen gegen Pods 3.3.9.2), `PodsIntegration` ohne Abilities | `PodsCompatibilityTest`; Registry-Test: leere Integration nicht in `grouped()`/`labels()`/Instructions | keine leere Gruppe sichtbar | sonnet | | 1.13 | `CompatibilityComponent` (3 Zustände, Zeile je Fähigkeit); `EnvironmentProbe` ohne Pods (TEC bleibt); `Plugin`: Guards `[DiviFeatureGate, CapabilityGate]`, Komponenten per Closure aus der Registry; 0.3-Umzug (`unknownIds` entfällt, Test zieht in `CapabilityGateTest`); ID-Eindeutigkeit | `EnvironmentProbeTest`; Zwei-Gate-Test über `AbilityRegistrar::argsFor`; `PluginTest` | Reihenfolge Divi, Pods, TEC | sonnet | | 1.14 | `PodsFieldSource` + Schema-Wahl über `usable(PodsFeature::…)`; `isAvailable()` entfällt; Feldrechte über `usable(FieldPermissions)`, sonst fail-closed; 0.2-Bedingung = `active && ! usable(SchemaRead)` | `PodsFieldSourceTest` je Fähigkeit; `PluginTest` | alle Pods-Tests grün | sonnet | -| 1.15 | `CompatibilityTab` + `CompatibilitySection` (Filter `lit_mcp_compat_sections`, lazy); Zustand (Wort + Icon) und Probleme je Fähigkeit für Nicht-`ReleasePerVersion`-Profile | `CompatibilityTabTest`: drei Zustände, Sektion eingebettet, escaped | `CoreIndependenceTest` grün | sonnet | -| 1.16 | `DiviTab implements CompatibilitySection` (`title()` „Divi“); Slug/Redirects/`requested()`/Notice auf `compatibility`; `DiviIntegration` hängt über `lit_mcp_compat_sections` ein | `DiviTabTest`, `DiviIntegrationTest` | Release/Withdraw/Orphans/Render unverändert | sonnet | -| 1.17 | Links/Texte: `DiviCheckRoute`, `ReleaseStatus:24`, `docs/guides/overview.md:96`, `divi-release-smoke.sh`, Docblock `DiviCheckCommand` | `DiviCheckRouteTest` `tab=compatibility`; `ReleaseStatusTest` „→ Compatibility“ | DDEV-Smoke grün | haiku | -| 1.18 | Reiter in `Plugin::boot` registrieren (nach `LogTab`); Pods-Smoke erweitern | `PluginTest` Tab-Liste | DDEV: `compatibility ok`, Pods ohne Probleme; hohe minVersion → Pods-Felder schreibgeschützt | sonnet | +**Erledigt 1.1–1.14:** 9beb55e, b511ef6, c851316. + +**Neu-Zuschnitt 1.15–1.25 (Gate 1 vom 2026-11-06):** Prüfen und Freigeben sind für alle Profile im Reiter „Compatibility“ +gleich; die Wirkung regelt das Tier (ReleasePerVersion = Pflicht, LockOnFailure = dokumentiert, Nutzung automatisch solange grün, +ReportOnly = nur Status). Optionen `lit_mcp__releases|_check_report|_check_lock` – für `divi` exakt die heutigen Namen. +**Divi behält auch das Datenformat:** Store und Bericht bekommen den Versionsschlüssel als Parameter (Divi `divi_version`, neue +Profile `version`) – keine Migration, Rollback-sicher. Profil-ID aus POST nur nach Allowlist (`Compatibility::profile()`) in +Optionsnamen. + +| 1.15 | `Profile`: `testedVersions`, `stateKey`, `extraSteps` (Closure(bool $manifestClean): list), `manifestCheck`; `DiviProfile` + `active`, `testedVersions=DiviReleases::TESTED`, `stateKey=ReleaseResolver::stateKey`; Pods `testedVersions=['3.3.9.2']` | `ProfileTest`, `DiviProfileTest`, `PodsCompatibilityTest` | Gates grün | sonnet (mit 1.16–1.18) | +| 1.16 | `CheckStep`, `CheckReport`, `CheckReportStore(profileId, versionKey)` nach `src/Compatibility`; Label mitgespeichert; Divi-Schrittkonstanten in `DiviCheckSteps` | `CheckReportTest` (Divi-Rundlauf unverändert, Option je ID) | Divi-Suite grün | sonnet | +| 1.17 | `ReleaseStore(profileId, versionKey)` nach `src/Compatibility` | `ReleaseStoreTest` (Divi-Format unverändert, Option je ID) | `ReleaseResolverTest` grün | sonnet | +| 1.18 | `CompatibilityCheck` generisch (Manifest, Fingerabdrücke nur mit Mirrors, dann `extraSteps`); `Compatibility::check(Profile)`; Divi liefert Katalog/Format/Schreibtest als `extraSteps` | `CompatibilityCheckTest`, `DiviCheckStepsTest` | `DiviCheckCommandTest` grün, Divi-Schrittfolge gleich | sonnet | +| 1.18a | Review-Befunde (Architektur-Review 2026-11-06): roter gespeicherter Bericht für den aktuellen Stand sperrt LockOnFailure-Fähigkeiten bis zur nächsten grünen Prüfung; gefangene Ausnahmen in `Compatibility` per `Log::failure` protokollieren; `Profile` nur mit benannten Argumenten bauen | `CompatibilityTest` (roter/grüner/veralteter/kein Bericht) | Divi unverändert | sonnet | +| 1.19 | `Admin\CheckRoute` je Profil (`/-check`, Lock je Profil, Redirect `tab=compatibility`, `do_action('lit_mcp_compat_changed', $id)`; Divi invalidiert darauf `ReleaseGate`) | `CheckRouteTest`, `DiviIntegrationTest` | Lock-Tests portiert | sonnet | +| 1.20 | `Admin\CompatibilityTab` read-only (Sektion je Profil, Zustand Wort+Icon, Tier-Hinweis, Fähigkeiten, Versionen, Bericht, Hook `lit_mcp_compat_profile_extra`; inaktiv ohne `matrix()`) | `CompatibilityTabTest` inkl. Escaping | `CoreIndependenceTest` grün | sonnet | +| 1.21 | Formulare + admin-post `lit_mcp_compat_release|withdraw` (guard, Allowlist, Nonce je Profil) | `CompatibilityTabTest` (frischer Bericht, unbekanntes Profil, Nonce/403, Tier-Sichtbarkeit) | Release/Withdraw-Fälle aus `DiviTabTest` portiert | sonnet | +| 1.22 | Reiter in `Plugin::boot` (nach `LimitsTab`), Hilfe-Tab | `PluginTest`, `SettingsPageTest` | grün | sonnet | +| 1.23 | `DiviTab` → `DiviAdminExtras` (Notice, Orphans, Zusatzhinweise); `TABS_FILTER`-Eintrag entfällt | `DiviAdminExtrasTest`, `DiviIntegrationTest` | Boundary/Core grün | sonnet | +| 1.24 | Texte/CSS (`lit-mcp-divi-*` → `lit-mcp-compat-*`), `ReleaseStatus:24`, `docs/guides/overview.md`, `Uninstaller` (`lit_mcp_pods_*`), `divi-release-smoke.sh` | `ReleaseStatusTest`, `UninstallerTest` | DDEV Divi-Smoke grün | sonnet | +| 1.25 | Pods-Smoke: `/pods-check`, Freigabe schreibt `lit_mcp_pods_releases` ohne Sperrwirkung, hohe `minVersion` sperrt | Smoke rot ohne 1.19–1.21 | DDEV Pods- und Divi-Smoke grün | sonnet | ## Phase 2 – WP Super Cache 3.1.5 @@ -67,8 +84,9 @@ mechanisch (Namespace + `use`), keine Verhaltensänderung. Keine Commits durch U (1.2–1.4) · 6 `refactor(compat): CompatibilityMatrix generisch mit fromProblems` (1.5) · 7 `refactor(compat): Manifest und ManifestCheck generisch, DiviAreas, DiviManifestCheck` (1.6–1.7) · 8 `feat(compat): Tier, Profile, Compatibility, CapabilityLocked, CapabilityGate` (1.8–1.10) · 9 `feat(compat): Profile aus Integrationen, Divi- und Pods-Profil` (1.11–1.12) · -10 `feat(compat): Gates, Status und Pods über den Kern verdrahten` (1.13–1.14) · 11 `feat(admin): Reiter „Compatibility“ mit -Divi als Abschnitt` (1.15–1.18) · 12 `feat(changelog): Hook lit_mcp_changed` (2.1) · 13 `feat(cache): WP Super Cache über den +10 `feat(compat): Gates, Status und Pods über den Kern verdrahten` (1.13–1.14) · 11a `refactor(compat): Freigabe-Bausteine +generisch` (1.15–1.18) · 11b `feat(compat): Prüfroute je Profil` (1.19) · 11c `feat(admin): Reiter „Compatibility“ für alle +Profile` (1.20–1.22) · 11d `refactor(divi): DiviTab geht im Reiter auf` (1.23–1.24) · 11e `test(compat): Smokes` (1.25) · 12 `feat(changelog): Hook lit_mcp_changed` (2.1) · 13 `feat(cache): WP Super Cache über den Kern anbinden` (2.2–2.4) · 14 `test(cache): Integrations-Smoke` (2.5) ## Risiken diff --git a/src/Integrations/Divi/Release/CheckReport.php b/src/Compatibility/CheckReport.php similarity index 67% rename from src/Integrations/Divi/Release/CheckReport.php rename to src/Compatibility/CheckReport.php index 990c048..6639d32 100644 --- a/src/Integrations/Divi/Release/CheckReport.php +++ b/src/Compatibility/CheckReport.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace LIT\MCP\Integrations\Divi\Release; +namespace LIT\MCP\Compatibility; defined('ABSPATH') || exit; @@ -14,7 +14,7 @@ final class CheckReport public readonly string $id, public readonly string $createdAt, public readonly int $userId, - public readonly string $diviVersion, + public readonly string $version, public readonly string $pluginVersion, public readonly string $stateKey, public readonly array $steps @@ -35,11 +35,16 @@ final class CheckReport return true; } - /** Green and made for exactly this Divi version, plugin version and Divi files (ReleaseResolver::stateKey()). */ - public function freshFor(string $diviVersion, string $pluginVersion, string $stateKey): bool + /** Green and made for exactly this version, plugin version and state (Profile::$stateKey). */ + public function freshFor(string $version, string $pluginVersion, string $stateKey): bool { - return $this->green() - && $this->diviVersion === $diviVersion + return $this->green() && $this->currentFor($version, $pluginVersion, $stateKey); + } + + /** Made for exactly this state, green or not. */ + public function currentFor(string $version, string $pluginVersion, string $stateKey): bool + { + return $this->version === $version && $this->pluginVersion === $pluginVersion && $this->stateKey === $stateKey; } @@ -55,26 +60,29 @@ final class CheckReport return null; } - /** @return array */ - public function toArray(): array + /** + * @param string $versionKey Array key of the version; Divi keeps 'divi_version' so stored reports stay as they are. + * @return array + */ + public function toArray(string $versionKey = 'version'): array { return [ 'id' => $this->id, 'created_at' => $this->createdAt, 'user_id' => $this->userId, - 'divi_version' => $this->diviVersion, + $versionKey => $this->version, 'plugin_version' => $this->pluginVersion, 'state_key' => $this->stateKey, 'steps' => array_map(static fn (CheckStep $step): array => $step->toArray(), $this->steps), ]; } - public static function fromArray(mixed $data): ?self + public static function fromArray(mixed $data, string $versionKey = 'version'): ?self { if (! is_array($data)) { return null; } - foreach (['id', 'created_at', 'divi_version', 'plugin_version', 'state_key'] as $key) { + foreach (['id', 'created_at', $versionKey, 'plugin_version', 'state_key'] as $key) { if (! is_string($data[$key] ?? null)) { return null; } @@ -96,7 +104,7 @@ final class CheckReport $data['id'], $data['created_at'], $data['user_id'], - $data['divi_version'], + $data[$versionKey], $data['plugin_version'], $data['state_key'], $steps diff --git a/src/Compatibility/CheckReportStore.php b/src/Compatibility/CheckReportStore.php new file mode 100644 index 0000000..0687aa5 --- /dev/null +++ b/src/Compatibility/CheckReportStore.php @@ -0,0 +1,39 @@ +_check_report. Not final so the + * tab can be tested with a mock. + */ +class CheckReportStore +{ + /** @param string $versionKey Array key of the version in the stored report (Divi: 'divi_version'). */ + public function __construct(private string $profileId, private string $versionKey = 'version') + { + } + + public static function forProfile(Profile $profile): self + { + return new self($profile->id, $profile->reportVersionKey); + } + + public function latest(): ?CheckReport + { + return CheckReport::fromArray(get_option($this->option(), null), $this->versionKey); + } + + public function save(CheckReport $report): void + { + update_option($this->option(), $report->toArray($this->versionKey), false); + } + + private function option(): string + { + return 'lit_mcp_' . $this->profileId . '_check_report'; + } +} diff --git a/src/Compatibility/CheckStep.php b/src/Compatibility/CheckStep.php new file mode 100644 index 0000000..b5976c1 --- /dev/null +++ b/src/Compatibility/CheckStep.php @@ -0,0 +1,68 @@ + sprintf('%s-Funktionen, Klassen und Registrierungen', $profileLabel), + self::MIRRORS => sprintf('Fingerabdrücke nachgebauter %s-Funktionen', $profileLabel), + default => null, + }; + } + + /** @param list $problems */ + public function __construct( + public readonly string $key, + public readonly array $problems, + public readonly bool $skipped = false, + public readonly ?string $label = null + ) { + } + + public function green(): bool + { + return ! $this->skipped && $this->problems === []; + } + + public function label(): string + { + return $this->label ?? $this->key; + } + + /** @return array{key: string, problems: list, skipped: bool, label?: string} */ + public function toArray(): array + { + $data = ['key' => $this->key, 'problems' => $this->problems, 'skipped' => $this->skipped]; + + return $this->label === null ? $data : $data + ['label' => $this->label]; + } + + public static function fromArray(mixed $data): ?self + { + if (! is_array($data) || ! is_string($data['key'] ?? null) || ! is_array($data['problems'] ?? null)) { + return null; + } + + return new self( + $data['key'], + array_values(array_filter($data['problems'], 'is_string')), + ($data['skipped'] ?? false) === true, + is_string($data['label'] ?? null) ? $data['label'] : null + ); + } +} diff --git a/src/Compatibility/Compatibility.php b/src/Compatibility/Compatibility.php index 602add1..5989b14 100644 --- a/src/Compatibility/Compatibility.php +++ b/src/Compatibility/Compatibility.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace LIT\MCP\Compatibility; use Closure; +use LIT\MCP\Log; +use LIT\MCP\Plugin; use LogicException; use Throwable; @@ -21,25 +23,31 @@ final class Compatibility private array $byId = []; /** @var array keyed by capability ID */ private array $byCapability = []; + /** @var array */ + private array $actives = []; + /** @var array */ + private array $versions = []; /** @var array */ private array $matrices = []; /** @var array */ private array $checks = []; /** @var Closure(Profile): ManifestCheck */ private Closure $check; + /** @var Closure(Profile): ?CheckReport */ + private Closure $report; /** * @param list $profiles - * @param (Closure(Profile): ManifestCheck)|null $check Builds the check of a profile; default: its manifests - * without signature checks. + * @param (Closure(Profile): ManifestCheck)|null $check Builds the check of a profile; default: Profile::checker(). + * @param (Closure(Profile): ?CheckReport)|null $report The latest check report of a profile; a red one for + * the current state locks a LockOnFailure profile. + * Default: none (the structure check is the base). * @throws LogicException For a profile ID or a capability ID that occurs twice. */ - public function __construct(private array $profiles, ?Closure $check = null) + public function __construct(private array $profiles, ?Closure $check = null, ?Closure $report = null) { - $this->check = $check ?? static fn (Profile $profile): ManifestCheck => new ManifestCheck( - $profile->manifests, - signatures: false - ); + $this->report = $report ?? static fn (Profile $profile): ?CheckReport => null; + $this->check = $check ?? static fn (Profile $profile): ManifestCheck => $profile->checker(); foreach ($profiles as $profile) { if (isset($this->byId[$profile->id])) { throw new LogicException('profile ID twice: ' . $profile->id); @@ -77,14 +85,18 @@ final class Compatibility public function active(Profile $profile): bool { - return $profile->active === null || self::attempt($profile->active) === true; + return $this->actives[$profile->id] ??= $profile->active === null + || self::attempt($profile->active, $profile) === true; } public function version(Profile $profile): ?string { - $version = $profile->version === null ? null : self::attempt($profile->version); + if (! array_key_exists($profile->id, $this->versions)) { + $version = $profile->version === null ? null : self::attempt($profile->version, $profile); + $this->versions[$profile->id] = is_string($version) ? $version : null; + } - return is_string($version) ? $version : null; + return $this->versions[$profile->id]; } public function matrix(Profile $profile): CompatibilityMatrix @@ -104,11 +116,19 @@ final class Compatibility static fn (Problem $problem): string => $problem->label, $this->checkOf($profile)->problems() )); - } catch (Throwable) { + } catch (Throwable $e) { + Log::failure('compat ' . $profile->id, $e); + return [self::failed($profile)]; } } + /** Runs the admin check of the profile (spec §6.2); a report for $userId. */ + public function check(Profile $profile, int $userId): CheckReport + { + return CompatibilityCheck::forProfile($profile, $this->checkOf($profile))->run($userId); + } + public function usable(Capability $capability): bool { $owner = $this->owner($capability); @@ -161,17 +181,62 @@ final class Compatibility return CompatibilityMatrix::allIncompatible($capabilities, $tooOld); } + $red = $this->redReport($profile); + if ($red !== null) { + // shortcut: a step names no capability, so a red check locks all of them; refine with step features. + return CompatibilityMatrix::allIncompatible($capabilities, $red); + } + return CompatibilityMatrix::fromProblems( $capabilities, $this->checkOf($profile)->problems(), null, automatic: true ); - } catch (Throwable) { + } catch (Throwable $e) { + Log::failure('compat ' . $profile->id, $e); + return CompatibilityMatrix::allIncompatible($capabilities, self::failed($profile)); } } + /** + * What the latest report says when it is red and made for the current state; null = no reason to lock. A report + * without steps is no check result. The reason names only the labels of the steps, never their problem texts + * (they reach MCP clients in the 423). Only a new green check or a version change (plugin or third-party plugin) + * lifts a lock by a red report. + */ + private function redReport(Profile $profile): ?string + { + $report = ($this->report)($profile); + $version = $this->version($profile) ?? ''; + $stateKey = $profile->stateKey === null ? '' : ($profile->stateKey)($version); + if ( + $report === null + || $report->steps === [] + || $report->green() + || ! $report->currentFor($version, Plugin::VERSION, $stateKey) + ) { + return null; + } + $failed = array_map( + static fn (CheckStep $step): string => $step->label(), + array_values(array_filter($report->steps, static fn (CheckStep $step): bool => ! $step->green())) + ); + + return sprintf( + 'Die letzte Prüfung von %s ist fehlgeschlagen (%s); Details im Admin-Bereich.', + $profile->label, + implode('; ', $failed) + ); + } + + /** The report stored by the admin check, for Compatibility's $report parameter. */ + public static function storedReport(Profile $profile): ?CheckReport + { + return CheckReportStore::forProfile($profile)->latest(); + } + private function versionProblem(Profile $profile): ?string { if ($profile->minVersion === null) { @@ -202,11 +267,13 @@ final class Compatibility } /** The closure's result, null when it throws. */ - private static function attempt(Closure $closure): mixed + private static function attempt(Closure $closure, Profile $profile): mixed { try { return $closure(); - } catch (Throwable) { + } catch (Throwable $e) { + Log::failure('compat ' . $profile->id, $e); + return null; } } diff --git a/src/Compatibility/CompatibilityCheck.php b/src/Compatibility/CompatibilityCheck.php new file mode 100644 index 0000000..2e77f1f --- /dev/null +++ b/src/Compatibility/CompatibilityCheck.php @@ -0,0 +1,78 @@ +checker()); + } + + public function run(int $userId): CheckReport + { + $label = $this->profile->label; + $manifest = self::guarded(fn (): array => ManifestCheck::failed( + array_merge($this->manifests->codeChecks(), $this->manifests->registrationChecks()) + )); + $steps = [new CheckStep( + CheckStep::MANIFEST, + $manifest, + false, + CheckStep::genericLabel(CheckStep::MANIFEST, $label) + )]; + if (array_filter($this->profile->manifests, static fn (Manifest $m): bool => $m->mirrors !== []) !== []) { + $steps[] = new CheckStep( + CheckStep::MIRRORS, + self::guarded(fn (): array => ManifestCheck::failed($this->manifests->mirrorChecks())), + false, + CheckStep::genericLabel(CheckStep::MIRRORS, $label) + ); + } + if ($this->profile->extraSteps !== null) { + $steps = [...$steps, ...($this->profile->extraSteps)($manifest === [])]; + } + $version = (string) ($this->profile->version === null ? '' : ($this->profile->version)()); + + return new CheckReport( + wp_generate_uuid4(), + gmdate('c'), + $userId, + $version, + Plugin::VERSION, + $this->profile->stateKey === null ? '' : ($this->profile->stateKey)($version), + $steps + ); + } + + /** @return list The step's problems; a throwing step is one problem. */ + public static function guarded(Closure $step): array + { + try { + return array_values(array_filter((array) $step(), 'is_string')); + } catch (Throwable $e) { + // The report reaches MCP clients through the lock reason; the exception text stays in the log. + Log::failure('compat check step', $e); + + return ['Prüfschritt abgebrochen (Details im PHP-Error-Log).']; + } + } +} diff --git a/src/Compatibility/Profile.php b/src/Compatibility/Profile.php index 3774457..7a7e14c 100644 --- a/src/Compatibility/Profile.php +++ b/src/Compatibility/Profile.php @@ -12,6 +12,7 @@ defined('ABSPATH') || exit; /** * Everything the compatibility layer needs to know about one third-party plugin: its capabilities, what its code * depends on, how to tell whether it is active and which version it has. Pure data; Compatibility evaluates it. + * Mit benannten Argumenten bauen. */ final class Profile { @@ -22,6 +23,14 @@ final class Profile * @param (Closure(): ?string)|null $version The installed version, null result = unknown. * @param string|null $minVersion Lowest supported version (version_compare). * @param (Closure(): CompatibilityMatrix)|null $matrix Required for ReleasePerVersion, unused otherwise. + * @param list $testedVersions Versions the capabilities were verified against. + * @param (Closure(string): string)|null $stateKey What a check report is valid for, given the version. + * @param (Closure(bool): list)|null $extraSteps Steps after manifest and fingerprints; gets + * whether the manifest step was clean. + * @param (Closure(): ManifestCheck)|null $manifestCheck The check of this profile; null = its manifests + * without signature checks. + * @param string $reportVersionKey Array key of the version in stored reports and + * release rows (Divi: 'divi_version'). * @throws LogicException For a ReleasePerVersion profile without matrix or a capability ID given twice. */ public function __construct( @@ -33,7 +42,12 @@ final class Profile public readonly ?Closure $active = null, public readonly ?Closure $version = null, public readonly ?string $minVersion = null, - public readonly ?Closure $matrix = null + public readonly ?Closure $matrix = null, + public readonly array $testedVersions = [], + public readonly ?Closure $stateKey = null, + public readonly ?Closure $extraSteps = null, + public readonly ?Closure $manifestCheck = null, + public readonly string $reportVersionKey = 'version' ) { if ($tier === Tier::ReleasePerVersion && $matrix === null) { throw new LogicException(sprintf('profile %s: a ReleasePerVersion profile needs a matrix closure', $id)); @@ -45,4 +59,12 @@ final class Profile throw new LogicException(sprintf('profile %s: capability IDs twice: %s', $id, implode(', ', $duplicates))); } } + + /** The check of this profile: its manifestCheck closure, else its manifests without signature checks. */ + public function checker(): ManifestCheck + { + return $this->manifestCheck !== null + ? ($this->manifestCheck)() + : new ManifestCheck($this->manifests, signatures: false); + } } diff --git a/src/Compatibility/ReleaseStore.php b/src/Compatibility/ReleaseStore.php new file mode 100644 index 0000000..ef36c72 --- /dev/null +++ b/src/Compatibility/ReleaseStore.php @@ -0,0 +1,111 @@ +_releases. An entry holds for the plugin version that ran the check only. + * + * Not final so the resolver can be tested with a mock. + */ +class ReleaseStore +{ + /** @param string $versionKey Row key of the version (Divi: 'divi_version', so stored rows stay as they are). */ + public function __construct(private string $profileId, private string $versionKey = 'version') + { + } + + public static function forProfile(Profile $profile): self + { + return new self($profile->id, $profile->reportVersionKey); + } + + /** + * @return list> Rows with the keys , plugin_version, user_id, released_at, + * report_id. + */ + public function entries(): array + { + $raw = get_option($this->option(), []); + $entries = []; + foreach (is_array($raw) ? $raw : [] as $row) { + if ( + is_array($row) + && is_string($row[$this->versionKey] ?? null) + && is_string($row['plugin_version'] ?? null) + && is_int($row['user_id'] ?? null) + && is_string($row['released_at'] ?? null) + && is_string($row['report_id'] ?? null) + ) { + $entries[] = [ + $this->versionKey => $row[$this->versionKey], + 'plugin_version' => $row['plugin_version'], + 'user_id' => $row['user_id'], + 'released_at' => $row['released_at'], + 'report_id' => $row['report_id'], + ]; + } + } + + return $entries; + } + + /** + * @return array|null + */ + public function find(string $version, string $pluginVersion): ?array + { + foreach ($this->entries() as $entry) { + if ($entry[$this->versionKey] === $version && $entry['plugin_version'] === $pluginVersion) { + return $entry; + } + } + + return null; + } + + public function add(string $version, string $pluginVersion, int $userId, string $reportId): void + { + $entries = $this->without($version, $pluginVersion); + $entries[] = [ + $this->versionKey => $version, + 'plugin_version' => $pluginVersion, + 'user_id' => $userId, + 'released_at' => gmdate('c'), + 'report_id' => $reportId, + ]; + update_option($this->option(), $entries, false); + } + + public function withdraw(string $version, string $pluginVersion): bool + { + if ($this->find($version, $pluginVersion) === null) { + return false; + } + update_option($this->option(), $this->without($version, $pluginVersion), false); + + return true; + } + + /** + * @return list> Rows with the keys , plugin_version, user_id, released_at, + * report_id. + */ + private function without(string $version, string $pluginVersion): array + { + return array_values(array_filter( + $this->entries(), + fn (array $entry): bool => $entry[$this->versionKey] !== $version + || $entry['plugin_version'] !== $pluginVersion + )); + } + + private function option(): string + { + return 'lit_mcp_' . $this->profileId . '_releases'; + } +} diff --git a/src/Integrations/Divi/Admin/DiviTab.php b/src/Integrations/Divi/Admin/DiviTab.php index 75ca45e..bf5c33b 100644 --- a/src/Integrations/Divi/Admin/DiviTab.php +++ b/src/Integrations/Divi/Admin/DiviTab.php @@ -10,16 +10,18 @@ use LIT\MCP\Admin\AdminTab; use LIT\MCP\Admin\SettingsPage; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Compatibility\ReleaseState; -use LIT\MCP\Integrations\Divi\Release\CheckReport; -use LIT\MCP\Integrations\Divi\Release\CheckReportStore; +use LIT\MCP\Compatibility\CheckReport; +use LIT\MCP\Compatibility\CheckReportStore; use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; +use LIT\MCP\Integrations\Divi\Release\DiviCheckSteps; +use LIT\MCP\Integrations\Divi\Release\DiviProfile; use LIT\MCP\Integrations\Divi\Release\DiviEnvironment; use LIT\MCP\Integrations\Divi\Release\FeatureOverview; use LIT\MCP\Compatibility\FeatureStatus; use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; -use LIT\MCP\Integrations\Divi\Release\ReleaseStore; +use LIT\MCP\Compatibility\ReleaseStore; use LIT\MCP\Integrations\Divi\Release\WriteProbe; use LIT\MCP\Plugin; @@ -159,7 +161,7 @@ final class DiviTab implements AdminTab return; } - ($this->releases ?? new ReleaseStore())->add($version, Plugin::VERSION, get_current_user_id(), $report->id); + $this->releases()->add($version, Plugin::VERSION, get_current_user_id(), $report->id); ReleaseGate::invalidate(); $this->log?->record( 'lit/divi-release', @@ -177,7 +179,7 @@ final class DiviTab implements AdminTab check_admin_referer(self::WITHDRAW_ACTION); $version = $this->version(); - if (! ($this->releases ?? new ReleaseStore())->withdraw($version, Plugin::VERSION)) { + if (! $this->releases()->withdraw($version, Plugin::VERSION)) { $action->redirect(self::SLUG, 'divi_withdraw_failed'); return; @@ -514,7 +516,7 @@ final class DiviTab implements AdminTab esc_html(sprintf( /* translators: 1: Divi version, 2: plugin version, 3: date */ __('Divi %1$s mit LIT MCP %2$s, %3$s', 'wp-lit-mcp'), - $report->diviVersion, + $report->version, $report->pluginVersion, date_i18n((string) get_option('date_format'), (int) strtotime($report->createdAt)) )) @@ -530,7 +532,7 @@ final class DiviTab implements AdminTab . ' aria-hidden="true">%2$s: %3$s', esc_attr($icon), esc_html($word), - esc_html($step->label()) + esc_html(DiviCheckSteps::label($step)) ); if ($step->skipped) { echo ' (' . esc_html__('nicht ausgeführt', 'wp-lit-mcp') . ')'; @@ -569,8 +571,13 @@ final class DiviTab implements AdminTab return ($this->stateKey ?? static fn (string $v): string => ReleaseResolver::stateKey($v))($version); } + private function releases(): ReleaseStore + { + return $this->releases ?? new ReleaseStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY); + } + private function reports(): CheckReportStore { - return $this->reports ?? new CheckReportStore(); + return $this->reports ?? new CheckReportStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY); } } diff --git a/src/Integrations/Divi/Cli/DiviCheckCommand.php b/src/Integrations/Divi/Cli/DiviCheckCommand.php index 6a6beda..fd4e460 100644 --- a/src/Integrations/Divi/Cli/DiviCheckCommand.php +++ b/src/Integrations/Divi/Cli/DiviCheckCommand.php @@ -7,10 +7,12 @@ namespace LIT\MCP\Integrations\Divi\Cli; use Closure; use LIT\MCP\Compatibility\LineDiff; use LIT\MCP\Compatibility\SourceFingerprint; -use LIT\MCP\Integrations\Divi\Release\CheckReport; -use LIT\MCP\Integrations\Divi\Release\CheckReportStore; -use LIT\MCP\Integrations\Divi\Release\CheckStep; -use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck; +use LIT\MCP\Compatibility\CheckReport; +use LIT\MCP\Compatibility\CheckReportStore; +use LIT\MCP\Compatibility\CheckStep; +use LIT\MCP\Compatibility\CompatibilityCheck; +use LIT\MCP\Integrations\Divi\Release\DiviCheckSteps; +use LIT\MCP\Integrations\Divi\Release\DiviProfile; use LIT\MCP\Compatibility\CompatibilityMatrix; use LIT\MCP\Integrations\Divi\Release\DiviAdapters; use LIT\MCP\Integrations\Divi\Release\DiviEnvironment; @@ -109,10 +111,10 @@ final class DiviCheckCommand } $report = ($this->check)()->run(get_current_user_id()); - ($this->reports ?? new CheckReportStore())->save($report); - ($this->out)(sprintf('Divi %s mit LIT MCP %s', $report->diviVersion ?: '?', $report->pluginVersion)); + ($this->reports ?? new CheckReportStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY))->save($report); + ($this->out)(sprintf('Divi %s mit LIT MCP %s', $report->version ?: '?', $report->pluginVersion)); foreach ($report->steps as $step) { - ($this->out)(($step->skipped ? '–' : ($step->green() ? '✓' : '✗')) . ' ' . $step->label()); + ($this->out)(($step->skipped ? '–' : ($step->green() ? '✓' : '✗')) . ' ' . DiviCheckSteps::label($step)); foreach ($step->problems as $problem) { ($this->out)(' ' . $problem); } diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 65898c2..9766880 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -9,6 +9,8 @@ use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\ToolAvailability; use LIT\MCP\Admin\SettingsPage; use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\Compatibility\CompatibilityCheck; +use LIT\MCP\Compatibility\Profile; use LIT\MCP\Compatibility\ProvidesCompatibility; use LIT\MCP\Integrations\Bootable; use LIT\MCP\Integrations\Divi\Admin\DiviTab; @@ -73,7 +75,6 @@ use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility; use LIT\MCP\Integrations\Divi\Pages\DiviWriter; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; -use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck; use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; use LIT\MCP\Integrations\Divi\Release\FeatureOverview; use LIT\MCP\Integrations\Divi\Release\DiviProfile; @@ -190,7 +191,7 @@ final class DiviIntegration implements Integration, Bootable, ProvidesCompatibil public function compatibilities(): array { - return [DiviProfile::profile()]; + return [$this->profile()]; } /** @@ -261,7 +262,8 @@ final class DiviIntegration implements Integration, Bootable, ProvidesCompatibil // Spec §6: the admin check runs as a REST request (same context as MCP calls). Its short form has no // Tools-tab gate: the check tests Divi, not this site's tool switches. - $check = fn (): CompatibilityCheck => CompatibilityCheck::create($this->shortForm(null)); + $profile = $this->profile(); + $check = static fn (): CompatibilityCheck => CompatibilityCheck::forProfile($profile); (new DiviCheckRoute($check))->register(); DiviCheckCommand::register($check); @@ -429,6 +431,12 @@ final class DiviIntegration implements Integration, Bootable, ProvidesCompatibil } /** @param (Closure(): bool)|null $writable */ + /** The Divi profile; the check's write test composes through the short form without a Tools-tab gate. */ + private function profile(): Profile + { + return DiviProfile::profile(fn (): DiviContentComposer => $this->shortForm(null)); + } + private function shortForm(?Closure $writable): DiviContentComposer { return new DiviContentComposer($this->schema, $this->composition(), new DiviSaver(), $this->design, $writable); diff --git a/src/Integrations/Divi/Release/CheckReportStore.php b/src/Integrations/Divi/Release/CheckReportStore.php deleted file mode 100644 index 1141884..0000000 --- a/src/Integrations/Divi/Release/CheckReportStore.php +++ /dev/null @@ -1,23 +0,0 @@ -toArray(), false); - } -} diff --git a/src/Integrations/Divi/Release/CheckStep.php b/src/Integrations/Divi/Release/CheckStep.php deleted file mode 100644 index fffac3d..0000000 --- a/src/Integrations/Divi/Release/CheckStep.php +++ /dev/null @@ -1,62 +0,0 @@ - 'Divi-Funktionen, Klassen und Registrierungen', - self::MIRRORS => 'Fingerabdrücke nachgebauter Divi-Funktionen', - self::CATALOG => 'Modulkatalog (Kurzform-Pfade)', - self::FORMAT => 'Format im Speicher (Parser und Serializer)', - self::WRITE => 'Schreibtest auf einer Wegwerfseite', - ]; - - /** @param list $problems */ - public function __construct( - public readonly string $key, - public readonly array $problems, - public readonly bool $skipped = false - ) { - } - - public function green(): bool - { - return ! $this->skipped && $this->problems === []; - } - - public function label(): string - { - return self::LABELS[$this->key] ?? $this->key; - } - - /** @return array{key: string, problems: list, skipped: bool} */ - public function toArray(): array - { - return ['key' => $this->key, 'problems' => $this->problems, 'skipped' => $this->skipped]; - } - - public static function fromArray(mixed $data): ?self - { - if (! is_array($data) || ! is_string($data['key'] ?? null) || ! is_array($data['problems'] ?? null)) { - return null; - } - - return new self( - $data['key'], - array_values(array_filter($data['problems'], 'is_string')), - ($data['skipped'] ?? false) === true - ); - } -} diff --git a/src/Integrations/Divi/Release/CompatibilityCheck.php b/src/Integrations/Divi/Release/CompatibilityCheck.php deleted file mode 100644 index 2fb631a..0000000 --- a/src/Integrations/Divi/Release/CompatibilityCheck.php +++ /dev/null @@ -1,93 +0,0 @@ - $catalog Short-form problems against Divi's live catalog. - * @param Closure(): string $version Installed Divi version ('' = unknown). - * @param Closure(string): string $stateKey ReleaseResolver::stateKey(). - */ - public function __construct( - private ManifestCheck $manifests, - private Closure $catalog, - private FormatCheck $format, - private WriteProbe $probe, - private Closure $version, - private Closure $stateKey - ) { - } - - public static function create(ContentComposer $composer): self - { - return new self( - DiviManifestCheck::create(), - static fn (): array => DiviModules::isAvailable() - ? ShortformCheck::problems((new DiviModules())->catalog()) - : ['Divi 5 ist in dieser Anfrage nicht geladen.'], - new FormatCheck(), - new WriteProbe($composer), - static fn (): string => DiviEnvironment::version() ?? '', - static fn (string $version): string => ReleaseResolver::stateKey($version) - ); - } - - public function run(int $userId): CheckReport - { - $manifest = self::guarded(fn (): array => ManifestCheck::failed( - array_merge($this->manifests->codeChecks(), $this->manifests->registrationChecks()) - )); - $steps = [ - new CheckStep(CheckStep::MANIFEST, $manifest), - new CheckStep(CheckStep::MIRRORS, self::guarded( - fn (): array => ManifestCheck::failed($this->manifests->mirrorChecks()) - )), - new CheckStep(CheckStep::CATALOG, self::guarded($this->catalog)), - new CheckStep(CheckStep::FORMAT, self::guarded(fn (): array => $this->format->problems())), - $manifest === [] - ? new CheckStep(CheckStep::WRITE, self::guarded(fn (): array => $this->probe->run())) - : new CheckStep(CheckStep::WRITE, [], true), - ]; - $version = ($this->version)(); - - return new CheckReport( - wp_generate_uuid4(), - gmdate('c'), - $userId, - $version, - Plugin::VERSION, - ($this->stateKey)($version), - $steps - ); - } - - /** @return list */ - private static function guarded(Closure $step): array - { - try { - return array_values(array_filter((array) $step(), 'is_string')); - } catch (Throwable $e) { - return ['Prüfschritt abgebrochen: ' . $e->getMessage()]; - } - } -} diff --git a/src/Integrations/Divi/Release/DiviCheckRoute.php b/src/Integrations/Divi/Release/DiviCheckRoute.php index 17513ac..3e90d6a 100644 --- a/src/Integrations/Divi/Release/DiviCheckRoute.php +++ b/src/Integrations/Divi/Release/DiviCheckRoute.php @@ -6,6 +6,8 @@ namespace LIT\MCP\Integrations\Divi\Release; use Closure; use LIT\MCP\Admin\SettingsPage; +use LIT\MCP\Compatibility\CheckReportStore; +use LIT\MCP\Compatibility\CompatibilityCheck; use LIT\MCP\Integrations\Divi\Admin\DiviTab; use LIT\MCP\Log; use Throwable; @@ -62,7 +64,8 @@ final class DiviCheckRoute if (self::lock()) { try { $report = ($this->check)()->run(get_current_user_id()); - ($this->reports ?? new CheckReportStore())->save($report); + $reports = $this->reports ?? new CheckReportStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY); + $reports->save($report); ReleaseGate::invalidate(); $notice = 'divi_checked'; } catch (Throwable $e) { diff --git a/src/Integrations/Divi/Release/DiviCheckSteps.php b/src/Integrations/Divi/Release/DiviCheckSteps.php new file mode 100644 index 0000000..ffbb550 --- /dev/null +++ b/src/Integrations/Divi/Release/DiviCheckSteps.php @@ -0,0 +1,61 @@ + 'Modulkatalog (Kurzform-Pfade)', + self::FORMAT => 'Format im Speicher (Parser und Serializer)', + self::WRITE => 'Schreibtest auf einer Wegwerfseite', + ]; + + /** @param list $problems */ + public static function step(string $key, array $problems, bool $skipped = false): CheckStep + { + return new CheckStep($key, $problems, $skipped, self::LABELS[$key] ?? null); + } + + /** + * The Profile::$extraSteps of Divi: module catalog, format round trip and the write test. The write test is + * skipped only when Divi functions are missing (it would fail anyway), not when only fingerprints changed – so + * --update-reference can see whether everything else still works. + * + * @param Closure(): list $catalog Short-form problems against Divi's live catalog. + * @param Closure(): list $probe WriteProbe::run(). + * @return Closure(bool): list + */ + public static function extra(Closure $catalog, FormatCheck $format, Closure $probe): Closure + { + return static fn (bool $manifestClean): array => [ + self::step(self::CATALOG, CompatibilityCheck::guarded($catalog)), + self::step(self::FORMAT, CompatibilityCheck::guarded(static fn (): array => $format->problems())), + $manifestClean + ? self::step(self::WRITE, CompatibilityCheck::guarded($probe)) + : self::step(self::WRITE, [], true), + ]; + } + + public static function label(CheckStep $step): string + { + // Reports saved before labels were stored have none. + return $step->label + ?? self::LABELS[$step->key] + ?? CheckStep::genericLabel($step->key, DiviProfile::LABEL) + ?? $step->key; + } +} diff --git a/src/Integrations/Divi/Release/DiviProfile.php b/src/Integrations/Divi/Release/DiviProfile.php index e2d68b7..7c9ef5c 100644 --- a/src/Integrations/Divi/Release/DiviProfile.php +++ b/src/Integrations/Divi/Release/DiviProfile.php @@ -4,24 +4,49 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Release; +use Closure; use LIT\MCP\Compatibility\Profile; use LIT\MCP\Compatibility\Tier; +use LIT\MCP\Integrations\Divi\Schema\DiviModules; +use LIT\MCP\Integrations\Divi\Schema\ShortformCheck; +use LIT\MCP\Integrations\WordPress\Content\ContentComposer; +use LogicException; defined('ABSPATH') || exit; /** Divi as a compatibility profile: every version needs a release, the matrix comes from the release gate. */ final class DiviProfile { - public static function profile(): Profile + public const ID = 'divi'; + public const LABEL = 'Divi'; + /** Stored reports and release rows keep this key, so data from before the generic layer stays readable. */ + public const REPORT_VERSION_KEY = 'divi_version'; + + /** @param (Closure(): ContentComposer)|null $composer The write test's composer; without it that step fails. */ + public static function profile(?Closure $composer = null): Profile { return new Profile( - 'divi', - 'Divi', - Tier::ReleasePerVersion, - DiviFeature::cases(), - array_values(DiviAdapters::manifests()), + id: self::ID, + label: self::LABEL, + tier: Tier::ReleasePerVersion, + capabilities: DiviFeature::cases(), + manifests: array_values(DiviAdapters::manifests()), + active: static fn (): bool => DiviEnvironment::version() !== null, version: DiviEnvironment::version(...), - matrix: static fn () => ReleaseGate::status()->matrix + matrix: static fn () => ReleaseGate::status()->matrix, + testedVersions: DiviReleases::TESTED, + stateKey: ReleaseResolver::stateKey(...), + extraSteps: DiviCheckSteps::extra( + static fn (): array => DiviModules::isAvailable() + ? ShortformCheck::problems((new DiviModules())->catalog()) + : ['Divi 5 ist in dieser Anfrage nicht geladen.'], + new FormatCheck(), + static fn (): array => (new WriteProbe( + $composer === null ? throw new LogicException('no composer for the write test') : $composer() + ))->run() + ), + manifestCheck: DiviManifestCheck::create(...), + reportVersionKey: self::REPORT_VERSION_KEY ); } } diff --git a/src/Integrations/Divi/Release/ReleaseResolver.php b/src/Integrations/Divi/Release/ReleaseResolver.php index 32bb65e..4b85771 100644 --- a/src/Integrations/Divi/Release/ReleaseResolver.php +++ b/src/Integrations/Divi/Release/ReleaseResolver.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Release; use Closure; use LIT\MCP\Compatibility\CompatibilityMatrix; use LIT\MCP\Compatibility\ManifestCheck; +use LIT\MCP\Compatibility\ReleaseStore; use LIT\MCP\Compatibility\Problem; use LIT\MCP\Plugin; @@ -53,7 +54,8 @@ final class ReleaseResolver if (in_array($version, DiviReleases::TESTED, true)) { return ReleaseStatus::of($version, self::matrix($problems, CompatibilityMatrix::SOURCE_CODE, false)); } - $entry = $version === '' ? null : ($this->store ?? new ReleaseStore())->find($version, Plugin::VERSION); + $store = $this->store ?? new ReleaseStore(DiviProfile::ID, DiviProfile::REPORT_VERSION_KEY); + $entry = $version === '' ? null : $store->find($version, Plugin::VERSION); if ($entry !== null) { return ReleaseStatus::of( $version, diff --git a/src/Integrations/Divi/Release/ReleaseStore.php b/src/Integrations/Divi/Release/ReleaseStore.php deleted file mode 100644 index 66187f3..0000000 --- a/src/Integrations/Divi/Release/ReleaseStore.php +++ /dev/null @@ -1,99 +0,0 @@ - - */ - public function entries(): array - { - $raw = get_option(self::OPTION, []); - $entries = []; - foreach (is_array($raw) ? $raw : [] as $row) { - if ( - is_array($row) - && is_string($row['divi_version'] ?? null) - && is_string($row['plugin_version'] ?? null) - && is_int($row['user_id'] ?? null) - && is_string($row['released_at'] ?? null) - && is_string($row['report_id'] ?? null) - ) { - $entries[] = [ - 'divi_version' => $row['divi_version'], - 'plugin_version' => $row['plugin_version'], - 'user_id' => $row['user_id'], - 'released_at' => $row['released_at'], - 'report_id' => $row['report_id'], - ]; - } - } - - return $entries; - } - - /** - * @return array{divi_version: string, plugin_version: string, user_id: int, released_at: string, - * report_id: string}|null - */ - public function find(string $diviVersion, string $pluginVersion): ?array - { - foreach ($this->entries() as $entry) { - if ($entry['divi_version'] === $diviVersion && $entry['plugin_version'] === $pluginVersion) { - return $entry; - } - } - - return null; - } - - public function add(string $diviVersion, string $pluginVersion, int $userId, string $reportId): void - { - $entries = $this->without($diviVersion, $pluginVersion); - $entries[] = [ - 'divi_version' => $diviVersion, - 'plugin_version' => $pluginVersion, - 'user_id' => $userId, - 'released_at' => gmdate('c'), - 'report_id' => $reportId, - ]; - update_option(self::OPTION, $entries, false); - } - - public function withdraw(string $diviVersion, string $pluginVersion): bool - { - if ($this->find($diviVersion, $pluginVersion) === null) { - return false; - } - update_option(self::OPTION, $this->without($diviVersion, $pluginVersion), false); - - return true; - } - - /** - * @return list - */ - private function without(string $diviVersion, string $pluginVersion): array - { - return array_values(array_filter( - $this->entries(), - static fn (array $entry): bool => $entry['divi_version'] !== $diviVersion - || $entry['plugin_version'] !== $pluginVersion - )); - } -} diff --git a/src/Integrations/Divi/Release/WriteProbe.php b/src/Integrations/Divi/Release/WriteProbe.php index 6a3b3e2..6d7ad40 100644 --- a/src/Integrations/Divi/Release/WriteProbe.php +++ b/src/Integrations/Divi/Release/WriteProbe.php @@ -8,6 +8,7 @@ use LIT\MCP\Abilities\Errors; use LIT\MCP\Integrations\Divi\Pages\DiviPages; use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Render\PageRenderer; +use LIT\MCP\Log; use Throwable; use WP_Error; use WP_Post; @@ -101,7 +102,9 @@ class WriteProbe try { $problems = $this->exercise($id); } catch (Throwable $e) { - $problems = ['Schreibtest abgebrochen: ' . $e->getMessage()]; + // The report reaches MCP clients through the lock reason; the exception text stays in the log. + Log::failure('divi write test', $e); + $problems = ['Schreibtest abgebrochen (Details im PHP-Error-Log).']; } finally { if (! self::delete($id)) { $problems[] = sprintf( diff --git a/src/Integrations/IntegrationRegistry.php b/src/Integrations/IntegrationRegistry.php index 7ed0baf..7520754 100644 --- a/src/Integrations/IntegrationRegistry.php +++ b/src/Integrations/IntegrationRegistry.php @@ -130,7 +130,10 @@ final class IntegrationRegistry */ public function compatibility(): Compatibility { - return $this->compatibility ??= new Compatibility($this->compatibilities()); + return $this->compatibility ??= new Compatibility( + $this->compatibilities(), + report: Compatibility::storedReport(...) + ); } /** @return list Ids of all known integrations, available or not. */ diff --git a/src/Integrations/Pods/PodsCompatibility.php b/src/Integrations/Pods/PodsCompatibility.php index edec2a8..e06b07a 100644 --- a/src/Integrations/Pods/PodsCompatibility.php +++ b/src/Integrations/Pods/PodsCompatibility.php @@ -24,11 +24,11 @@ final class PodsCompatibility $all = PodsFeature::cases(); return new Profile( - 'pods', - 'Pods', - Tier::LockOnFailure, - $all, - [new Manifest( + id: 'pods', + label: 'Pods', + tier: Tier::LockOnFailure, + capabilities: $all, + manifests: [new Manifest( 'Pods', [ new Call('pods_api', 0, [PodsFeature::SchemaRead]), @@ -41,9 +41,10 @@ final class PodsCompatibility ], [new ConstantRequirement('PODS_VERSION', $all)] )], - static fn (): bool => defined('PODS_VERSION'), - static fn (): ?string => defined('PODS_VERSION') ? (string) PODS_VERSION : null, - '2.8' + active: static fn (): bool => defined('PODS_VERSION'), + version: static fn (): ?string => defined('PODS_VERSION') ? (string) PODS_VERSION : null, + minVersion: '2.8', + testedVersions: ['3.3.9.2'] ); } } diff --git a/src/Plugin.php b/src/Plugin.php index 739a4ee..5e4985b 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -231,7 +231,7 @@ final class Plugin // The sources exist before the registry, so Pods gets its own Compatibility here – a deliberate, cheap // duplicate of the registry's (a few function_exists checks, memoised per request). $sources = self::schemaSources( - new Compatibility([PodsCompatibility::profile()]), + new Compatibility([PodsCompatibility::profile()], report: Compatibility::storedReport(...)), defined('PODS_VERSION') ); diff --git a/src/Uninstaller.php b/src/Uninstaller.php index 4d6ce29..be49e0c 100644 --- a/src/Uninstaller.php +++ b/src/Uninstaller.php @@ -17,9 +17,11 @@ defined('ABSPATH') || exit; * so the Composer/Jetpack autoloader is not guaranteed to have run. The option/hook/table * names below are therefore duplicated from Access::OPTION, Abilities\ExposurePolicy::OPTION, * Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Integrations\Divi\Release\ - * ReleaseStore::OPTION, Integrations\Divi\Release\CheckReportStore::OPTION, Integrations\ - * Divi\Release\DiviCheckRoute::LOCK, Integrations\Divi\Design\DesignLock::option(), - * Admin\ConnectionStatus::ENDPOINT_TRANSIENT, Integrations\Divi\Release\ReleaseResolver::CACHE, + * lit_mcp__releases (Compatibility\ReleaseStore), lit_mcp__check_report + * (Compatibility\CheckReportStore) and lit_mcp__check_lock (Divi\Release\DiviCheckRoute::LOCK) for the + * profiles divi and pods (an explicit list: a LIKE pattern could hit foreign options), + * Integrations\Divi\Design\DesignLock::option(), Admin\ConnectionStatus::ENDPOINT_TRANSIENT, + * Integrations\Divi\Release\ReleaseResolver::CACHE, * ChangeLog\ChangeLogCleanup::HOOK, ChangeLog\ChangeLog::TABLE, Admin\LogTab::PER_PAGE_OPTION and * Updater\RecheckOnInstall::OPTION on purpose; * tests/Unit/UninstallerTest.php and tests/Unit/UninstallTest.php keep them in sync with @@ -35,6 +37,7 @@ final class Uninstaller private const OPTIONS = [ 'lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version', 'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock', + 'lit_mcp_pods_releases', 'lit_mcp_pods_check_report', 'lit_mcp_pods_check_lock', 'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets', 'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css', 'lit_mcp_lock_divi_options', 'lit_mcp_lock_variables', 'lit_mcp_lock_presets', 'lit_mcp_lock_custom_css', diff --git a/tests/Unit/Abilities/CompatibilityGatesTest.php b/tests/Unit/Abilities/CompatibilityGatesTest.php index 850767d..cbcdc71 100644 --- a/tests/Unit/Abilities/CompatibilityGatesTest.php +++ b/tests/Unit/Abilities/CompatibilityGatesTest.php @@ -59,7 +59,7 @@ final class CompatibilityGatesTest extends TestCase $ability->allows('requirements')->andReturn($ids); $ability->allows('permission')->andReturn(true); - $free = new Profile('fake', 'Fake Plugin', Tier::LockOnFailure, [FakeCapability::Read]); + $free = new Profile(id: 'fake', label: 'Fake Plugin', tier: Tier::LockOnFailure, capabilities: [FakeCapability::Read]); $compat = new Compatibility([DiviProfile::profile(), PodsCompatibility::profile(), $free]); $integration = Mockery::mock(Integration::class); $integration->allows('id')->andReturn('core'); diff --git a/tests/Unit/Compatibility/CapabilityGateTest.php b/tests/Unit/Compatibility/CapabilityGateTest.php index 07fa5db..b61eaf9 100644 --- a/tests/Unit/Compatibility/CapabilityGateTest.php +++ b/tests/Unit/Compatibility/CapabilityGateTest.php @@ -40,17 +40,17 @@ final class CapabilityGateTest extends TestCase private function gate(?FakeChangeLog $log = null, array $delegated = []): CapabilityGate { $lock = new Profile( - 'fake', - 'Fake Plugin', - Tier::LockOnFailure, - [FakeCapability::Read, FakeCapability::Write], - [new Manifest('Fake', calls: [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])])] + id: 'fake', + label: 'Fake Plugin', + tier: Tier::LockOnFailure, + capabilities: [FakeCapability::Read, FakeCapability::Write], + manifests: [new Manifest('Fake', calls: [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])])] ); $release = new Profile( - 'release', - 'Release', - Tier::ReleasePerVersion, - [FakeCapability::Other], + id: 'release', + label: 'Release', + tier: Tier::ReleasePerVersion, + capabilities: [FakeCapability::Other], matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allIncompatible( [FakeCapability::Other], 'would lock' diff --git a/tests/Unit/Compatibility/CapabilityLockedTest.php b/tests/Unit/Compatibility/CapabilityLockedTest.php index a35ba9c..a263537 100644 --- a/tests/Unit/Compatibility/CapabilityLockedTest.php +++ b/tests/Unit/Compatibility/CapabilityLockedTest.php @@ -18,7 +18,7 @@ final class CapabilityLockedTest extends TestCase { public function testTheErrorNamesPluginCapabilitiesAndProblems(): void { - $profile = new Profile('fake', 'Fake Plugin', Tier::LockOnFailure, [FakeCapability::Write]); + $profile = new Profile(id: 'fake', label: 'Fake Plugin', tier: Tier::LockOnFailure, capabilities: [FakeCapability::Write]); $locked = CapabilityLocked::of( [new FeatureStatus(FakeCapability::Write, ReleaseState::Incompatible, ['Fake: x() fehlt'])], [$profile] diff --git a/tests/Unit/Compatibility/CheckReportTest.php b/tests/Unit/Compatibility/CheckReportTest.php new file mode 100644 index 0000000..f90e0dd --- /dev/null +++ b/tests/Unit/Compatibility/CheckReportTest.php @@ -0,0 +1,197 @@ + */ + private array $options = []; + + protected function setUp(): void + { + parent::setUp(); + $this->options = []; + Functions\when('get_option')->alias( + fn (string $name, mixed $default = false): mixed => $this->options[$name] ?? $default + ); + Functions\when('update_option')->alias(function (string $name, mixed $value, mixed $autoload = null): bool { + $this->options[$name] = $value; + self::assertFalse($autoload, 'the report is read in the admin only – no autoload'); + + return true; + }); + } + + /** @param list $steps */ + private static function report(array $steps): CheckReport + { + return new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, '5.3.0', '0.6.0', 'key', $steps); + } + + /** @return list */ + private static function green(): array + { + return array_map( + static fn (string $key): CheckStep => new CheckStep($key, []), + [CheckStep::MANIFEST, CheckStep::MIRRORS, 'catalog'] + ); + } + + /** What the Divi report looked like before the generic version: no label per step, key divi_version. */ + private static function oldDiviReport(): array + { + return [ + 'id' => 'r1', + 'created_at' => '2026-10-01T08:00:00+00:00', + 'user_id' => 1, + 'divi_version' => '5.3.0', + 'plugin_version' => '0.6.0', + 'state_key' => 'key', + 'steps' => [ + ['key' => 'manifest', 'problems' => [], 'skipped' => false], + ['key' => 'mirrors', 'problems' => ['Theme Builder: Nachbau von x() – y'], 'skipped' => false], + ['key' => 'write', 'problems' => [], 'skipped' => true], + ], + ]; + } + + public function testGreenOnlyWithEveryStepGreenAndNoneSkipped(): void + { + self::assertTrue(self::report(self::green())->green()); + self::assertFalse(self::report([])->green()); + + $red = self::green(); + $red[2] = new CheckStep('catalog', ['divi/text: unbekanntes Modul']); + self::assertFalse(self::report($red)->green()); + + $skipped = self::green(); + $skipped[2] = new CheckStep('catalog', [], true); + self::assertFalse(self::report($skipped)->green()); + } + + public function testFreshOnlyForTheSameVersionsAndStateKey(): void + { + $report = self::report(self::green()); + + self::assertTrue($report->freshFor('5.3.0', '0.6.0', 'key')); + self::assertFalse($report->freshFor('5.3.1', '0.6.0', 'key'), 'plugin under test updated'); + self::assertFalse($report->freshFor('5.3.0', '0.7.0', 'key'), 'this plugin updated'); + self::assertFalse($report->freshFor('5.3.0', '0.6.0', 'other'), 'a source file changed'); + } + + public function testRoundTripsThroughAnArrayWithTheLabel(): void + { + $steps = self::green(); + $steps[1] = new CheckStep(CheckStep::MIRRORS, ['x'], false, 'Fingerabdrücke'); + $report = self::report($steps); + + self::assertEquals($report, CheckReport::fromArray($report->toArray())); + self::assertSame('Fingerabdrücke', $report->step(CheckStep::MIRRORS)?->label()); + self::assertSame('Fingerabdrücke', $report->toArray()['steps'][1]['label']); + self::assertNull(CheckReport::fromArray('broken')); + self::assertNull(CheckReport::fromArray(['id' => 'r1'])); + } + + public function testAStepWithoutLabelShowsItsKeyAndIsWrittenWithoutOne(): void + { + $step = new CheckStep('catalog', []); + + self::assertSame('catalog', $step->label()); + self::assertArrayNotHasKey('label', $step->toArray()); + } + + public function testTheGenericStepsHaveOneLabelSourceWithTheProfileName(): void + { + self::assertSame('Pods-Funktionen, Klassen und Registrierungen', CheckStep::genericLabel(CheckStep::MANIFEST, 'Pods')); + self::assertSame('Fingerabdrücke nachgebauter Pods-Funktionen', CheckStep::genericLabel(CheckStep::MIRRORS, 'Pods')); + self::assertNull(CheckStep::genericLabel('catalog', 'Pods')); + } + + public function testAReportWithoutStepsIsNotGreen(): void + { + self::assertFalse(self::report([])->green()); + } + + public function testAnOldDiviReportIsReadAndWrittenUnchanged(): void + { + $old = self::oldDiviReport(); + + $report = CheckReport::fromArray($old, 'divi_version'); + + self::assertSame('5.3.0', $report?->version); + self::assertSame('manifest', $report?->steps[0]->label(), 'no stored label: the raw key'); + self::assertSame($old, $report?->toArray('divi_version')); + self::assertNull(CheckReport::fromArray($old), 'the default key is "version"'); + } + + public function testTheStoreKeepsTheDiviOptionAndFormat(): void + { + $this->options['lit_mcp_divi_check_report'] = self::oldDiviReport(); + $store = new CheckReportStore('divi', 'divi_version'); + + $report = $store->latest(); + self::assertSame('r1', $report?->id); + + $store->save($report); + self::assertSame(self::oldDiviReport(), $this->options['lit_mcp_divi_check_report']); + } + + public function testTheStoresOfAProfileUseItsIdAndVersionKey(): void + { + $profile = new Profile( + id: 'divi', + label: 'Divi', + tier: Tier::ReportOnly, + capabilities: [FakeCapability::Read], + reportVersionKey: 'divi_version' + ); + $this->options['lit_mcp_divi_check_report'] = self::oldDiviReport(); + $this->options['lit_mcp_divi_releases'] = [[ + 'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3, + 'released_at' => '2026-10-01T08:00:00+00:00', 'report_id' => 'r', + ]]; + + self::assertSame('r1', CheckReportStore::forProfile($profile)->latest()?->id); + self::assertSame('r', ReleaseStore::forProfile($profile)->find('5.2.2', '0.6.0')['report_id'] ?? null); + } + + public function testStoredReportReadsTheDiviFormatForAProfileWithItsKey(): void + { + $profile = new Profile( + id: 'divi', + label: 'Divi', + tier: Tier::ReportOnly, + capabilities: [FakeCapability::Read], + reportVersionKey: 'divi_version' + ); + $this->options['lit_mcp_divi_check_report'] = self::oldDiviReport(); + + self::assertSame('5.3.0', Compatibility::storedReport($profile)?->version); + } + + public function testTheStoreUsesAnOptionAndVersionKeyPerProfile(): void + { + $store = new CheckReportStore('pods'); + self::assertNull($store->latest()); + + $store->save(self::report(self::green())); + + self::assertSame(['lit_mcp_pods_check_report'], array_keys($this->options)); + self::assertSame('5.3.0', $this->options['lit_mcp_pods_check_report']['version']); + self::assertSame('5.3.0', $store->latest()?->version); + self::assertNull((new CheckReportStore('divi', 'divi_version'))->latest()); + } +} diff --git a/tests/Unit/Compatibility/CompatibilityCheckTest.php b/tests/Unit/Compatibility/CompatibilityCheckTest.php new file mode 100644 index 0000000..a18ceda --- /dev/null +++ b/tests/Unit/Compatibility/CompatibilityCheckTest.php @@ -0,0 +1,163 @@ +justReturn('uuid-1'); + } + + /** @param list $manifests */ + private static function profile(array $manifests = [], ?Closure $extraSteps = null, ?Closure $stateKey = null): Profile + { + return new Profile( + id: 'fake', + label: 'Fake', + tier: Tier::LockOnFailure, + capabilities: [FakeCapability::Read], + manifests: $manifests, + version: static fn (): ?string => '2.1.0', + stateKey: $stateKey, + extraSteps: $extraSteps + ); + } + + private static function withMirrors(): Manifest + { + return new Manifest('Fake', mirrors: [new Mirror('m', 'f.php', 'fn', null, 'x')]); + } + + /** @param array $code */ + private static function check(Profile $profile, array $code = [], array $mirrors = []): ManifestCheck + { + $check = Mockery::mock(ManifestCheck::class); + $check->allows('codeChecks')->andReturn($code); + $check->allows('registrationChecks')->andReturn([]); + $check->allows('mirrorChecks')->andReturn($mirrors); + + return $check; + } + + /** @return list */ + private static function keys(CompatibilityCheck $check): array + { + return array_map(static fn (CheckStep $step): string => $step->key, $check->run(7)->steps); + } + + public function testAProfileWithoutMirrorsGetsOnlyTheManifestStep(): void + { + $profile = self::profile([new Manifest('Fake')]); + $report = (new CompatibilityCheck($profile, self::check($profile)))->run(7); + + self::assertTrue($report->green()); + self::assertSame(['manifest'], array_map(static fn (CheckStep $s): string => $s->key, $report->steps)); + self::assertSame('Fake-Funktionen, Klassen und Registrierungen', $report->steps[0]->label()); + self::assertSame(['uuid-1', 7, '2.1.0', Plugin::VERSION, ''], [ + $report->id, $report->userId, $report->version, $report->pluginVersion, $report->stateKey, + ]); + } + + public function testAManifestWithMirrorsAddsTheFingerprintStep(): void + { + $profile = self::profile([self::withMirrors()], stateKey: static fn (string $v): string => 'key-' . $v); + $check = new CompatibilityCheck($profile, self::check($profile, [], ['TB: Nachbau von y()' => static fn (): bool => false])); + + $report = $check->run(7); + + self::assertSame(['manifest', 'mirrors'], array_map(static fn (CheckStep $s): string => $s->key, $report->steps)); + self::assertSame(['TB: Nachbau von y()'], $report->step(CheckStep::MIRRORS)?->problems); + self::assertSame('Fingerabdrücke nachgebauter Fake-Funktionen', $report->step(CheckStep::MIRRORS)?->label()); + self::assertSame('key-2.1.0', $report->stateKey); + self::assertFalse($report->green()); + } + + public function testExtraStepsFollowAndKnowWhetherTheManifestIsClean(): void + { + $seen = []; + $extra = static function (bool $clean) use (&$seen): array { + $seen[] = $clean; + + return [new CheckStep('extra', [])]; + }; + $profile = self::profile([self::withMirrors()], $extra); + + $clean = new CompatibilityCheck($profile, self::check($profile)); + $broken = new CompatibilityCheck($profile, self::check($profile, ['Fake: f()' => static fn (): bool => false])); + + self::assertSame(['manifest', 'mirrors', 'extra'], self::keys($clean)); + self::assertSame(['manifest', 'mirrors', 'extra'], self::keys($broken)); + self::assertSame([true, false], $seen); + } + + public function testAThrowingManifestCheckIsARedStep(): void + { + $profile = self::profile([new Manifest('Fake')]); + $manifests = Mockery::mock(ManifestCheck::class); + $manifests->allows('codeChecks')->andThrow(new RuntimeException('kaputt')); + + $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $log); + try { + $report = (new CompatibilityCheck($profile, $manifests))->run(7); + $logged = (string) file_get_contents($log); + } finally { + ini_set('error_log', (string) $previous); + unlink($log); + } + + self::assertSame( + ['Prüfschritt abgebrochen (Details im PHP-Error-Log).'], + $report->step(CheckStep::MANIFEST)?->problems + ); + self::assertStringContainsString('kaputt', $logged, 'the exception text goes to the error log'); + } + + public function testForProfileUsesTheManifestCheckOfTheProfile(): void + { + $profile = new Profile( + id: 'fake', + label: 'Fake', + tier: Tier::ReportOnly, + capabilities: [FakeCapability::Read], + manifestCheck: static fn (): ManifestCheck => new ManifestCheck( + [new Manifest('Fake', [new \LIT\MCP\Compatibility\Call('lit_mcp_missing_fn', 0)])], + signatures: false + ) + ); + + $report = CompatibilityCheck::forProfile($profile)->run(1); + + self::assertSame(['Fake: lit_mcp_missing_fn()'], $report->step(CheckStep::MANIFEST)?->problems); + } + + public function testCompatibilityChecksAProfileWithoutManifestCheckClosureByItsManifests(): void + { + $profile = self::profile([new Manifest('Fake', [new \LIT\MCP\Compatibility\Call('lit_mcp_missing_fn', 0)])]); + + $report = (new Compatibility([$profile]))->check($profile, 3); + + self::assertSame(3, $report->userId); + self::assertSame(['Fake: lit_mcp_missing_fn()'], $report->step(CheckStep::MANIFEST)?->problems); + } +} diff --git a/tests/Unit/Compatibility/CompatibilityComponentTest.php b/tests/Unit/Compatibility/CompatibilityComponentTest.php index 7d3590f..2a53e16 100644 --- a/tests/Unit/Compatibility/CompatibilityComponentTest.php +++ b/tests/Unit/Compatibility/CompatibilityComponentTest.php @@ -22,16 +22,16 @@ final class CompatibilityComponentTest extends TestCase private function profile(bool $active, bool $broken = false): Profile { return new Profile( - 'fake', - 'Fake Plugin', - Tier::LockOnFailure, - [FakeCapability::Read, FakeCapability::Write], - [new Manifest( + id: 'fake', + label: 'Fake Plugin', + tier: Tier::LockOnFailure, + capabilities: [FakeCapability::Read, FakeCapability::Write], + manifests: [new Manifest( 'Fake', calls: $broken ? [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])] : [] )], - static fn (): bool => $active, - static fn (): string => '4.2.0' + active: static fn (): bool => $active, + version: static fn (): string => '4.2.0' ); } @@ -85,10 +85,10 @@ final class CompatibilityComponentTest extends TestCase public function testAReleasePerVersionProfileHasItsOwnComponent(): void { $profile = new Profile( - 'rel', - 'Rel', - Tier::ReleasePerVersion, - [FakeCapability::Other], + id: 'rel', + label: 'Rel', + tier: Tier::ReleasePerVersion, + capabilities: [FakeCapability::Other], matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Other]) ); @@ -101,10 +101,10 @@ final class CompatibilityComponentTest extends TestCase { $plain = $this->profile(true); $release = new Profile( - 'rel', - 'Rel', - Tier::ReleasePerVersion, - [FakeCapability::Other], + id: 'rel', + label: 'Rel', + tier: Tier::ReleasePerVersion, + capabilities: [FakeCapability::Other], matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Other]) ); diff --git a/tests/Unit/Compatibility/CompatibilityTest.php b/tests/Unit/Compatibility/CompatibilityTest.php index 6dbccb3..f9e0d18 100644 --- a/tests/Unit/Compatibility/CompatibilityTest.php +++ b/tests/Unit/Compatibility/CompatibilityTest.php @@ -6,6 +6,8 @@ namespace LIT\MCP\Tests\Unit\Compatibility; use Closure; use LIT\MCP\Compatibility\Call; +use LIT\MCP\Compatibility\CheckReport; +use LIT\MCP\Compatibility\CheckStep; use LIT\MCP\Compatibility\Compatibility; use LIT\MCP\Compatibility\CompatibilityMatrix; use LIT\MCP\Compatibility\Manifest; @@ -13,6 +15,7 @@ use LIT\MCP\Compatibility\ManifestCheck; use LIT\MCP\Compatibility\Profile; use LIT\MCP\Compatibility\ReleaseState; use LIT\MCP\Compatibility\Tier; +use LIT\MCP\Plugin; use LIT\MCP\Tests\Support\FakeCapability; use LIT\MCP\Tests\Unit\TestCase; use LogicException; @@ -36,26 +39,26 @@ final class CompatibilityTest extends TestCase Tier $tier = Tier::LockOnFailure ): Profile { return new Profile( - 'fake', - 'Fake Plugin', - $tier, - self::CAPS, - $broken ? [self::brokenManifest()] : [], - $active, - $version, - $minVersion + id: 'fake', + label: 'Fake Plugin', + tier: $tier, + capabilities: self::CAPS, + manifests: $broken ? [self::brokenManifest()] : [], + active: $active, + version: $version, + minVersion: $minVersion ); } public function testReportOnlyNeverLocksNotEvenWhenInactiveOrBroken(): void { $profile = new Profile( - 'fake', - 'Fake', - Tier::ReportOnly, - self::CAPS, - [self::brokenManifest()], - static fn (): bool => false, + id: 'fake', + label: 'Fake', + tier: Tier::ReportOnly, + capabilities: self::CAPS, + manifests: [self::brokenManifest()], + active: static fn (): bool => false, minVersion: '9' ); $compat = new Compatibility([$profile]); @@ -155,10 +158,10 @@ final class CompatibilityTest extends TestCase public function testReleasePerVersionTakesItsMatrixFromTheClosure(): void { $profile = new Profile( - 'fake', - 'Fake', - Tier::ReleasePerVersion, - self::CAPS, + id: 'fake', + label: 'Fake', + tier: Tier::ReleasePerVersion, + capabilities: self::CAPS, matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allIncompatible(self::CAPS, 'gesperrt') ); $compat = new Compatibility([$profile]); @@ -173,10 +176,10 @@ final class CompatibilityTest extends TestCase $matrices = 0; $lock = self::lockProfile(); $release = new Profile( - 'release', - 'Release', - Tier::ReleasePerVersion, - [FakeCapability::Other], + id: 'release', + label: 'Release', + tier: Tier::ReleasePerVersion, + capabilities: [FakeCapability::Other], matrix: static function () use (&$matrices): CompatibilityMatrix { $matrices++; @@ -203,7 +206,7 @@ final class CompatibilityTest extends TestCase public function testLookups(): void { $lock = self::lockProfile(); - $other = new Profile('other', 'Other', Tier::ReportOnly, [FakeCapability::Other]); + $other = new Profile(id: 'other', label: 'Other', tier: Tier::ReportOnly, capabilities: [FakeCapability::Other]); $compat = new Compatibility([$lock, $other]); self::assertSame([$lock, $other], $compat->profiles()); @@ -222,7 +225,7 @@ final class CompatibilityTest extends TestCase new Compatibility([ self::lockProfile(), - new Profile('other', 'Other', Tier::ReportOnly, [FakeCapability::Read]), + new Profile(id: 'other', label: 'Other', tier: Tier::ReportOnly, capabilities: [FakeCapability::Read]), ]); } @@ -230,4 +233,135 @@ final class CompatibilityTest extends TestCase { self::assertFalse((new Compatibility([]))->usable(FakeCapability::Read)); } + + private static function report(bool $green, string $version = '2.1.0', string $stateKey = 'k1'): CheckReport + { + return new CheckReport('r1', 'd', 1, $version, Plugin::VERSION, $stateKey, [ + new CheckStep('probe', $green ? [] : ['Schreibprobe fehlgeschlagen']), + ]); + } + + private static function reported(?CheckReport $report, bool $withStateKey = true): Compatibility + { + $profile = new Profile( + id: 'fake', + label: 'Fake Plugin', + tier: Tier::LockOnFailure, + capabilities: self::CAPS, + version: static fn (): ?string => '2.1.0', + stateKey: $withStateKey ? static fn (string $version): string => 'k1' : null + ); + + return new Compatibility([$profile], report: static fn (Profile $p): ?CheckReport => $report); + } + + public function testARedReportOfTheCurrentStateLocksEveryCapability(): void + { + $compat = self::reported(self::report(false)); + + self::assertSame(ReleaseState::Incompatible, $compat->matrix($compat->profile('fake'))->state()); + self::assertNotNull($compat->lockOf(self::CAPS)); + $reason = implode(' ', $compat->problems($compat->profile('fake'))); + self::assertStringContainsString('(probe)', $reason); + self::assertStringNotContainsString('Schreibprobe fehlgeschlagen', $reason, 'step texts may hold exception text'); + } + + public function testAReportWithoutStepsIsNoResultAndDoesNotLock(): void + { + $empty = new CheckReport('r1', 'd', 1, '2.1.0', Plugin::VERSION, 'k1', []); + + self::assertNull(self::reported($empty)->lockOf(self::CAPS)); + } + + public function testActiveAndVersionRunTheirClosuresOncePerInstance(): void + { + $calls = 0; + $profile = new Profile( + id: 'fake', + label: 'Fake', + tier: Tier::LockOnFailure, + capabilities: self::CAPS, + active: static function () use (&$calls): bool { + $calls++; + + return true; + }, + version: static function () use (&$calls): string { + $calls++; + + return '1.0'; + } + ); + $compat = new Compatibility([$profile]); + + foreach ([1, 2, 3] as $ignored) { + self::assertTrue($compat->active($profile)); + self::assertSame('1.0', $compat->version($profile)); + } + + self::assertSame(2, $calls); + } + + public function testAThrowingActiveClosureIsLoggedOnlyOnce(): void + { + $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $log); + $profile = new Profile( + id: 'fake', + label: 'Fake', + tier: Tier::LockOnFailure, + capabilities: self::CAPS, + active: static fn (): bool => throw new RuntimeException('einmal') + ); + $compat = new Compatibility([$profile]); + + try { + $compat->active($profile); + $compat->active($profile); + $compat->lockOf(self::CAPS); + $logged = (string) file_get_contents($log); + } finally { + ini_set('error_log', (string) $previous); + unlink($log); + } + + self::assertSame(1, substr_count($logged, 'einmal')); + } + + public function testAnOtherStateOrAGreenReportOrNoneDoesNotLock(): void + { + foreach ([self::report(false, stateKey: 'old'), self::report(false, version: '2.0.0'), self::report(true), null] as $report) { + self::assertNull(self::reported($report)->lockOf(self::CAPS)); + } + } + + public function testWithoutAStateKeyTheVersionDecides(): void + { + self::assertNotNull(self::reported(self::report(false, stateKey: ''), false)->lockOf(self::CAPS)); + self::assertNull(self::reported(self::report(false, version: '9', stateKey: ''), false)->lockOf(self::CAPS)); + } + + public function testACaughtThrowableIsLogged(): void + { + $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $log); + $profile = new Profile( + id: 'fake', + label: 'Fake', + tier: Tier::LockOnFailure, + capabilities: self::CAPS, + active: static fn (): bool => throw new RuntimeException('geheim-xyz') + ); + + try { + self::assertFalse((new Compatibility([$profile]))->active($profile)); + $logged = (string) file_get_contents($log); + } finally { + ini_set('error_log', (string) $previous); + unlink($log); + } + + self::assertStringContainsString('compat fake failed', $logged); + self::assertStringContainsString('geheim-xyz', $logged); + } } diff --git a/tests/Unit/Compatibility/ProfileTest.php b/tests/Unit/Compatibility/ProfileTest.php index 53194b1..56862bb 100644 --- a/tests/Unit/Compatibility/ProfileTest.php +++ b/tests/Unit/Compatibility/ProfileTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Compatibility; use LIT\MCP\Compatibility\CompatibilityMatrix; +use LIT\MCP\Compatibility\ManifestCheck; use LIT\MCP\Compatibility\Profile; use LIT\MCP\Compatibility\Tier; use LIT\MCP\Tests\Support\FakeCapability; @@ -17,10 +18,10 @@ final class ProfileTest extends TestCase { $matrix = static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Read]); $profile = new Profile( - 'fake', - 'Fake Plugin', - Tier::ReleasePerVersion, - [FakeCapability::Read], + id: 'fake', + label: 'Fake Plugin', + tier: Tier::ReleasePerVersion, + capabilities: [FakeCapability::Read], matrix: $matrix, minVersion: '2.0' ); @@ -34,12 +35,54 @@ final class ProfileTest extends TestCase self::assertSame([], $profile->manifests); } + public function testTheStoredVersionKeyDefaultsToVersionAndIsKeptWhenGiven(): void + { + $plain = new Profile(id: 'fake', label: 'Fake', tier: Tier::ReportOnly, capabilities: [FakeCapability::Read]); + $divi = new Profile( + id: 'fake', + label: 'Fake', + tier: Tier::ReportOnly, + capabilities: [FakeCapability::Read], + reportVersionKey: 'divi_version' + ); + + self::assertSame('version', $plain->reportVersionKey); + self::assertSame('divi_version', $divi->reportVersionKey); + } + + public function testTheReleaseFieldsAreOptionalAndKeptWhenGiven(): void + { + $bare = new Profile(id: 'fake', label: 'Fake', tier: Tier::ReportOnly, capabilities: [FakeCapability::Read]); + self::assertSame([], $bare->testedVersions); + self::assertNull($bare->stateKey); + self::assertNull($bare->extraSteps); + self::assertNull($bare->manifestCheck); + + $stateKey = static fn (string $version): string => 'k' . $version; + $extraSteps = static fn (bool $manifestClean): array => []; + $manifestCheck = static fn (): ManifestCheck => new ManifestCheck([], signatures: false); + $full = new Profile( + id: 'fake', + label: 'Fake', + tier: Tier::ReportOnly, + capabilities: [FakeCapability::Read], + testedVersions: ['1.0'], + stateKey: $stateKey, + extraSteps: $extraSteps, + manifestCheck: $manifestCheck + ); + self::assertSame(['1.0'], $full->testedVersions); + self::assertSame($stateKey, $full->stateKey); + self::assertSame($extraSteps, $full->extraSteps); + self::assertSame($manifestCheck, $full->manifestCheck); + } + public function testAReleasePerVersionProfileWithoutMatrixIsAProgrammingError(): void { $this->expectException(LogicException::class); $this->expectExceptionMessage('needs a matrix closure'); - new Profile('fake', 'Fake', Tier::ReleasePerVersion, [FakeCapability::Read]); + new Profile(id: 'fake', label: 'Fake', tier: Tier::ReleasePerVersion, capabilities: [FakeCapability::Read]); } public function testCapabilityIdsMustBeUniqueWithinAProfile(): void @@ -47,7 +90,7 @@ final class ProfileTest extends TestCase $this->expectException(LogicException::class); $this->expectExceptionMessage('fake-read'); - new Profile('fake', 'Fake', Tier::ReportOnly, [FakeCapability::Read, FakeCapability::Read]); + new Profile(id: 'fake', label: 'Fake', tier: Tier::ReportOnly, capabilities: [FakeCapability::Read, FakeCapability::Read]); } public function testTheTiersHaveStableValues(): void diff --git a/tests/Unit/Compatibility/ReleaseStoreTest.php b/tests/Unit/Compatibility/ReleaseStoreTest.php new file mode 100644 index 0000000..060a2a9 --- /dev/null +++ b/tests/Unit/Compatibility/ReleaseStoreTest.php @@ -0,0 +1,89 @@ + */ + private array $options = []; + + protected function setUp(): void + { + parent::setUp(); + $this->options = []; + Functions\when('get_option')->alias( + fn (string $name, mixed $default = false): mixed => $this->options[$name] ?? $default + ); + Functions\when('update_option')->alias(function (string $name, mixed $value, mixed $autoload = null): bool { + $this->options[$name] = $value; + self::assertFalse($autoload, 'releases are read only by the gate – no autoload'); + + return true; + }); + } + + public function testAddsFindsAndWithdrawsPerVersionAndPluginVersion(): void + { + $store = new ReleaseStore('divi', 'divi_version'); + $store->add('5.3.0', '0.6.0', 1, 'r1'); + $store->add('5.3.0', '0.6.0', 2, 'r2'); + + self::assertCount(1, $store->entries(), 'one entry per version pair'); + self::assertSame(2, $store->find('5.3.0', '0.6.0')['user_id'] ?? null); + self::assertNull($store->find('5.3.0', '0.7.0'), 'another plugin version needs its own check'); + self::assertTrue($store->withdraw('5.3.0', '0.6.0')); + self::assertFalse($store->withdraw('5.3.0', '0.6.0')); + self::assertSame([], $store->entries()); + } + + public function testDiviKeepsOptionAndFormat(): void + { + $this->options['lit_mcp_divi_releases'] = [[ + 'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3, + 'released_at' => '2026-10-01T08:00:00+00:00', 'report_id' => 'r', + ]]; + $store = new ReleaseStore('divi', 'divi_version'); + + self::assertSame('r', $store->find('5.2.2', '0.6.0')['report_id'] ?? null); + + $store->add('5.3.0', '0.6.0', 1, 'r1'); + $written = $this->options['lit_mcp_divi_releases']; + self::assertSame(['lit_mcp_divi_releases'], array_keys($this->options)); + self::assertSame( + ['divi_version', 'plugin_version', 'user_id', 'released_at', 'report_id'], + array_keys($written[1]) + ); + self::assertSame($this->options['lit_mcp_divi_releases'][0], [ + 'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3, + 'released_at' => '2026-10-01T08:00:00+00:00', 'report_id' => 'r', + ]); + } + + public function testAnotherProfileGetsItsOwnOptionAndTheVersionKey(): void + { + $store = new ReleaseStore('pods'); + $store->add('3.3.9.2', '0.6.0', 1, 'r1'); + + self::assertSame(['lit_mcp_pods_releases'], array_keys($this->options)); + self::assertSame('3.3.9.2', $this->options['lit_mcp_pods_releases'][0]['version']); + self::assertSame('r1', $store->find('3.3.9.2', '0.6.0')['report_id'] ?? null); + self::assertSame([], (new ReleaseStore('divi', 'divi_version'))->entries()); + } + + public function testIgnoresBrokenRows(): void + { + $this->options['lit_mcp_divi_releases'] = ['x', ['divi_version' => '5.3.0'], [ + 'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3, + 'released_at' => 'd', 'report_id' => 'r', + ]]; + + self::assertSame(['5.2.2'], array_column((new ReleaseStore('divi', 'divi_version'))->entries(), 'divi_version')); + self::assertSame([], (new ReleaseStore('divi'))->entries(), 'rows hold divi_version, not version'); + } +} diff --git a/tests/Unit/Integrations/Divi/Admin/DiviTabTest.php b/tests/Unit/Integrations/Divi/Admin/DiviTabTest.php index 398daef..299b4b9 100644 --- a/tests/Unit/Integrations/Divi/Admin/DiviTabTest.php +++ b/tests/Unit/Integrations/Divi/Admin/DiviTabTest.php @@ -9,15 +9,16 @@ use Brain\Monkey\Functions; use LIT\MCP\Admin\AdminPostAction; use LIT\MCP\Compatibility\ReleaseState; use LIT\MCP\Integrations\Divi\Admin\DiviTab; -use LIT\MCP\Integrations\Divi\Release\CheckReport; -use LIT\MCP\Integrations\Divi\Release\CheckReportStore; -use LIT\MCP\Integrations\Divi\Release\CheckStep; +use LIT\MCP\Compatibility\CheckReport; +use LIT\MCP\Compatibility\CheckReportStore; +use LIT\MCP\Compatibility\CheckStep; +use LIT\MCP\Integrations\Divi\Release\DiviCheckSteps; use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\FeatureOverview; use LIT\MCP\Compatibility\FeatureStatus; use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; -use LIT\MCP\Integrations\Divi\Release\ReleaseStore; +use LIT\MCP\Compatibility\ReleaseStore; use LIT\MCP\Integrations\Divi\Release\WriteProbe; use LIT\MCP\Plugin; use LIT\MCP\Tests\Support\FakeChangeLog; @@ -63,7 +64,7 @@ final class DiviTabTest extends TestCase { $steps = array_map( static fn (string $step): CheckStep => new CheckStep($step, $green ? [] : ['rot']), - [CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE] + [CheckStep::MANIFEST, CheckStep::MIRRORS, DiviCheckSteps::CATALOG, DiviCheckSteps::FORMAT, DiviCheckSteps::WRITE] ); return new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, $version, Plugin::VERSION, $key, $steps); diff --git a/tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php b/tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php index d0b12c9..341c2de 100644 --- a/tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php +++ b/tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php @@ -7,10 +7,11 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Cli; use Brain\Monkey\Functions; use LIT\MCP\Compatibility\ReleaseState; use LIT\MCP\Integrations\Divi\Cli\DiviCheckCommand; -use LIT\MCP\Integrations\Divi\Release\CheckReport; -use LIT\MCP\Integrations\Divi\Release\CheckReportStore; -use LIT\MCP\Integrations\Divi\Release\CheckStep; -use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck; +use LIT\MCP\Compatibility\CheckReport; +use LIT\MCP\Compatibility\CheckReportStore; +use LIT\MCP\Compatibility\CheckStep; +use LIT\MCP\Integrations\Divi\Release\DiviCheckSteps; +use LIT\MCP\Compatibility\CompatibilityCheck; use LIT\MCP\Compatibility\CompatibilityMatrix; use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviReference; @@ -74,7 +75,7 @@ final class DiviCheckCommandTest extends TestCase ): DiviCheckCommand { $steps = array_map( static fn (string $key): CheckStep => new CheckStep($key, $problems[$key] ?? []), - [CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE] + [CheckStep::MANIFEST, CheckStep::MIRRORS, DiviCheckSteps::CATALOG, DiviCheckSteps::FORMAT, DiviCheckSteps::WRITE] ); $check = Mockery::mock(CompatibilityCheck::class); $check->allows('run')->andReturn(new CheckReport('r1', 'd', 1, '5.3.0', '0.6.0', 'k', $steps)); @@ -121,14 +122,14 @@ final class DiviCheckCommandTest extends TestCase public function testFailsWhenAStepIsRed(): void { - self::assertSame(1, $this->command([CheckStep::CATALOG => ['divi/text: unbekanntes Modul']])->run([])); + self::assertSame(1, $this->command([DiviCheckSteps::CATALOG => ['divi/text: unbekanntes Modul']])->run([])); self::assertContains('✗ Modulkatalog (Kurzform-Pfade)', $this->lines); self::assertContains(' divi/text: unbekanntes Modul', $this->lines); } public function testUpdateReferenceRefusesWhenMoreThanFingerprintsIsRed(): void { - $code = $this->command([CheckStep::FORMAT => ['kaputt']])->run(['update-reference' => true]); + $code = $this->command([DiviCheckSteps::FORMAT => ['kaputt']])->run(['update-reference' => true]); self::assertSame(1, $code); self::assertFileDoesNotExist($this->expectedFile); @@ -324,7 +325,7 @@ final class DiviCheckCommandTest extends TestCase public function testARedReportKeepsExitOneWhateverTheMatrixSays(): void { - $red = [CheckStep::CATALOG => ['divi/text: unbekanntes Modul']]; + $red = [DiviCheckSteps::CATALOG => ['divi/text: unbekanntes Modul']]; $incompatible = MatrixFixtures::with(DiviFeature::Catalog, ReleaseState::Incompatible, 'auto', 'x'); self::assertSame(1, $this->command($red)->run([])); diff --git a/tests/Unit/Integrations/Divi/Release/CheckReportTest.php b/tests/Unit/Integrations/Divi/Release/CheckReportTest.php deleted file mode 100644 index 959034b..0000000 --- a/tests/Unit/Integrations/Divi/Release/CheckReportTest.php +++ /dev/null @@ -1,61 +0,0 @@ - new CheckStep($key, []), - [CheckStep::MANIFEST, CheckStep::MIRRORS, CheckStep::CATALOG, CheckStep::FORMAT, CheckStep::WRITE] - ); - } - - public function testGreenOnlyWithEveryStepGreenAndNoneSkipped(): void - { - self::assertTrue(self::report(self::green())->green()); - self::assertFalse(self::report([])->green()); - - $red = self::green(); - $red[2] = new CheckStep(CheckStep::CATALOG, ['divi/text: unbekanntes Modul']); - self::assertFalse(self::report($red)->green()); - - $skipped = self::green(); - $skipped[4] = new CheckStep(CheckStep::WRITE, [], true); - self::assertFalse(self::report($skipped)->green()); - } - - public function testFreshOnlyForTheSameVersionsAndStateKey(): void - { - $report = self::report(self::green()); - - self::assertTrue($report->freshFor('5.3.0', '0.6.0', 'key')); - self::assertFalse($report->freshFor('5.3.1', '0.6.0', 'key'), 'Divi updated'); - self::assertFalse($report->freshFor('5.3.0', '0.7.0', 'key'), 'plugin updated'); - self::assertFalse($report->freshFor('5.3.0', '0.6.0', 'other'), 'a Divi file changed'); - } - - public function testRoundTripsThroughAnArray(): void - { - $steps = self::green(); - $steps[1] = new CheckStep(CheckStep::MIRRORS, ['Theme Builder: Nachbau von x() – y']); - $report = self::report($steps); - - self::assertEquals($report, CheckReport::fromArray($report->toArray())); - self::assertNull(CheckReport::fromArray('broken')); - self::assertNull(CheckReport::fromArray(['id' => 'r1'])); - self::assertSame('Fingerabdrücke nachgebauter Divi-Funktionen', $report->step(CheckStep::MIRRORS)?->label()); - } -} diff --git a/tests/Unit/Integrations/Divi/Release/CompatibilityCheckTest.php b/tests/Unit/Integrations/Divi/Release/CompatibilityCheckTest.php deleted file mode 100644 index 12bf27a..0000000 --- a/tests/Unit/Integrations/Divi/Release/CompatibilityCheckTest.php +++ /dev/null @@ -1,92 +0,0 @@ -justReturn('uuid-1'); - $manifests = Mockery::mock(ManifestCheck::class); - $manifests->allows('codeChecks')->andReturn($code); - $manifests->allows('registrationChecks')->andReturn([]); - $manifests->allows('mirrorChecks')->andReturn($mirrors); - $format = Mockery::mock(FormatCheck::class); - $format->allows('problems')->andReturn([]); - if ($probe === null) { - $probe = Mockery::mock(WriteProbe::class); - $probe->allows('run')->andReturn([]); - } - - return new CompatibilityCheck( - $manifests, - $catalog ?? static fn (): array => [], - $format, - $probe, - static fn (): string => '5.3.0', - static fn (string $version): string => 'key-' . $version - ); - } - - public function testAGreenRunNamesVersionsAndStateKey(): void - { - $report = $this->check([], [])->run(7); - - self::assertTrue($report->green()); - self::assertSame(['uuid-1', 7, '5.3.0', Plugin::VERSION, 'key-5.3.0'], [ - $report->id, $report->userId, $report->diviVersion, $report->pluginVersion, $report->stateKey, - ]); - self::assertSame( - ['manifest', 'mirrors', 'catalog', 'format', 'write'], - array_map(static fn (CheckStep $step): string => $step->key, $report->steps) - ); - } - - public function testNoWriteTestWhenDiviFunctionsAreMissing(): void - { - $probe = Mockery::mock(WriteProbe::class); - $probe->expects('run')->never(); - - $report = $this->check(['Seiten: et_x()' => static fn (): bool => false], [], $probe)->run(7); - - self::assertSame(['Seiten: et_x()'], $report->step(CheckStep::MANIFEST)?->problems); - self::assertTrue($report->step(CheckStep::WRITE)?->skipped); - } - - public function testChangedFingerprintsStillRunTheWriteTest(): void - { - $probe = Mockery::mock(WriteProbe::class); - $probe->expects('run')->once()->andReturn([]); - - $report = $this->check([], ['TB: Nachbau von y()' => static fn (): bool => false], $probe)->run(7); - - self::assertFalse($report->green()); - self::assertTrue($report->step(CheckStep::WRITE)?->green()); - } - - public function testAThrowingStepIsARedStep(): void - { - $report = $this->check([], [], null, static function (): never { - throw new RuntimeException('Katalog weg'); - })->run(7); - - self::assertSame(['Prüfschritt abgebrochen: Katalog weg'], $report->step(CheckStep::CATALOG)?->problems); - } -} diff --git a/tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php b/tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php index 56c45ad..aa89c3a 100644 --- a/tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php +++ b/tests/Unit/Integrations/Divi/Release/DiviCheckRouteTest.php @@ -5,9 +5,9 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release; use Brain\Monkey\Functions; -use LIT\MCP\Integrations\Divi\Release\CheckReport; -use LIT\MCP\Integrations\Divi\Release\CheckReportStore; -use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck; +use LIT\MCP\Compatibility\CheckReport; +use LIT\MCP\Compatibility\CheckReportStore; +use LIT\MCP\Compatibility\CompatibilityCheck; use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; use LIT\MCP\Tests\Unit\TestCase; use Mockery; diff --git a/tests/Unit/Integrations/Divi/Release/DiviCheckStepsTest.php b/tests/Unit/Integrations/Divi/Release/DiviCheckStepsTest.php new file mode 100644 index 0000000..d08c04f --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/DiviCheckStepsTest.php @@ -0,0 +1,124 @@ +key); + self::assertSame(['x'], $step->problems); + self::assertSame('Modulkatalog (Kurzform-Pfade)', $step->label()); + self::assertTrue(DiviCheckSteps::step(DiviCheckSteps::WRITE, [], true)->skipped); + } + + public function testTheLabelOfAnOldReportStepComesFromTheKey(): void + { + self::assertSame( + 'Schreibtest auf einer Wegwerfseite', + DiviCheckSteps::label(new CheckStep(DiviCheckSteps::WRITE, [])) + ); + self::assertSame( + 'Divi-Funktionen, Klassen und Registrierungen', + DiviCheckSteps::label(new CheckStep(CheckStep::MANIFEST, [])) + ); + self::assertSame('stored', DiviCheckSteps::label(new CheckStep('catalog', [], false, 'stored'))); + self::assertSame('other', DiviCheckSteps::label(new CheckStep('other', []))); + } + + /** @param array $code */ + private function check(array $code, array $mirrors, ?Closure $probe = null, ?Closure $catalog = null): CompatibilityCheck + { + Functions\when('wp_generate_uuid4')->justReturn('uuid-1'); + $manifests = Mockery::mock(ManifestCheck::class); + $manifests->allows('codeChecks')->andReturn($code); + $manifests->allows('registrationChecks')->andReturn([]); + $manifests->allows('mirrorChecks')->andReturn($mirrors); + $format = Mockery::mock(FormatCheck::class); + $format->allows('problems')->andReturn([]); + $profile = new Profile( + id: 'divi', + label: 'Divi', + tier: Tier::ReleasePerVersion, + capabilities: DiviFeature::cases(), + manifests: [new Manifest('Seiten', mirrors: [new Mirror('m', 'f.php', 'fn', null, 'x')])], + version: static fn (): ?string => '5.3.0', + matrix: static fn () => throw new RuntimeException('unused'), + stateKey: static fn (string $version): string => 'key-' . $version, + extraSteps: DiviCheckSteps::extra( + $catalog ?? static fn (): array => [], + $format, + $probe ?? static fn (): array => [] + ) + ); + + return new CompatibilityCheck($profile, $manifests); + } + + public function testTheDiviStepOrderIsUnchanged(): void + { + $report = $this->check([], [])->run(7); + + self::assertTrue($report->green()); + self::assertSame( + ['manifest', 'mirrors', 'catalog', 'format', 'write'], + array_map(static fn (CheckStep $step): string => $step->key, $report->steps) + ); + self::assertSame('Schreibtest auf einer Wegwerfseite', $report->step('write')?->label()); + self::assertSame('Divi-Funktionen, Klassen und Registrierungen', $report->step('manifest')?->label()); + self::assertSame('key-5.3.0', $report->stateKey); + } + + public function testNoWriteTestWhenDiviFunctionsAreMissing(): void + { + $ran = false; + $probe = static function () use (&$ran): array { + $ran = true; + + return []; + }; + + $report = $this->check(['Seiten: et_x()' => static fn (): bool => false], [], $probe)->run(7); + + self::assertSame(['Seiten: et_x()'], $report->step(CheckStep::MANIFEST)?->problems); + self::assertTrue($report->step(DiviCheckSteps::WRITE)?->skipped); + self::assertFalse($ran); + } + + public function testChangedFingerprintsStillRunTheWriteTest(): void + { + $report = $this->check([], ['TB: Nachbau von y()' => static fn (): bool => false])->run(7); + + self::assertFalse($report->green()); + self::assertTrue($report->step(DiviCheckSteps::WRITE)?->green()); + } + + public function testAThrowingStepIsARedStep(): void + { + $report = $this->check([], [], null, static function (): never { + throw new RuntimeException('Katalog weg'); + })->run(7); + + self::assertSame(['Prüfschritt abgebrochen (Details im PHP-Error-Log).'], $report->step(DiviCheckSteps::CATALOG)?->problems); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/DiviProfileTest.php b/tests/Unit/Integrations/Divi/Release/DiviProfileTest.php index bc143b5..b1e87f8 100644 --- a/tests/Unit/Integrations/Divi/Release/DiviProfileTest.php +++ b/tests/Unit/Integrations/Divi/Release/DiviProfileTest.php @@ -5,12 +5,15 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release; use Brain\Monkey\Functions; +use LIT\MCP\Compatibility\ManifestCheck; use LIT\MCP\Compatibility\ReleaseState; use LIT\MCP\Compatibility\Tier; use LIT\MCP\Integrations\Divi\Release\DiviAdapters; use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\DiviProfile; +use LIT\MCP\Integrations\Divi\Release\DiviReleases; use LIT\MCP\Integrations\Divi\Release\ReleaseGate; +use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; use LIT\MCP\Tests\Support\ReleaseMatrices; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -32,6 +35,9 @@ final class DiviProfileTest extends TestCase self::assertSame('divi', $profile->id); self::assertSame('Divi', $profile->label); + self::assertSame('divi_version', $profile->reportVersionKey, 'stored Divi data keeps its key'); + self::assertSame(DiviProfile::ID, $profile->id); + self::assertSame(DiviProfile::REPORT_VERSION_KEY, $profile->reportVersionKey); self::assertSame(Tier::ReleasePerVersion, $profile->tier); self::assertSame(DiviFeature::cases(), $profile->capabilities); self::assertEquals(array_values(DiviAdapters::manifests()), $profile->manifests); @@ -45,14 +51,25 @@ final class DiviProfileTest extends TestCase self::assertSame($status->matrix, (DiviProfile::profile()->matrix)()); } - public function testTheVersionComesFromTheDiviTemplateAndThereIsNoActiveCheck(): void + public function testTheVersionComesFromTheDiviTemplateAndActiveMeansDiviIsInstalled(): void { $this->theme('Divi', '5.3.0'); $profile = DiviProfile::profile(); - self::assertNull($profile->active, 'Divi has its own component; DiviIntegration decides availability'); + self::assertTrue(($profile->active)()); self::assertSame('5.3.0', ($profile->version)()); $this->theme('twentytwentyfive'); + self::assertFalse(($profile->active)()); self::assertNull(($profile->version)()); } + + public function testCarriesTestedVersionsStateKeyAndManifestCheck(): void + { + $this->theme('twentytwentyfive'); + $profile = DiviProfile::profile(); + + self::assertSame(DiviReleases::TESTED, $profile->testedVersions); + self::assertSame(ReleaseResolver::stateKey('5.3.0'), ($profile->stateKey)('5.3.0')); + self::assertInstanceOf(ManifestCheck::class, ($profile->manifestCheck)()); + } } diff --git a/tests/Unit/Integrations/Divi/Release/ReleaseResolverTest.php b/tests/Unit/Integrations/Divi/Release/ReleaseResolverTest.php index 5ebbc4c..fa35b12 100644 --- a/tests/Unit/Integrations/Divi/Release/ReleaseResolverTest.php +++ b/tests/Unit/Integrations/Divi/Release/ReleaseResolverTest.php @@ -12,7 +12,7 @@ use LIT\MCP\Compatibility\ManifestCheck; use LIT\MCP\Compatibility\Problem; use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; -use LIT\MCP\Integrations\Divi\Release\ReleaseStore; +use LIT\MCP\Compatibility\ReleaseStore; use LIT\MCP\Plugin; use LIT\MCP\Tests\Unit\TestCase; use Mockery; diff --git a/tests/Unit/Integrations/Divi/Release/ReleaseStoreTest.php b/tests/Unit/Integrations/Divi/Release/ReleaseStoreTest.php deleted file mode 100644 index 7972b54..0000000 --- a/tests/Unit/Integrations/Divi/Release/ReleaseStoreTest.php +++ /dev/null @@ -1,54 +0,0 @@ - */ - private array $options = []; - - protected function setUp(): void - { - parent::setUp(); - $this->options = []; - Functions\when('get_option')->alias( - fn (string $name, mixed $default = false): mixed => $this->options[$name] ?? $default - ); - Functions\when('update_option')->alias(function (string $name, mixed $value, mixed $autoload = null): bool { - $this->options[$name] = $value; - self::assertFalse($autoload, 'releases are read only by the gate – no autoload'); - - return true; - }); - } - - public function testAddsFindsAndWithdrawsPerDiviAndPluginVersion(): void - { - $store = new ReleaseStore(); - $store->add('5.3.0', '0.6.0', 1, 'r1'); - $store->add('5.3.0', '0.6.0', 2, 'r2'); - - self::assertCount(1, $store->entries(), 'one entry per version pair'); - self::assertSame(2, $store->find('5.3.0', '0.6.0')['user_id'] ?? null); - self::assertNull($store->find('5.3.0', '0.7.0'), 'another plugin version needs its own check'); - self::assertTrue($store->withdraw('5.3.0', '0.6.0')); - self::assertFalse($store->withdraw('5.3.0', '0.6.0')); - self::assertSame([], $store->entries()); - } - - public function testIgnoresBrokenRows(): void - { - $this->options[ReleaseStore::OPTION] = ['x', ['divi_version' => '5.3.0'], [ - 'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3, - 'released_at' => 'd', 'report_id' => 'r', - ]]; - - self::assertSame(['5.2.2'], array_column((new ReleaseStore())->entries(), 'divi_version')); - } -} diff --git a/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php b/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php index 8e23214..16ed2b7 100644 --- a/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php +++ b/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php @@ -97,9 +97,18 @@ final class WriteProbeTest extends TestCase throw new RuntimeException('Composer kaputt'); }); - $problems = (new WriteProbe($composer, $this->renderer(''), '/nonexistent-divi'))->run(); + $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $log); + try { + $problems = (new WriteProbe($composer, $this->renderer(''), '/nonexistent-divi'))->run(); + $logged = (string) file_get_contents($log); + } finally { + ini_set('error_log', (string) $previous); + unlink($log); + } - self::assertSame(['Schreibtest abgebrochen: Composer kaputt'], $problems); + self::assertSame(['Schreibtest abgebrochen (Details im PHP-Error-Log).'], $problems); + self::assertStringContainsString('Composer kaputt', $logged); self::assertSame([41], $this->deleted); $this->expectException(\LIT\MCP\Integrations\Divi\Release\DiviLocked::class); ReleaseGate::assertFeature(DiviFeature::PageWrite, 41); diff --git a/tests/Unit/Integrations/IntegrationRegistryTest.php b/tests/Unit/Integrations/IntegrationRegistryTest.php index c2d0682..f98c57c 100644 --- a/tests/Unit/Integrations/IntegrationRegistryTest.php +++ b/tests/Unit/Integrations/IntegrationRegistryTest.php @@ -112,8 +112,8 @@ final class IntegrationRegistryTest extends TestCase public function testCompatibilitiesOfAllProvidersInRegistryOrderEvenUnavailable(): void { - $first = new Profile('a', 'A', Tier::ReportOnly, []); - $second = new Profile('b', 'B', Tier::ReportOnly, []); + $first = new Profile(id: 'a', label: 'A', tier: Tier::ReportOnly, capabilities: []); + $second = new Profile(id: 'b', label: 'B', tier: Tier::ReportOnly, capabilities: []); $provider = static function (string $id, bool $available, array $profiles): Integration { $integration = Mockery::mock(Integration::class . ', ' . ProvidesCompatibility::class); $integration->allows('id')->andReturn($id); diff --git a/tests/Unit/Integrations/Pods/PodsCompatibilityTest.php b/tests/Unit/Integrations/Pods/PodsCompatibilityTest.php index 8ca7146..cf3a075 100644 --- a/tests/Unit/Integrations/Pods/PodsCompatibilityTest.php +++ b/tests/Unit/Integrations/Pods/PodsCompatibilityTest.php @@ -35,6 +35,7 @@ final class PodsCompatibilityTest extends TestCase self::assertSame('Pods', $profile->label); self::assertSame(Tier::LockOnFailure, $profile->tier); self::assertSame('2.8', $profile->minVersion); + self::assertSame(['3.3.9.2'], $profile->testedVersions); self::assertSame(PodsFeature::cases(), $profile->capabilities); } diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php index e767f4f..0bcfa02 100644 --- a/tests/Unit/PluginTest.php +++ b/tests/Unit/PluginTest.php @@ -145,10 +145,10 @@ final class PluginTest extends TestCase private static function podsCompat(bool $usable): Compatibility { return new Compatibility([new Profile( - 'pods', - 'Pods', - Tier::LockOnFailure, - PodsFeature::cases(), + id: 'pods', + label: 'Pods', + tier: Tier::LockOnFailure, + capabilities: PodsFeature::cases(), active: static fn (): bool => $usable )]); } diff --git a/tests/Unit/UninstallTest.php b/tests/Unit/UninstallTest.php index 1e64ee1..49f09b1 100644 --- a/tests/Unit/UninstallTest.php +++ b/tests/Unit/UninstallTest.php @@ -11,10 +11,8 @@ use LIT\MCP\Access; use LIT\MCP\Admin\ConnectionStatus; use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; -use LIT\MCP\Integrations\Divi\Release\CheckReportStore; use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; -use LIT\MCP\Integrations\Divi\Release\ReleaseStore; use LIT\MCP\Limits; use LIT\MCP\Updater\RecheckOnInstall; use Mockery; @@ -56,7 +54,8 @@ final class UninstallTest extends TestCase $own = [ Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION, - ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK, + 'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', DiviCheckRoute::LOCK, + 'lit_mcp_pods_releases', 'lit_mcp_pods_check_report', 'lit_mcp_pods_check_lock', 'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets', 'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css', DesignLock::option('colors'), DesignLock::option('variables'), DesignLock::option('presets'), DesignLock::option('custom_css'), diff --git a/tests/Unit/UninstallerTest.php b/tests/Unit/UninstallerTest.php index 7e74b07..cbc2570 100644 --- a/tests/Unit/UninstallerTest.php +++ b/tests/Unit/UninstallerTest.php @@ -12,10 +12,8 @@ use LIT\MCP\Admin\ConnectionStatus; use LIT\MCP\Admin\LogTab; use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; -use LIT\MCP\Integrations\Divi\Release\CheckReportStore; use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; -use LIT\MCP\Integrations\Divi\Release\ReleaseStore; use LIT\MCP\Limits; use LIT\MCP\Uninstaller; use LIT\MCP\Updater\RecheckOnInstall; @@ -62,7 +60,8 @@ final class UninstallerTest extends TestCase self::assertSame( [ Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION, - ReleaseStore::OPTION, CheckReportStore::OPTION, DiviCheckRoute::LOCK, + 'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', DiviCheckRoute::LOCK, + 'lit_mcp_pods_releases', 'lit_mcp_pods_check_report', 'lit_mcp_pods_check_lock', 'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets', 'lit_mcp_snapshots_theme_options', 'lit_mcp_snapshots_custom_css', DesignLock::option('colors'), DesignLock::option('variables'), DesignLock::option('presets'), DesignLock::option('custom_css'),