refactor: Divi-Boot-Schritte in DiviIntegration::boot() (Bootable) – Upgrader-Hook, Prüf-Route, CLI und Reiter „Divi“ über lit_mcp_admin_tabs; IntegrationRegistry::boot() bootet jede Integration genau einmal; Prüfseiten-Aufräumen im load-Hook statt beim Rendern
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
06148fb8c1
commit
6c59934caa
+32
-4
@@ -23,7 +23,8 @@ defined('ABSPATH') || exit;
|
|||||||
/**
|
/**
|
||||||
* Tab "Divi" (spec §6): release status of the installed Divi, the latest check report, "Prüfung starten" (REST,
|
* Tab "Divi" (spec §6): release status of the installed Divi, the latest check report, "Prüfung starten" (REST,
|
||||||
* DiviCheckRoute), "Divi <Version> freigeben" (only after a fresh green report) and "Freigabe zurückziehen" (only
|
* DiviCheckRoute), "Divi <Version> freigeben" (only after a fresh green report) and "Freigabe zurückziehen" (only
|
||||||
* for a database release). Opening the tab removes check pages a dead check left behind.
|
* for a database release). Loading the tab removes check pages a dead check left behind – in the page's load hook
|
||||||
|
* (removeOrphans()), not while rendering.
|
||||||
*/
|
*/
|
||||||
final class DiviTab implements AdminTab
|
final class DiviTab implements AdminTab
|
||||||
{
|
{
|
||||||
@@ -31,6 +32,9 @@ final class DiviTab implements AdminTab
|
|||||||
public const RELEASE_ACTION = 'lit_mcp_divi_release';
|
public const RELEASE_ACTION = 'lit_mcp_divi_release';
|
||||||
public const WITHDRAW_ACTION = 'lit_mcp_divi_withdraw';
|
public const WITHDRAW_ACTION = 'lit_mcp_divi_withdraw';
|
||||||
|
|
||||||
|
/** @var list<int> Check pages removeOrphans() deleted in this request; render() reports them. */
|
||||||
|
private array $removed = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param (Closure(): string)|null $version Installed Divi version ('' = none); null = DiviEnvironment.
|
* @param (Closure(): string)|null $version Installed Divi version ('' = none); null = DiviEnvironment.
|
||||||
* @param (Closure(string): string)|null $stateKey null = ReleaseResolver::stateKey().
|
* @param (Closure(string): string)|null $stateKey null = ReleaseResolver::stateKey().
|
||||||
@@ -63,6 +67,22 @@ final class DiviTab implements AdminTab
|
|||||||
{
|
{
|
||||||
add_action('admin_post_' . self::RELEASE_ACTION, [$this, 'release']);
|
add_action('admin_post_' . self::RELEASE_ACTION, [$this, 'release']);
|
||||||
add_action('admin_post_' . self::WITHDRAW_ACTION, [$this, 'withdraw']);
|
add_action('admin_post_' . self::WITHDRAW_ACTION, [$this, 'withdraw']);
|
||||||
|
add_action('load-' . SettingsPage::SCREEN_HOOK, [$this, 'removeOrphans']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* load-<page> of the settings page, before any output: on this tab, for manage_options and with Divi active,
|
||||||
|
* deletes the check pages a dead check left behind (WriteProbe::removeOrphans(): marked drafts with the check
|
||||||
|
* title, older than ten minutes – no running check uses them). On a GET without nonce on purpose: it removes
|
||||||
|
* only the plugin's own leftovers and is idempotent. render() only reports what was deleted.
|
||||||
|
*/
|
||||||
|
public function removeOrphans(): void
|
||||||
|
{
|
||||||
|
if (! $this->requested() || ! current_user_can('manage_options') || $this->version() === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->removed = WriteProbe::removeOrphans();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function release(): void
|
public function release(): void
|
||||||
@@ -127,7 +147,6 @@ final class DiviTab implements AdminTab
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$removed = WriteProbe::removeOrphans();
|
|
||||||
$status = ReleaseGate::status();
|
$status = ReleaseGate::status();
|
||||||
$report = $this->reports()->latest();
|
$report = $this->reports()->latest();
|
||||||
|
|
||||||
@@ -137,13 +156,13 @@ final class DiviTab implements AdminTab
|
|||||||
'LIT MCP schreibt nur mit Divi-Versionen, die geprüft sind. Nach einem Divi-Update hier die Prüfung starten und die Version freigeben; bis dahin lesen die Divi-Werkzeuge nur.',
|
'LIT MCP schreibt nur mit Divi-Versionen, die geprüft sind. Nach einem Divi-Update hier die Prüfung starten und die Version freigeben; bis dahin lesen die Divi-Werkzeuge nur.',
|
||||||
'wp-lit-mcp'
|
'wp-lit-mcp'
|
||||||
) . '</p>';
|
) . '</p>';
|
||||||
if ($removed !== []) {
|
if ($this->removed !== []) {
|
||||||
printf(
|
printf(
|
||||||
'<div class="notice notice-warning inline"><p>%s</p></div>',
|
'<div class="notice notice-warning inline"><p>%s</p></div>',
|
||||||
esc_html(sprintf(
|
esc_html(sprintf(
|
||||||
/* translators: %s: comma separated page IDs */
|
/* translators: %s: comma separated page IDs */
|
||||||
__('Liegengebliebene Prüfseiten gelöscht: %s', 'wp-lit-mcp'),
|
__('Liegengebliebene Prüfseiten gelöscht: %s', 'wp-lit-mcp'),
|
||||||
implode(', ', $removed)
|
implode(', ', $this->removed)
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -305,6 +324,15 @@ final class DiviTab implements AdminTab
|
|||||||
) . '</p>';
|
) . '</p>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether this request opens this tab of the settings page (the load hook fires for every tab). */
|
||||||
|
private function requested(): bool
|
||||||
|
{
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
|
||||||
|
$tab = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : '';
|
||||||
|
|
||||||
|
return $tab === self::SLUG;
|
||||||
|
}
|
||||||
|
|
||||||
private function version(): string
|
private function version(): string
|
||||||
{
|
{
|
||||||
return ($this->version ?? static fn (): string => DiviEnvironment::version() ?? '')();
|
return ($this->version ?? static fn (): string => DiviEnvironment::version() ?? '')();
|
||||||
|
|||||||
+33
-13
@@ -16,24 +16,27 @@ final class SettingsPage
|
|||||||
/** Hook suffix WordPress gives this page (add_options_page → "settings_page_<slug>"). */
|
/** Hook suffix WordPress gives this page (add_options_page → "settings_page_<slug>"). */
|
||||||
public const SCREEN_HOOK = 'settings_page_' . self::SLUG;
|
public const SCREEN_HOOK = 'settings_page_' . self::SLUG;
|
||||||
|
|
||||||
/** @var array<string, AdminTab> */
|
/**
|
||||||
private array $tabs = [];
|
* Filter on the tab list (list<AdminTab>): integrations append their own tabs in Bootable::boot() – Divi its
|
||||||
|
* tab "Divi". Applied once, on first use of the tabs (after Plugin booted the integrations).
|
||||||
|
*/
|
||||||
|
public const TABS_FILTER = 'lit_mcp_admin_tabs';
|
||||||
|
|
||||||
|
/** @var array<string, AdminTab>|null null = TABS_FILTER not applied yet. */
|
||||||
|
private ?array $tabs = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<AdminTab> $tabs The first tab is the default.
|
* @param list<AdminTab> $baseTabs The plugin's own tabs; the first tab is the default.
|
||||||
* @param string $logoUrl Loosen-IT logo shown top right of the page ('' = none).
|
* @param string $logoUrl Loosen-IT logo shown top right of the page ('' = none).
|
||||||
* @param string $assetsUrl URL of the plugin's assets/ directory ('' = no own CSS/JS).
|
* @param string $assetsUrl URL of the plugin's assets/ directory ('' = no own CSS/JS).
|
||||||
* @param string $version Asset version for cache busting.
|
* @param string $version Asset version for cache busting.
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
array $tabs,
|
private array $baseTabs,
|
||||||
private string $logoUrl = '',
|
private string $logoUrl = '',
|
||||||
private string $assetsUrl = '',
|
private string $assetsUrl = '',
|
||||||
private string $version = ''
|
private string $version = ''
|
||||||
) {
|
) {
|
||||||
foreach ($tabs as $tab) {
|
|
||||||
$this->tabs[$tab->slug()] = $tab;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function register(): void
|
public function register(): void
|
||||||
@@ -41,7 +44,7 @@ final class SettingsPage
|
|||||||
add_action('admin_menu', [$this, 'addMenu']);
|
add_action('admin_menu', [$this, 'addMenu']);
|
||||||
add_action('admin_init', [$this, 'registerSettings']);
|
add_action('admin_init', [$this, 'registerSettings']);
|
||||||
add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
|
add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
|
||||||
foreach ($this->tabs as $tab) {
|
foreach ($this->tabs() as $tab) {
|
||||||
$tab->registerActions();
|
$tab->registerActions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +62,7 @@ final class SettingsPage
|
|||||||
|
|
||||||
public function registerSettings(): void
|
public function registerSettings(): void
|
||||||
{
|
{
|
||||||
foreach ($this->tabs as $tab) {
|
foreach ($this->tabs() as $tab) {
|
||||||
$tab->registerSettings();
|
$tab->registerSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +93,7 @@ final class SettingsPage
|
|||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
public function tabSlugs(): array
|
public function tabSlugs(): array
|
||||||
{
|
{
|
||||||
return array_keys($this->tabs);
|
return array_keys($this->tabs());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function currentTab(): string
|
public function currentTab(): string
|
||||||
@@ -98,7 +101,9 @@ final class SettingsPage
|
|||||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
|
||||||
$requested = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : '';
|
$requested = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : '';
|
||||||
|
|
||||||
return isset($this->tabs[$requested]) ? $requested : (string) array_key_first($this->tabs);
|
$tabs = $this->tabs();
|
||||||
|
|
||||||
|
return isset($tabs[$requested]) ? $requested : (string) array_key_first($tabs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, string> $args */
|
/** @param array<string, string> $args */
|
||||||
@@ -123,7 +128,7 @@ final class SettingsPage
|
|||||||
echo '</div><hr class="wp-header-end">';
|
echo '</div><hr class="wp-header-end">';
|
||||||
$this->renderNotice();
|
$this->renderNotice();
|
||||||
printf('<nav class="nav-tab-wrapper wp-clearfix" aria-label="%s">', esc_attr__('Bereiche', 'wp-lit-mcp'));
|
printf('<nav class="nav-tab-wrapper wp-clearfix" aria-label="%s">', esc_attr__('Bereiche', 'wp-lit-mcp'));
|
||||||
foreach ($this->tabs as $slug => $tab) {
|
foreach ($this->tabs() as $slug => $tab) {
|
||||||
printf(
|
printf(
|
||||||
'<a href="%s" class="nav-tab%s"%s>%s</a>',
|
'<a href="%s" class="nav-tab%s"%s>%s</a>',
|
||||||
esc_url(self::url($slug)),
|
esc_url(self::url($slug)),
|
||||||
@@ -133,10 +138,25 @@ final class SettingsPage
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
echo '</nav>';
|
echo '</nav>';
|
||||||
$this->tabs[$current]->render();
|
$this->tabs()[$current]->render();
|
||||||
echo '</div>';
|
echo '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array<string, AdminTab> The plugin's tabs and those the integrations add (TABS_FILTER), by slug. */
|
||||||
|
private function tabs(): array
|
||||||
|
{
|
||||||
|
if ($this->tabs === null) {
|
||||||
|
$this->tabs = [];
|
||||||
|
foreach ((array) apply_filters(self::TABS_FILTER, $this->baseTabs) as $tab) {
|
||||||
|
if ($tab instanceof AdminTab) {
|
||||||
|
$this->tabs[$tab->slug()] = $tab;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->tabs;
|
||||||
|
}
|
||||||
|
|
||||||
private function renderLogo(): void
|
private function renderLogo(): void
|
||||||
{
|
{
|
||||||
if ($this->logoUrl === '') {
|
if ($this->logoUrl === '') {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An integration with boot steps of its own (hooks, REST routes, WP-CLI commands, admin tabs), so the composition
|
||||||
|
* root does not need to know them. IntegrationRegistry::boot() calls boot() whether or not the integration is
|
||||||
|
* available: Plugin boots on plugins_loaded, before the theme – isAvailable() cannot be asked yet.
|
||||||
|
*/
|
||||||
|
interface Bootable
|
||||||
|
{
|
||||||
|
/** Registers the integration's own hooks, routes, CLI commands and admin tabs. Called once by Plugin. */
|
||||||
|
public function boot(): void;
|
||||||
|
}
|
||||||
@@ -5,7 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace LIT\MCP\Integrations\Divi;
|
namespace LIT\MCP\Integrations\Divi;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
|
use LIT\MCP\Admin\DiviTab;
|
||||||
|
use LIT\MCP\Admin\SettingsPage;
|
||||||
use LIT\MCP\ChangeLog\ChangeLog;
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
|
use LIT\MCP\Cli\DiviCheckCommand;
|
||||||
|
use LIT\MCP\Integrations\Bootable;
|
||||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||||||
@@ -47,6 +51,9 @@ use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
|||||||
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
|
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
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\ReleaseResolver;
|
||||||
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
|
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
|
||||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||||
@@ -66,9 +73,9 @@ defined('ABSPATH') || exit;
|
|||||||
* Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded
|
* Divi 5 pages (spec §5, Etappe 2). Only available when the Divi 5 builder is loaded
|
||||||
* (spec §2 principle 4); Divi 4 is never edited (spec §3.4). The write abilities share one
|
* (spec §2 principle 4); Divi 4 is never edited (spec §3.4). The write abilities share one
|
||||||
* DiviWriter; each binds the composition to its entry via DiviWriter::composition().
|
* DiviWriter; each binds the composition to its entry via DiviWriter::composition().
|
||||||
* contentComposer() gives lit/content-create the Divi short form (spec §6.2).
|
* contentComposer() gives lit/content-create the Divi short form (spec §6.2). boot() registers Divi's own hooks.
|
||||||
*/
|
*/
|
||||||
final class DiviIntegration implements Integration
|
final class DiviIntegration implements Integration, Bootable
|
||||||
{
|
{
|
||||||
private DiviModules $modules;
|
private DiviModules $modules;
|
||||||
private DesignSystemReader $design;
|
private DesignSystemReader $design;
|
||||||
@@ -190,6 +197,27 @@ final class DiviIntegration implements Integration
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Divi's own boot steps, registered whether or not Divi is loaded yet (Bootable): a theme update checks the
|
||||||
|
* fingerprints again at once (spec §5.4), the admin check as REST route and WP-CLI command (spec §6, §7) and
|
||||||
|
* the tab "Divi" after the plugin's own tabs.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
// Spec §5.4: a theme update checks the Divi fingerprints again at once (the state key would, too).
|
||||||
|
add_action('upgrader_process_complete', static function (): void {
|
||||||
|
delete_transient(ReleaseResolver::CACHE);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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->contentComposer());
|
||||||
|
(new DiviCheckRoute($check))->register();
|
||||||
|
DiviCheckCommand::register($check);
|
||||||
|
|
||||||
|
add_filter(SettingsPage::TABS_FILTER, fn (array $tabs): array => [...$tabs, new DiviTab(log: $this->log)]);
|
||||||
|
}
|
||||||
|
|
||||||
public function abilities(): array
|
public function abilities(): array
|
||||||
{
|
{
|
||||||
$loader = $this->loader();
|
$loader = $this->loader();
|
||||||
|
|||||||
@@ -16,11 +16,32 @@ final class IntegrationRegistry
|
|||||||
/** @var list<array{Integration, list<Ability>}>|null Available integrations with their abilities. */
|
/** @var list<array{Integration, list<Ability>}>|null Available integrations with their abilities. */
|
||||||
private ?array $loaded = null;
|
private ?array $loaded = null;
|
||||||
|
|
||||||
|
private bool $booted = false;
|
||||||
|
|
||||||
public function __construct(Integration ...$integrations)
|
public function __construct(Integration ...$integrations)
|
||||||
{
|
{
|
||||||
$this->integrations = array_values($integrations);
|
$this->integrations = array_values($integrations);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boots every Bootable integration once, in registry order – available or not: Plugin boots on plugins_loaded,
|
||||||
|
* before the theme (Divi) is loaded, so isAvailable() is not known yet (as the hooks were registered before
|
||||||
|
* Bootable). A second call does nothing.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
if ($this->booted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->booted = true;
|
||||||
|
foreach ($this->integrations as $integration) {
|
||||||
|
if ($integration instanceof Bootable) {
|
||||||
|
$integration->boot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @return list<Integration> */
|
/** @return list<Integration> */
|
||||||
public function available(): array
|
public function available(): array
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-15
@@ -12,7 +12,6 @@ use LIT\MCP\Admin\ConnectionRepository;
|
|||||||
use LIT\MCP\Admin\ConnectionStatus;
|
use LIT\MCP\Admin\ConnectionStatus;
|
||||||
use LIT\MCP\Admin\ConnectionTab;
|
use LIT\MCP\Admin\ConnectionTab;
|
||||||
use LIT\MCP\Admin\ConnectionUsage;
|
use LIT\MCP\Admin\ConnectionUsage;
|
||||||
use LIT\MCP\Admin\DiviTab;
|
|
||||||
use LIT\MCP\Admin\LimitsTab;
|
use LIT\MCP\Admin\LimitsTab;
|
||||||
use LIT\MCP\Admin\LogTab;
|
use LIT\MCP\Admin\LogTab;
|
||||||
use LIT\MCP\Admin\SettingsPage;
|
use LIT\MCP\Admin\SettingsPage;
|
||||||
@@ -21,15 +20,11 @@ use LIT\MCP\Admin\ToolsTab;
|
|||||||
use LIT\MCP\ChangeLog\ChangeLog;
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
use LIT\MCP\ChangeLog\ChangeLogCleanup;
|
use LIT\MCP\ChangeLog\ChangeLogCleanup;
|
||||||
use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
use LIT\MCP\ChangeLog\ChangeLogInstaller;
|
||||||
use LIT\MCP\Cli\DiviCheckCommand;
|
|
||||||
use LIT\MCP\Guides\InstructionsBuilder;
|
use LIT\MCP\Guides\InstructionsBuilder;
|
||||||
use LIT\MCP\Integrations\Core\CoreIntegration;
|
use LIT\MCP\Integrations\Core\CoreIntegration;
|
||||||
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
||||||
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
|
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
|
||||||
use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck;
|
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
|
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
|
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
|
||||||
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
|
|
||||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
|
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
|
||||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||||
use LIT\MCP\Integrations\Media\MediaIntegration;
|
use LIT\MCP\Integrations\Media\MediaIntegration;
|
||||||
@@ -78,11 +73,6 @@ final class Plugin
|
|||||||
(new ChangeLogInstaller($changeLog))->maybeInstall();
|
(new ChangeLogInstaller($changeLog))->maybeInstall();
|
||||||
(new ChangeLogCleanup($changeLog, $limits))->register();
|
(new ChangeLogCleanup($changeLog, $limits))->register();
|
||||||
|
|
||||||
// Spec §5.4: a theme update checks the Divi fingerprints again at once (the state key would, too).
|
|
||||||
add_action('upgrader_process_complete', static function (): void {
|
|
||||||
delete_transient(ReleaseResolver::CACHE);
|
|
||||||
});
|
|
||||||
|
|
||||||
$schema = self::contentSchema();
|
$schema = self::contentSchema();
|
||||||
$registry = null;
|
$registry = null;
|
||||||
$policy = null;
|
$policy = null;
|
||||||
@@ -139,10 +129,9 @@ final class Plugin
|
|||||||
$divi->themeBuilder()
|
$divi->themeBuilder()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Spec §6: the admin check runs as a REST request (same context as MCP calls).
|
// Integration-owned hooks (Divi: upgrader hook, check route, CLI command, tab "Divi") – before the settings
|
||||||
$diviCheck = static fn (): CompatibilityCheck => CompatibilityCheck::create($divi->contentComposer());
|
// page reads its tabs.
|
||||||
(new DiviCheckRoute($diviCheck))->register();
|
$registry->boot();
|
||||||
DiviCheckCommand::register($diviCheck);
|
|
||||||
|
|
||||||
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
|
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
|
||||||
(new AbilityRegistrar(
|
(new AbilityRegistrar(
|
||||||
@@ -167,7 +156,6 @@ final class Plugin
|
|||||||
new ToolsTab($registry, $policy),
|
new ToolsTab($registry, $policy),
|
||||||
new LogTab($changeLog, $limits, $registry),
|
new LogTab($changeLog, $limits, $registry),
|
||||||
new LimitsTab(),
|
new LimitsTab(),
|
||||||
new DiviTab(log: $changeLog),
|
|
||||||
];
|
];
|
||||||
self::$settingsPage = new SettingsPage(
|
self::$settingsPage = new SettingsPage(
|
||||||
$tabs,
|
$tabs,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Tests\Unit\Admin;
|
namespace LIT\MCP\Tests\Unit\Admin;
|
||||||
|
|
||||||
|
use Brain\Monkey\Actions;
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use LIT\MCP\Admin\AdminPostAction;
|
use LIT\MCP\Admin\AdminPostAction;
|
||||||
use LIT\MCP\Admin\DiviTab;
|
use LIT\MCP\Admin\DiviTab;
|
||||||
@@ -14,10 +15,12 @@ use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
|||||||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||||||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||||||
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
|
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\WriteProbe;
|
||||||
use LIT\MCP\Plugin;
|
use LIT\MCP\Plugin;
|
||||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||||
use LIT\MCP\Tests\Unit\TestCase;
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
final class DiviTabTest extends TestCase
|
final class DiviTabTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -41,6 +44,13 @@ final class DiviTabTest extends TestCase
|
|||||||
Functions\when('add_query_arg')->alias(
|
Functions\when('add_query_arg')->alias(
|
||||||
static fn (array $args, string $url): string => $url . '?' . http_build_query($args)
|
static fn (array $args, string $url): string => $url . '?' . http_build_query($args)
|
||||||
);
|
);
|
||||||
|
Functions\when('sanitize_key')->alias(static fn (string $key): string => strtolower($key));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
unset($_GET['tab']);
|
||||||
|
parent::tearDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function report(bool $green = true, string $version = '5.3.0', string $key = 'key'): CheckReport
|
private static function report(bool $green = true, string $version = '5.3.0', string $key = 'key'): CheckReport
|
||||||
@@ -143,10 +153,9 @@ final class DiviTabTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @return string The rendered tab. */
|
/** @return string The rendered tab. */
|
||||||
private function rendered(ReleaseStatus $status, ?CheckReport $report): string
|
private function rendered(ReleaseStatus $status, ?CheckReport $report, ?DiviTab $tab = null): string
|
||||||
{
|
{
|
||||||
ReleaseGate::useResolver(static fn (): ReleaseStatus => $status);
|
ReleaseGate::useResolver(static fn (): ReleaseStatus => $status);
|
||||||
Functions\when('get_posts')->justReturn([]);
|
|
||||||
Functions\when('rest_url')->justReturn('https://k.de/wp-json/lit-mcp/v1/divi-check');
|
Functions\when('rest_url')->justReturn('https://k.de/wp-json/lit-mcp/v1/divi-check');
|
||||||
Functions\when('wp_create_nonce')->justReturn('n');
|
Functions\when('wp_create_nonce')->justReturn('n');
|
||||||
Functions\when('wp_nonce_field')->justReturn('');
|
Functions\when('wp_nonce_field')->justReturn('');
|
||||||
@@ -158,7 +167,7 @@ final class DiviTabTest extends TestCase
|
|||||||
Functions\when('get_option')->justReturn('d.m.Y');
|
Functions\when('get_option')->justReturn('d.m.Y');
|
||||||
|
|
||||||
ob_start();
|
ob_start();
|
||||||
$this->tab($report, Mockery::mock(ReleaseStore::class))->render();
|
($tab ?? $this->tab($report, Mockery::mock(ReleaseStore::class)))->render();
|
||||||
|
|
||||||
return (string) ob_get_clean();
|
return (string) ob_get_clean();
|
||||||
}
|
}
|
||||||
@@ -254,4 +263,63 @@ final class DiviTabTest extends TestCase
|
|||||||
self::assertStringContainsString('Theme Builder: Nachbau von x() – y', $html);
|
self::assertStringContainsString('Theme Builder: Nachbau von x() – y', $html);
|
||||||
self::assertStringContainsString('Plugin-Update', $html);
|
self::assertStringContainsString('Plugin-Update', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The tab removes the pages of a dead check in the settings page's load hook, never while rendering. */
|
||||||
|
public function testRegistersTheCleanupOnTheSettingsPageLoadHook(): void
|
||||||
|
{
|
||||||
|
Actions\expectAdded('admin_post_lit_mcp_divi_release')->once();
|
||||||
|
Actions\expectAdded('admin_post_lit_mcp_divi_withdraw')->once();
|
||||||
|
Actions\expectAdded('load-settings_page_lit-mcp')->once()->with(Mockery::type('array'));
|
||||||
|
|
||||||
|
$this->tab(null, Mockery::mock(ReleaseStore::class))->registerActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheLoadHookOnThisTabRemovesOrphansAndTheTabReportsThem(): void
|
||||||
|
{
|
||||||
|
$_GET['tab'] = 'divi';
|
||||||
|
Functions\expect('get_posts')->once()->andReturn([
|
||||||
|
new WP_Post(['ID' => 51, 'post_title' => WriteProbe::TITLE . ' 2026-10-18 08:00']),
|
||||||
|
]);
|
||||||
|
Functions\expect('wp_delete_post')->once()->with(51, true)->andReturn(new WP_Post(['ID' => 51]));
|
||||||
|
$tab = $this->tab(self::report(), Mockery::mock(ReleaseStore::class));
|
||||||
|
|
||||||
|
$tab->removeOrphans();
|
||||||
|
$html = $this->rendered(ReleaseStatus::released('5.3.0'), self::report(), $tab);
|
||||||
|
|
||||||
|
self::assertStringContainsString('Liegengebliebene Prüfseiten gelöscht: 51', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderingAloneDeletesNothing(): void
|
||||||
|
{
|
||||||
|
Functions\expect('get_posts')->never();
|
||||||
|
Functions\expect('wp_delete_post')->never();
|
||||||
|
|
||||||
|
$html = $this->rendered(ReleaseStatus::released('5.3.0'), self::report());
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('Liegengebliebene Prüfseiten', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheLoadHookDeletesNothingOnOtherTabsWithoutTheRightOrWithoutDivi(): void
|
||||||
|
{
|
||||||
|
Functions\expect('get_posts')->never();
|
||||||
|
$tab = $this->tab(null, Mockery::mock(ReleaseStore::class));
|
||||||
|
|
||||||
|
unset($_GET['tab']);
|
||||||
|
$tab->removeOrphans();
|
||||||
|
$_GET['tab'] = 'tools';
|
||||||
|
$tab->removeOrphans();
|
||||||
|
|
||||||
|
$_GET['tab'] = 'divi';
|
||||||
|
Functions\when('current_user_can')->justReturn(false);
|
||||||
|
$tab->removeOrphans();
|
||||||
|
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
(new DiviTab(
|
||||||
|
Mockery::mock(CheckReportStore::class),
|
||||||
|
Mockery::mock(ReleaseStore::class),
|
||||||
|
FakeChangeLog::forbidden(),
|
||||||
|
null,
|
||||||
|
static fn (): string => ''
|
||||||
|
))->removeOrphans();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Tests\Unit\Admin;
|
namespace LIT\MCP\Tests\Unit\Admin;
|
||||||
|
|
||||||
|
use Brain\Monkey\Filters;
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use LIT\MCP\Admin\AdminTab;
|
use LIT\MCP\Admin\AdminTab;
|
||||||
use LIT\MCP\Admin\SettingsPage;
|
use LIT\MCP\Admin\SettingsPage;
|
||||||
@@ -58,6 +59,23 @@ final class SettingsPageTest extends TestCase
|
|||||||
self::assertSame(['verbindung', 'zugriff'], $page->tabSlugs());
|
self::assertSame(['verbindung', 'zugriff'], $page->tabSlugs());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Integrations add their tabs in Bootable::boot() (Divi: "Divi", last); the filter runs once, on first use. */
|
||||||
|
public function testIntegrationsAppendTheirTabsThroughTheFilter(): void
|
||||||
|
{
|
||||||
|
$divi = Mockery::mock(AdminTab::class);
|
||||||
|
$divi->allows('slug')->andReturn('divi');
|
||||||
|
$divi->expects('registerActions')->once();
|
||||||
|
Filters\expectApplied(SettingsPage::TABS_FILTER)->once()->andReturnUsing(
|
||||||
|
static fn (array $tabs): array => [...$tabs, $divi]
|
||||||
|
);
|
||||||
|
$page = new SettingsPage([$this->tab('verbindung', 'Verbindung'), $this->tab('zugriff', 'Zugriff')]);
|
||||||
|
Functions\when('add_action')->justReturn(true);
|
||||||
|
|
||||||
|
$page->register();
|
||||||
|
|
||||||
|
self::assertSame(['verbindung', 'zugriff', 'divi'], $page->tabSlugs());
|
||||||
|
}
|
||||||
|
|
||||||
public function testRendersTheLogoTopRightOnlyWhenGiven(): void
|
public function testRendersTheLogoTopRightOnlyWhenGiven(): void
|
||||||
{
|
{
|
||||||
Functions\when('current_user_can')->justReturn(true);
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
|||||||
@@ -4,14 +4,21 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi;
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi;
|
||||||
|
|
||||||
|
use Brain\Monkey\Actions;
|
||||||
|
use Brain\Monkey\Filters;
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use LIT\MCP\Abilities\Ability;
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use LIT\MCP\Admin\AdminTab;
|
||||||
|
use LIT\MCP\Admin\DiviTab;
|
||||||
|
use LIT\MCP\Admin\SettingsPage;
|
||||||
use LIT\MCP\Integrations\Core\EnvironmentComponent;
|
use LIT\MCP\Integrations\Core\EnvironmentComponent;
|
||||||
use LIT\MCP\Integrations\Divi\DiviBuilderDetector;
|
use LIT\MCP\Integrations\Divi\DiviBuilderDetector;
|
||||||
use LIT\MCP\Integrations\Divi\DiviEnvironmentComponent;
|
use LIT\MCP\Integrations\Divi\DiviEnvironmentComponent;
|
||||||
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
||||||
use LIT\MCP\Integrations\Divi\DiviLogoUsage;
|
use LIT\MCP\Integrations\Divi\DiviLogoUsage;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
|
||||||
use LIT\MCP\Integrations\Media\MediaUsageSource;
|
use LIT\MCP\Integrations\Media\MediaUsageSource;
|
||||||
use LIT\MCP\Integrations\WordPress\BuilderDetector;
|
use LIT\MCP\Integrations\WordPress\BuilderDetector;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
@@ -251,4 +258,36 @@ final class DiviIntegrationTest extends TestCase
|
|||||||
self::assertSame(1, $asked);
|
self::assertSame(1, $asked);
|
||||||
self::assertFalse((new DiviIntegration($schema, FakeChangeLog::forbidden()))->libraryIdsVisible());
|
self::assertFalse((new DiviIntegration($schema, FakeChangeLog::forbidden()))->libraryIdsVisible());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bootable: Divi's own hooks – upgrader cache reset, check route, tab "Divi" after the plugin's tabs. */
|
||||||
|
public function testBootRegistersDivisOwnHooks(): void
|
||||||
|
{
|
||||||
|
$upgraded = null;
|
||||||
|
$tabs = null;
|
||||||
|
Actions\expectAdded('upgrader_process_complete')->once()->whenHappen(
|
||||||
|
static function (callable $callback) use (&$upgraded): void {
|
||||||
|
$upgraded = $callback;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Actions\expectAdded('rest_api_init')->once()->with(Mockery::on(
|
||||||
|
static fn (array $callback): bool => $callback[0] instanceof DiviCheckRoute
|
||||||
|
));
|
||||||
|
Filters\expectAdded(SettingsPage::TABS_FILTER)->once()->whenHappen(
|
||||||
|
static function (callable $callback) use (&$tabs): void {
|
||||||
|
$tabs = $callback;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Functions\expect('delete_transient')->once()->with(ReleaseResolver::CACHE)->andReturn(true);
|
||||||
|
$base = Mockery::mock(AdminTab::class);
|
||||||
|
|
||||||
|
(new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->boot();
|
||||||
|
self::assertIsCallable($upgraded);
|
||||||
|
$upgraded();
|
||||||
|
self::assertIsCallable($tabs);
|
||||||
|
$all = $tabs([$base]);
|
||||||
|
|
||||||
|
self::assertCount(2, $all);
|
||||||
|
self::assertSame($base, $all[0]);
|
||||||
|
self::assertInstanceOf(DiviTab::class, $all[1]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations;
|
|||||||
|
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use LIT\MCP\Abilities\Ability;
|
use LIT\MCP\Abilities\Ability;
|
||||||
|
use LIT\MCP\Integrations\Bootable;
|
||||||
use LIT\MCP\Integrations\Core\CoreIntegration;
|
use LIT\MCP\Integrations\Core\CoreIntegration;
|
||||||
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
use LIT\MCP\Integrations\Divi\DiviIntegration;
|
||||||
use LIT\MCP\Integrations\Integration;
|
use LIT\MCP\Integrations\Integration;
|
||||||
@@ -79,6 +80,33 @@ final class IntegrationRegistryTest extends TestCase
|
|||||||
self::assertSame('Divi', (new DiviIntegration($schema, FakeChangeLog::forbidden()))->label());
|
self::assertSame('Divi', (new DiviIntegration($schema, FakeChangeLog::forbidden()))->label());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Integration-owned boot steps run once each, in registry order, whether or not the integration is available. */
|
||||||
|
public function testBootsEveryBootableIntegrationOnce(): void
|
||||||
|
{
|
||||||
|
$booted = [];
|
||||||
|
$bootable = function (string $id, bool $available) use (&$booted): Integration {
|
||||||
|
$integration = Mockery::mock(Integration::class . ', ' . Bootable::class);
|
||||||
|
$integration->allows('id')->andReturn($id);
|
||||||
|
$integration->allows('isAvailable')->andReturn($available);
|
||||||
|
$integration->expects('abilities')->never();
|
||||||
|
$integration->expects('boot')->once()->andReturnUsing(static function () use (&$booted, $id): void {
|
||||||
|
$booted[] = $id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $integration;
|
||||||
|
};
|
||||||
|
$registry = new IntegrationRegistry(
|
||||||
|
$this->integration('core', true, []),
|
||||||
|
$bootable('divi', false),
|
||||||
|
$bootable('pods', true)
|
||||||
|
);
|
||||||
|
|
||||||
|
$registry->boot();
|
||||||
|
$registry->boot();
|
||||||
|
|
||||||
|
self::assertSame(['divi', 'pods'], $booted);
|
||||||
|
}
|
||||||
|
|
||||||
private function ability(string $name, bool $direct): Ability
|
private function ability(string $name, bool $direct): Ability
|
||||||
{
|
{
|
||||||
$ability = Mockery::mock(Ability::class);
|
$ability = Mockery::mock(Ability::class);
|
||||||
|
|||||||
Reference in New Issue
Block a user