diff --git a/src/Admin/DiviTab.php b/src/Admin/DiviTab.php index cb1176f..3fdff5d 100644 --- a/src/Admin/DiviTab.php +++ b/src/Admin/DiviTab.php @@ -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, * DiviCheckRoute), "Divi 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 { @@ -31,6 +32,9 @@ final class DiviTab implements AdminTab public const RELEASE_ACTION = 'lit_mcp_divi_release'; public const WITHDRAW_ACTION = 'lit_mcp_divi_withdraw'; + /** @var list 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): 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::WITHDRAW_ACTION, [$this, 'withdraw']); + add_action('load-' . SettingsPage::SCREEN_HOOK, [$this, 'removeOrphans']); + } + + /** + * load- 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 @@ -127,7 +147,6 @@ final class DiviTab implements AdminTab return; } - $removed = WriteProbe::removeOrphans(); $status = ReleaseGate::status(); $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.', 'wp-lit-mcp' ) . '

'; - if ($removed !== []) { + if ($this->removed !== []) { printf( '

%s

', esc_html(sprintf( /* translators: %s: comma separated page IDs */ __('Liegengebliebene Prüfseiten gelöscht: %s', 'wp-lit-mcp'), - implode(', ', $removed) + implode(', ', $this->removed) )) ); } @@ -305,6 +324,15 @@ final class DiviTab implements AdminTab ) . '

'; } + /** 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 { return ($this->version ?? static fn (): string => DiviEnvironment::version() ?? '')(); diff --git a/src/Admin/SettingsPage.php b/src/Admin/SettingsPage.php index b81246f..8bb705a 100644 --- a/src/Admin/SettingsPage.php +++ b/src/Admin/SettingsPage.php @@ -16,24 +16,27 @@ final class SettingsPage /** Hook suffix WordPress gives this page (add_options_page → "settings_page_"). */ public const SCREEN_HOOK = 'settings_page_' . self::SLUG; - /** @var array */ - private array $tabs = []; + /** + * Filter on the tab list (list): 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|null null = TABS_FILTER not applied yet. */ + private ?array $tabs = null; /** - * @param list $tabs The first tab is the default. + * @param list $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 $assetsUrl URL of the plugin's assets/ directory ('' = no own CSS/JS). * @param string $version Asset version for cache busting. */ public function __construct( - array $tabs, + private array $baseTabs, private string $logoUrl = '', private string $assetsUrl = '', private string $version = '' ) { - foreach ($tabs as $tab) { - $this->tabs[$tab->slug()] = $tab; - } } public function register(): void @@ -41,7 +44,7 @@ final class SettingsPage add_action('admin_menu', [$this, 'addMenu']); add_action('admin_init', [$this, 'registerSettings']); add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']); - foreach ($this->tabs as $tab) { + foreach ($this->tabs() as $tab) { $tab->registerActions(); } } @@ -59,7 +62,7 @@ final class SettingsPage public function registerSettings(): void { - foreach ($this->tabs as $tab) { + foreach ($this->tabs() as $tab) { $tab->registerSettings(); } } @@ -90,7 +93,7 @@ final class SettingsPage /** @return list */ public function tabSlugs(): array { - return array_keys($this->tabs); + return array_keys($this->tabs()); } public function currentTab(): string @@ -98,7 +101,9 @@ final class SettingsPage // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch. $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 $args */ @@ -123,7 +128,7 @@ final class SettingsPage echo '
'; $this->renderNotice(); printf(''; - $this->tabs[$current]->render(); + $this->tabs()[$current]->render(); echo ''; } + /** @return array 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 { if ($this->logoUrl === '') { diff --git a/src/Integrations/Bootable.php b/src/Integrations/Bootable.php new file mode 100644 index 0000000..514cd27 --- /dev/null +++ b/src/Integrations/Bootable.php @@ -0,0 +1,18 @@ + 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 { $loader = $this->loader(); diff --git a/src/Integrations/IntegrationRegistry.php b/src/Integrations/IntegrationRegistry.php index 28f30cd..2a59956 100644 --- a/src/Integrations/IntegrationRegistry.php +++ b/src/Integrations/IntegrationRegistry.php @@ -16,11 +16,32 @@ final class IntegrationRegistry /** @var list}>|null Available integrations with their abilities. */ private ?array $loaded = null; + private bool $booted = false; + public function __construct(Integration ...$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 */ public function available(): array { diff --git a/src/Plugin.php b/src/Plugin.php index 40212ab..174c78d 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -12,7 +12,6 @@ use LIT\MCP\Admin\ConnectionRepository; use LIT\MCP\Admin\ConnectionStatus; use LIT\MCP\Admin\ConnectionTab; use LIT\MCP\Admin\ConnectionUsage; -use LIT\MCP\Admin\DiviTab; use LIT\MCP\Admin\LimitsTab; use LIT\MCP\Admin\LogTab; use LIT\MCP\Admin\SettingsPage; @@ -21,15 +20,11 @@ use LIT\MCP\Admin\ToolsTab; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; -use LIT\MCP\Cli\DiviCheckCommand; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\Divi\DiviIntegration; 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\ReleaseResolver; use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess; use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Integrations\Media\MediaIntegration; @@ -78,11 +73,6 @@ final class Plugin (new ChangeLogInstaller($changeLog))->maybeInstall(); (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(); $registry = null; $policy = null; @@ -139,10 +129,9 @@ final class Plugin $divi->themeBuilder() ); - // Spec §6: the admin check runs as a REST request (same context as MCP calls). - $diviCheck = static fn (): CompatibilityCheck => CompatibilityCheck::create($divi->contentComposer()); - (new DiviCheckRoute($diviCheck))->register(); - DiviCheckCommand::register($diviCheck); + // Integration-owned hooks (Divi: upgrader hook, check route, CLI command, tab "Divi") – before the settings + // page reads its tabs. + $registry->boot(); $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); (new AbilityRegistrar( @@ -167,7 +156,6 @@ final class Plugin new ToolsTab($registry, $policy), new LogTab($changeLog, $limits, $registry), new LimitsTab(), - new DiviTab(log: $changeLog), ]; self::$settingsPage = new SettingsPage( $tabs, diff --git a/tests/Unit/Admin/DiviTabTest.php b/tests/Unit/Admin/DiviTabTest.php index 705ae9c..d5e0ba4 100644 --- a/tests/Unit/Admin/DiviTabTest.php +++ b/tests/Unit/Admin/DiviTabTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Admin; +use Brain\Monkey\Actions; use Brain\Monkey\Functions; use LIT\MCP\Admin\AdminPostAction; 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\ReleaseStatus; use LIT\MCP\Integrations\Divi\Release\ReleaseStore; +use LIT\MCP\Integrations\Divi\Release\WriteProbe; use LIT\MCP\Plugin; use LIT\MCP\Tests\Support\FakeChangeLog; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use WP_Post; final class DiviTabTest extends TestCase { @@ -41,6 +44,13 @@ final class DiviTabTest extends TestCase Functions\when('add_query_arg')->alias( 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 @@ -143,10 +153,9 @@ final class DiviTabTest extends TestCase } /** @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); - Functions\when('get_posts')->justReturn([]); 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_nonce_field')->justReturn(''); @@ -158,7 +167,7 @@ final class DiviTabTest extends TestCase Functions\when('get_option')->justReturn('d.m.Y'); ob_start(); - $this->tab($report, Mockery::mock(ReleaseStore::class))->render(); + ($tab ?? $this->tab($report, Mockery::mock(ReleaseStore::class)))->render(); 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('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(); + } } diff --git a/tests/Unit/Admin/SettingsPageTest.php b/tests/Unit/Admin/SettingsPageTest.php index 0cea111..c913ae4 100644 --- a/tests/Unit/Admin/SettingsPageTest.php +++ b/tests/Unit/Admin/SettingsPageTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Admin; +use Brain\Monkey\Filters; use Brain\Monkey\Functions; use LIT\MCP\Admin\AdminTab; use LIT\MCP\Admin\SettingsPage; @@ -58,6 +59,23 @@ final class SettingsPageTest extends TestCase 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 { Functions\when('current_user_can')->justReturn(true); diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 4b9e911..7ecc1f3 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -4,14 +4,21 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi; +use Brain\Monkey\Actions; +use Brain\Monkey\Filters; use Brain\Monkey\Functions; 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\Divi\DiviBuilderDetector; use LIT\MCP\Integrations\Divi\DiviEnvironmentComponent; use LIT\MCP\Integrations\Divi\DiviIntegration; use LIT\MCP\Integrations\Divi\DiviLogoUsage; 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\WordPress\BuilderDetector; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; @@ -251,4 +258,36 @@ final class DiviIntegrationTest extends TestCase self::assertSame(1, $asked); 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]); + } } diff --git a/tests/Unit/Integrations/IntegrationRegistryTest.php b/tests/Unit/Integrations/IntegrationRegistryTest.php index 2e05f90..20bc4d9 100644 --- a/tests/Unit/Integrations/IntegrationRegistryTest.php +++ b/tests/Unit/Integrations/IntegrationRegistryTest.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations; use Brain\Monkey\Functions; use LIT\MCP\Abilities\Ability; +use LIT\MCP\Integrations\Bootable; use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\Divi\DiviIntegration; use LIT\MCP\Integrations\Integration; @@ -79,6 +80,33 @@ final class IntegrationRegistryTest extends TestCase 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 { $ability = Mockery::mock(Ability::class);