From 040df24916bd77c8b5de59208858f4f8a59b2142 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Tue, 29 Sep 2026 18:06:13 +0200 Subject: [PATCH 1/9] docs: point the documentation submodule at block themes and script modules Pollora/documentation 2b02a7e: #10 (body classes), #11 (script modules), #12 (block themes, the magazine template). --- documentation | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation b/documentation index 6a8be203..2b02a7eb 160000 --- a/documentation +++ b/documentation @@ -1 +1 @@ -Subproject commit 6a8be203bed65ed0cc06855c119f149f90ccff9e +Subproject commit 2b02a7ebf31343f5812d2e791dedbf6e0ac22dad From cc8017425e1b18b954e2e0119f35f00e96d983a2 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 30 Sep 2026 09:00:50 +0200 Subject: [PATCH 2/9] feat: pollora:make:theme activates the theme only where the site needs one A site with no usable theme gets the generated one without a question; a site with one keeps it unless the answer is yes, now the default no, so --no-interaction never replaces a working theme. --activate and --no-activate settle it; pollora:install passes --activate. Activation goes through switch_theme(), so switch_theme and after_switch_theme fire. --- CHANGELOG.md | 3 + src/Theme/UI/Console/MakeThemeCommand.php | 70 +++++++++++---- .../Commands/LaunchPadInstallCommand.php | 3 + .../Feature/Theme/MakeThemeActivationTest.php | 90 +++++++++++++++++++ .../Commands/LaunchPadInstallCommandTest.php | 7 +- 5 files changed, 154 insertions(+), 19 deletions(-) create mode 100644 tests/Feature/Theme/MakeThemeActivationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index bca80d27..577f9c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.0-beta...develop) +### Changed +- `pollora:make:theme` activates the generated theme only where the site needs one. A site with no usable theme — a first install — gets it without a question; a site that already has one keeps it unless the answer is "yes", now the default "no", so `--no-interaction` never replaces a working theme. `--activate` and `--no-activate` settle it without asking, and `pollora:install` passes `--activate`. Activation goes through `switch_theme()`, which fires `switch_theme` and `after_switch_theme`; the options were written directly before, so those hooks never ran + ## [v13.34.0-beta](https://github.com/Pollora/framework/compare/v13.32.0-beta.9...v13.34.0-beta) - 2026-09-29 The framework's version tracks Laravel's: this beta requires Laravel 13.34. diff --git a/src/Theme/UI/Console/MakeThemeCommand.php b/src/Theme/UI/Console/MakeThemeCommand.php index 8985c65e..1e590c69 100755 --- a/src/Theme/UI/Console/MakeThemeCommand.php +++ b/src/Theme/UI/Console/MakeThemeCommand.php @@ -12,6 +12,7 @@ use Pollora\Console\Contracts\PromptsForMissingOption as PromptsForMissingOptionContract; use Pollora\Modules\Infrastructure\Services\ModuleScaffolderService; use Pollora\Support\NpmRunner; +use Pollora\Theme\Application\Services\ThemeAvailability; use Pollora\Theme\Domain\Models\ThemeMetadata; use Pollora\Translation\Domain\Contracts\TranslationCompilerInterface; use Pollora\Translation\Infrastructure\Services\GettextMoCompiler; @@ -30,7 +31,7 @@ * setting the theme as the active WordPress theme. */ #[Description('Generate theme structure by downloading from GitHub repository')] -#[Signature('pollora:make:theme {name} {--theme-author= : Theme author name} {--theme-author-uri= : Theme author URI} {--theme-uri= : Theme URI} {--theme-description= : Theme description} {--theme-version= : Theme version} {--repository= : GitHub repository to download (owner/repo format)} {--repo-version= : Specific version/tag to download} {--force : Force create theme with same name}', aliases: ['pollora:make-theme'])] +#[Signature('pollora:make:theme {name} {--theme-author= : Theme author name} {--theme-author-uri= : Theme author URI} {--theme-uri= : Theme URI} {--theme-description= : Theme description} {--theme-version= : Theme version} {--repository= : GitHub repository to download (owner/repo format)} {--repo-version= : Specific version/tag to download} {--force : Force create theme with same name} {--activate : Activate the theme without asking} {--no-activate : Leave the active theme as it is, without asking}', aliases: ['pollora:make-theme'])] class MakeThemeCommand extends BaseThemeCommand implements PromptsForMissingInput, PromptsForMissingOptionContract { use PromptsForMissingOption; @@ -396,26 +397,63 @@ protected function runNpmIfNeeded(): void } /** - * Prompt to set the theme as active and do so if confirmed. + * Activate the new theme when that is what the site needs. + * + * A site with no usable theme — a first install — gets it without being + * asked: its front end renders nothing until a theme is active. A site + * that already has one keeps it unless someone says otherwise, so the + * question defaults to "no", and --no-interaction never replaces a working + * theme. --activate and --no-activate settle it without a question. */ protected function promptAndSetActiveTheme(): void { - $shouldSetActive = select( - label: 'Do you want to set "'.$this->theme->getName().'" as the active WordPress theme?', - options: ['yes' => 'Yes', 'no' => 'No'], - default: 'yes', - hint: 'Selecting "Yes" will set this theme as the active one in WordPress.' - ); + $name = $this->theme->getName(); - if ($shouldSetActive === 'yes') { - if (function_exists('update_option')) { - update_option('stylesheet', $this->theme->getName()); - update_option('template', $this->theme->getName()); - $this->info('Theme "'.$this->theme->getName().'" is now set as the active WordPress theme.'); - } else { - $this->warn('Unable to set the theme as active: WordPress functions are not available in this context.'); - } + if (function_exists('get_stylesheet') && get_stylesheet() === $name) { + return; + } + + if (! $this->shouldActivateTheme($name)) { + return; + } + + if (! function_exists('switch_theme')) { + $this->warn('Unable to set the theme as active: WordPress functions are not available in this context.'); + + return; } + + // switch_theme(), not the options alone: it fires switch_theme and + // after_switch_theme, which plugins and themes set themselves up on. + switch_theme($name); + $this->info(sprintf('Theme "%s" is now the active WordPress theme.', $name)); + } + + protected function shouldActivateTheme(string $name): bool + { + if ($this->option('activate')) { + return true; + } + + if ($this->option('no-activate')) { + return false; + } + + if ($this->laravel->make(ThemeAvailability::class)->isMissing()) { + return true; + } + + // Nobody to ask: the site keeps the theme it has. + if (! $this->input->isInteractive()) { + return false; + } + + return select( + label: sprintf('Another theme is active. Do you want to replace it with "%s"?', $name), + options: ['yes' => 'Yes', 'no' => 'No'], + default: 'no', + hint: 'Selecting "Yes" makes this theme the active one in WordPress.' + ) === 'yes'; } /** diff --git a/src/WordPress/Commands/LaunchPadInstallCommand.php b/src/WordPress/Commands/LaunchPadInstallCommand.php index 2ef2feb1..05c6dc59 100644 --- a/src/WordPress/Commands/LaunchPadInstallCommand.php +++ b/src/WordPress/Commands/LaunchPadInstallCommand.php @@ -118,6 +118,9 @@ private function installTheme(): void $arguments['--no-interaction'] = true; } + // Installing a site is the intent to use the theme it generates. + $arguments['--activate'] = true; + $this->call('pollora:make:theme', $arguments); } diff --git a/tests/Feature/Theme/MakeThemeActivationTest.php b/tests/Feature/Theme/MakeThemeActivationTest.php new file mode 100644 index 00000000..bfa3db21 --- /dev/null +++ b/tests/Feature/Theme/MakeThemeActivationTest.php @@ -0,0 +1,90 @@ +setLaravel(app()); + + $registrar = Mockery::mock(ThemeRegistrarInterface::class); + $registrar->shouldReceive('getActiveTheme')->andReturn($themeMissing ? null : Mockery::mock(ThemeModuleInterface::class)); + $container = new Container; + $container->instance(ThemeRegistrarInterface::class, $registrar); + + app()->instance(ThemeAvailability::class, new ThemeAvailability($container)); + + $input = new ArrayInput(['name' => 'my-journal', ...$options], $command->getDefinition()); + $input->setInteractive(false); + + $command->setInput($input); + $command->setOutput(new OutputStyle($input, new BufferedOutput)); + + (new ReflectionProperty($command, 'theme'))->setValue($command, new ThemeMetadata('my-journal', '/tmp/themes')); + + return $command; +} + +function activates(MakeThemeCommand $command): bool +{ + $switched = []; + Functions\when('switch_theme')->alias(function (string $stylesheet) use (&$switched): void { + $switched[] = $stylesheet; + }); + // Writing the option directly is activating too, as the command once did. + Functions\when('update_option')->alias(function (string $option, mixed $value) use (&$switched): bool { + if ($option === 'stylesheet') { + $switched[] = $value; + } + + return true; + }); + + (fn () => $this->promptAndSetActiveTheme())->call($command); + + return $switched === ['my-journal']; +} + +beforeEach(function (): void { + Functions\when('get_stylesheet')->justReturn('twentytwentyfive'); + Functions\when('get_stylesheet_directory')->justReturn('/nope'); +}); + +it('activates the theme on a site that has none, without asking', function (): void { + expect(activates(activationCommand([], themeMissing: true)))->toBeTrue(); +}); + +it('keeps the active theme of a site that has one, when nobody answers', function (): void { + expect(activates(activationCommand([], themeMissing: false)))->toBeFalse(); +}); + +it('activates with --activate, even over another theme', function (): void { + expect(activates(activationCommand(['--activate' => true], themeMissing: false)))->toBeTrue(); +}); + +it('leaves the site alone with --no-activate, even when it has no theme', function (): void { + expect(activates(activationCommand(['--no-activate' => true], themeMissing: true)))->toBeFalse(); +}); + +it('does nothing when the generated theme is already the active one', function (): void { + Functions\when('get_stylesheet')->justReturn('my-journal'); + + expect(activates(activationCommand(['--activate' => true], themeMissing: false)))->toBeFalse(); +}); diff --git a/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php b/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php index 0e337328..0e9e039e 100644 --- a/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php +++ b/tests/Unit/WordPress/Commands/LaunchPadInstallCommandTest.php @@ -29,7 +29,7 @@ function runInstallCommand(array $parameters, bool $interactive, int $migrateExi $database = Mockery::mock(DatabaseService::class); $database->shouldReceive('isConfigured')->andReturn(true); - $recorder = new #[Signature('pollora:make:theme {name?} {--theme-author=}')] class extends Command + $recorder = new #[Signature('pollora:make:theme {name?} {--theme-author=} {--activate}')] class extends Command { /** @var array|null */ public ?array $received = null; @@ -39,6 +39,7 @@ public function handle(): int $this->received = [ 'name' => $this->argument('name'), 'interactive' => $this->input->isInteractive(), + 'activate' => $this->option('activate'), ]; return self::SUCCESS; @@ -117,13 +118,13 @@ public function isLocal(): bool $result = runInstallCommand($this->installOptions, interactive: false); expect($result['exit'])->toBe(0) - ->and($result['theme'])->toBe(['name' => 'default', 'interactive' => false]); + ->and($result['theme'])->toBe(['name' => 'default', 'interactive' => false, 'activate' => true]); }); it('generates the theme named by --theme', function (): void { $result = runInstallCommand([...$this->installOptions, '--theme' => 'acme'], interactive: false); - expect($result['theme'])->toBe(['name' => 'acme', 'interactive' => false]); + expect($result['theme'])->toBe(['name' => 'acme', 'interactive' => false, 'activate' => true]); }); it('leaves the theme name to the prompt when interactive', function (): void { From facdf1adc8cde9e6a47bac38ea9148176e764134 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 30 Sep 2026 10:06:41 +0200 Subject: [PATCH 3/9] feat: pollora:doctor and Site Health checks for silent failures Eleven checks, each from a failure met in practice and each printing the command that fixes it: the WordPress core patch and who owns __(), the patches lock, .env names, the discovery cache, the theme and its build, a symlinked theme directory, placeholders left in a copied template, pattern files WordPress never registers or has not cached, Route::wp() routes over a block theme's templates. The same list feeds WordPress's Site Health, which also checks that the theme's blocks are registered in a web request, the boot a console check cannot see. --- CHANGELOG.md | 4 + .../Services/DiscoveryCacheManager.php | 40 +++ src/Doctor/Application/Services/Doctor.php | 63 +++++ .../Domain/Contracts/CheckInterface.php | 33 +++ src/Doctor/Domain/Enums/CheckStatus.php | 19 ++ src/Doctor/Domain/Enums/RunContext.php | 18 ++ src/Doctor/Domain/Models/CheckResult.php | 66 +++++ .../Checks/BlockRegistrationCheck.php | 78 ++++++ .../Checks/BlockThemeRoutesCheck.php | 63 +++++ .../Checks/DiscoveryCacheCheck.php | 69 +++++ .../Checks/EnvironmentFileCheck.php | 87 +++++++ .../Checks/PatchesLockCheck.php | 93 +++++++ .../Checks/PatternCacheCheck.php | 72 ++++++ .../Checks/PatternFilesCheck.php | 66 +++++ .../Infrastructure/Checks/ThemeBuildCheck.php | 84 +++++++ .../Checks/ThemeDirectoryCheck.php | 57 +++++ .../Checks/ThemePlaceholdersCheck.php | 59 +++++ .../Checks/WordPressCorePatchCheck.php | 78 ++++++ .../Providers/DoctorServiceProvider.php | 64 +++++ .../Infrastructure/Support/ActiveTheme.php | 89 +++++++ src/Doctor/UI/Console/DoctorCommand.php | 75 ++++++ src/Doctor/UI/Http/SiteHealthTests.php | 75 ++++++ src/Providers/PolloraServiceProvider.php | 2 + tests/Feature/Doctor/DoctorTest.php | 147 +++++++++++ tests/Feature/Doctor/InstallChecksTest.php | 235 ++++++++++++++++++ tests/Feature/Doctor/ThemeChecksTest.php | 233 +++++++++++++++++ tests/Pest.php | 2 +- tests/e2e/specs/doctor.spec.ts | 25 ++ 28 files changed, 1995 insertions(+), 1 deletion(-) create mode 100644 src/Doctor/Application/Services/Doctor.php create mode 100644 src/Doctor/Domain/Contracts/CheckInterface.php create mode 100644 src/Doctor/Domain/Enums/CheckStatus.php create mode 100644 src/Doctor/Domain/Enums/RunContext.php create mode 100644 src/Doctor/Domain/Models/CheckResult.php create mode 100644 src/Doctor/Infrastructure/Checks/BlockRegistrationCheck.php create mode 100644 src/Doctor/Infrastructure/Checks/BlockThemeRoutesCheck.php create mode 100644 src/Doctor/Infrastructure/Checks/DiscoveryCacheCheck.php create mode 100644 src/Doctor/Infrastructure/Checks/EnvironmentFileCheck.php create mode 100644 src/Doctor/Infrastructure/Checks/PatchesLockCheck.php create mode 100644 src/Doctor/Infrastructure/Checks/PatternCacheCheck.php create mode 100644 src/Doctor/Infrastructure/Checks/PatternFilesCheck.php create mode 100644 src/Doctor/Infrastructure/Checks/ThemeBuildCheck.php create mode 100644 src/Doctor/Infrastructure/Checks/ThemeDirectoryCheck.php create mode 100644 src/Doctor/Infrastructure/Checks/ThemePlaceholdersCheck.php create mode 100644 src/Doctor/Infrastructure/Checks/WordPressCorePatchCheck.php create mode 100644 src/Doctor/Infrastructure/Providers/DoctorServiceProvider.php create mode 100644 src/Doctor/Infrastructure/Support/ActiveTheme.php create mode 100644 src/Doctor/UI/Console/DoctorCommand.php create mode 100644 src/Doctor/UI/Http/SiteHealthTests.php create mode 100644 tests/Feature/Doctor/DoctorTest.php create mode 100644 tests/Feature/Doctor/InstallChecksTest.php create mode 100644 tests/Feature/Doctor/ThemeChecksTest.php create mode 100644 tests/e2e/specs/doctor.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 577f9c5f..b293a4d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.0-beta...develop) +### Added +- `pollora:doctor`: checks a project for the failures that stay silent — the site renders, the command exits 0 — and prints, under each, the command that fixes it. Eleven checks, each from a failure met in practice: WordPress core not patched or `__()` not Pollora's, `patches.lock.json` missing or older than the framework's patches, `.env` names Pollora does not read (`DB_NAME`, `WP_HOME`…) or MySQL settings on a sqlite connection, classes missing from the discovery cache, no theme or an unbuilt one, a theme directory symlinked under another name, `%theme_*%` placeholders left in a theme copied from a template, pattern files WordPress never registers (`.html`, no Title or Slug) or missing from its cache, `Route::wp()` routes answering in place of a block theme's templates. `--json` for scripts; exits 1 on an error +- The same checks in WordPress's **Site Health** (Tools › Site Health), with a "Pollora" badge, plus one that only a web request can make: every block of the active theme is registered. That is the boot a visitor gets — blocks were once registered under WP-CLI but not over HTTP, which a console check would have passed + ### Changed - `pollora:make:theme` activates the generated theme only where the site needs one. A site with no usable theme — a first install — gets it without a question; a site that already has one keeps it unless the answer is "yes", now the default "no", so `--no-interaction` never replaces a working theme. `--activate` and `--no-activate` settle it without asking, and `pollora:install` passes `--activate`. Activation goes through `switch_theme()`, which fires `switch_theme` and `after_switch_theme`; the options were written directly before, so those hooks never ran diff --git a/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php b/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php index edf19d35..d2dfcd69 100644 --- a/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php +++ b/src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php @@ -130,6 +130,46 @@ public function clearCache(iterable $locations): void } } + /** + * Classes on disk that the cached entry for a location does not list. + * + * They stay invisible to discovery — no hook, no post type, no route — until + * the cache is cleared, and nothing says so. Null when the location has no + * cached entry, or the cache is off, since then discovery reads the disk. + * + * @return list|null + */ + public function classesMissingFromCache(DiscoveryLocationInterface $location): ?array + { + if (! $this->shouldUseCache()) { + return null; + } + + $cacheId = $this->generateCacheId($location); + + if (! $this->cacheDriver->has($cacheId)) { + return null; + } + + $names = static fn (array $structures): array => array_map( + static fn (mixed $structure): string => is_object($structure) && method_exists($structure, 'getFcqn') ? $structure->getFcqn() : (string) $structure, + $structures, + ); + + return array_values(array_diff( + $names(Discover::in($location->getPath())->full()->get()), + $names($this->cacheDriver->get($cacheId)), + )); + } + + /** + * Whether discovery reads through a persistent cache (it does not in debug mode). + */ + public function isCacheEnabled(): bool + { + return $this->shouldUseCache(); + } + /** * Get the cache driver instance. */ diff --git a/src/Doctor/Application/Services/Doctor.php b/src/Doctor/Application/Services/Doctor.php new file mode 100644 index 00000000..05d4d585 --- /dev/null +++ b/src/Doctor/Application/Services/Doctor.php @@ -0,0 +1,63 @@ + $checks + */ + public function __construct(private iterable $checks) {} + + /** + * @return list + */ + public function checksFor(RunContext $context): array + { + $checks = []; + + foreach ($this->checks as $check) { + if (in_array($context, $check->runsIn(), true)) { + $checks[] = $check; + } + } + + return $checks; + } + + public function runCheck(CheckInterface $check, RunContext $context): CheckResult + { + try { + return $check->run($context); + } catch (Throwable $throwable) { + return CheckResult::error( + 'The check itself failed: '.$throwable->getMessage(), + [$throwable::class.' in '.$throwable->getFile().':'.$throwable->getLine()], + ); + } + } + + /** + * @return list + */ + public function run(RunContext $context): array + { + return array_map( + fn (CheckInterface $check): array => ['check' => $check, 'result' => $this->runCheck($check, $context)], + $this->checksFor($context), + ); + } +} diff --git a/src/Doctor/Domain/Contracts/CheckInterface.php b/src/Doctor/Domain/Contracts/CheckInterface.php new file mode 100644 index 00000000..32613e68 --- /dev/null +++ b/src/Doctor/Domain/Contracts/CheckInterface.php @@ -0,0 +1,33 @@ + + */ + public function runsIn(): array; + + public function run(RunContext $context): CheckResult; +} diff --git a/src/Doctor/Domain/Enums/CheckStatus.php b/src/Doctor/Domain/Enums/CheckStatus.php new file mode 100644 index 00000000..27c047f5 --- /dev/null +++ b/src/Doctor/Domain/Enums/CheckStatus.php @@ -0,0 +1,19 @@ + $details + */ + private function __construct( + public CheckStatus $status, + public string $summary, + public array $details = [], + public ?string $fix = null, + ) {} + + /** + * @param list $details + */ + public static function ok(string $summary, array $details = []): self + { + return new self(CheckStatus::Ok, $summary, $details); + } + + /** + * @param list $details + */ + public static function warning(string $summary, array $details = [], ?string $fix = null): self + { + return new self(CheckStatus::Warning, $summary, $details, $fix); + } + + /** + * @param list $details + */ + public static function error(string $summary, array $details = [], ?string $fix = null): self + { + return new self(CheckStatus::Error, $summary, $details, $fix); + } + + public static function skipped(string $reason): self + { + return new self(CheckStatus::Skipped, $reason); + } + + /** + * @return array{status: string, summary: string, details: list, fix: ?string} + */ + public function toArray(): array + { + return [ + 'status' => $this->status->value, + 'summary' => $this->summary, + 'details' => $this->details, + 'fix' => $this->fix, + ]; + } +} diff --git a/src/Doctor/Infrastructure/Checks/BlockRegistrationCheck.php b/src/Doctor/Infrastructure/Checks/BlockRegistrationCheck.php new file mode 100644 index 00000000..64551017 --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/BlockRegistrationCheck.php @@ -0,0 +1,78 @@ +is_registered($name)) { + $missing[] = $name.' ('.$file.')'; + } + } + + if ($missing !== []) { + return CheckResult::error( + sprintf('%d block(s) of the theme are not registered: the editor and the pages do not have them.', count($missing)), + $missing, + "Check storage/logs/laravel.log for the block's registration error; a dynamic block needs a valid block.json and its render.blade.php", + ); + } + + return CheckResult::ok(sprintf('%d block(s) of the theme are registered.', count($declared))); + } +} diff --git a/src/Doctor/Infrastructure/Checks/BlockThemeRoutesCheck.php b/src/Doctor/Infrastructure/Checks/BlockThemeRoutesCheck.php new file mode 100644 index 00000000..5983db97 --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/BlockThemeRoutesCheck.php @@ -0,0 +1,63 @@ +router->getRoutes()->getRoutes() as $route) { + if ($route instanceof Route && $route->isWordPressRoute() && $route->hasCondition()) { + $routes[] = sprintf('Route::wp(\'%s\') → %s', $route->getCondition(), $route->getActionName()); + } + } + + if ($routes !== []) { + return CheckResult::warning( + sprintf('%d Route::wp() route(s) answer before the block templates, which then never render.', count($routes)), + $routes, + 'Remove them from routes/web.php, unless they are meant to replace a block template', + ); + } + + return CheckResult::ok('No route answers in place of the block templates.'); + } +} diff --git a/src/Doctor/Infrastructure/Checks/DiscoveryCacheCheck.php b/src/Doctor/Infrastructure/Checks/DiscoveryCacheCheck.php new file mode 100644 index 00000000..fe994b9d --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/DiscoveryCacheCheck.php @@ -0,0 +1,69 @@ +cache->isCacheEnabled()) { + return CheckResult::ok('The cache is off (debug mode): discovery reads the disk on every request.'); + } + + $missing = []; + + foreach ($this->discovery->getLocations() as $location) { + foreach ($this->cache->classesMissingFromCache($location) ?? [] as $class) { + $missing[] = $class; + } + } + + if ($missing !== []) { + sort($missing); + + return CheckResult::error( + sprintf('%d class(es) added since the cache was written are not registered.', count($missing)), + array_slice($missing, 0, 20), + 'php artisan discovery:clear', + ); + } + + return CheckResult::ok('The cache matches the classes on disk.'); + } +} diff --git a/src/Doctor/Infrastructure/Checks/EnvironmentFileCheck.php b/src/Doctor/Infrastructure/Checks/EnvironmentFileCheck.php new file mode 100644 index 00000000..9b8c192c --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/EnvironmentFileCheck.php @@ -0,0 +1,87 @@ + the one it reads instead. */ + private const array RENAMED = [ + 'DB_NAME' => 'DB_DATABASE', + 'DB_USER' => 'DB_USERNAME', + 'WP_HOME' => 'APP_URL', + 'WP_SITEURL' => 'APP_URL', + ]; + + public function id(): string + { + return 'environment-file'; + } + + public function label(): string + { + return 'Environment file'; + } + + public function runsIn(): array + { + return [RunContext::Console, RunContext::Http]; + } + + public function run(RunContext $context): CheckResult + { + $file = base_path('.env'); + + if (! is_file($file)) { + return CheckResult::skipped('No .env file: the settings come from the environment.'); + } + + $env = Dotenv::parse((string) file_get_contents($file)); + $errors = []; + $warnings = []; + + foreach (self::RENAMED as $ignored => $read) { + if (! array_key_exists($ignored, $env)) { + continue; + } + + if (! array_key_exists($read, $env)) { + $errors[] = sprintf('%s is not read: Pollora reads %s', $ignored, $read); + } elseif ($env[$ignored] !== $env[$read]) { + $warnings[] = sprintf('%s (%s) is ignored: Pollora uses %s (%s)', $ignored, $env[$ignored], $read, $env[$read]); + } + } + + if (config('database.default') === 'sqlite' && ($env['DB_HOST'] ?? '') !== '') { + $errors[] = 'DB_HOST is set, but the connection is sqlite: DB_CONNECTION is missing or not mysql'; + } + + if ($errors !== []) { + return CheckResult::error( + 'Some settings in .env are not the ones Pollora reads.', + [...$errors, ...$warnings], + "Use Laravel's names in .env: DB_CONNECTION=mysql, DB_DATABASE, DB_USERNAME, APP_URL", + ); + } + + if ($warnings !== []) { + return CheckResult::warning('.env carries settings Pollora ignores.', $warnings, 'Remove them, or give them the value Pollora uses'); + } + + return CheckResult::ok('.env uses the names Pollora reads.'); + } +} diff --git a/src/Doctor/Infrastructure/Checks/PatchesLockCheck.php b/src/Doctor/Infrastructure/Checks/PatchesLockCheck.php new file mode 100644 index 00000000..b7efd6cb --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/PatchesLockCheck.php @@ -0,0 +1,93 @@ +declaredPatches(); + + if ($declared === []) { + return CheckResult::ok('The installed framework declares no patch.'); + } + + $lockFile = base_path('patches.lock.json'); + + if (! is_file($lockFile)) { + return CheckResult::error( + "patches.lock.json is missing: Composer does not apply the framework's patches.", + [$lockFile], + 'composer patches-relock && composer patches-repatch', + ); + } + + $locked = json_decode((string) file_get_contents($lockFile), true)['patches'] ?? []; + $missing = []; + + foreach ($declared as $package => $patches) { + $lockedUrls = array_column(is_array($locked[$package] ?? null) ? $locked[$package] : [], 'url'); + + foreach ($patches as $description => $url) { + if (! in_array($url, $lockedUrls, true)) { + $missing[] = sprintf('%s: %s', $package, $description); + } + } + } + + if ($missing !== []) { + return CheckResult::error( + "patches.lock.json is older than the framework's patches: Composer applies the old ones, or none.", + $missing, + 'composer patches-relock && composer patches-repatch', + ); + } + + return CheckResult::ok('patches.lock.json lists every patch the framework declares.'); + } + + /** + * @return array> package => [description => url] + */ + private function declaredPatches(): array + { + $path = InstalledVersions::isInstalled('pollora/framework') ? InstalledVersions::getInstallPath('pollora/framework') : null; + + if ($path === null || ! is_file($path.'/composer.json')) { + return []; + } + + $patches = json_decode((string) file_get_contents($path.'/composer.json'), true)['extra']['patches'] ?? []; + + return is_array($patches) ? $patches : []; + } +} diff --git a/src/Doctor/Infrastructure/Checks/PatternCacheCheck.php b/src/Doctor/Infrastructure/Checks/PatternCacheCheck.php new file mode 100644 index 00000000..826928d1 --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/PatternCacheCheck.php @@ -0,0 +1,72 @@ + substr($file, strlen('patterns/')), + array_filter(ActiveTheme::files('patterns', ['php']), static fn (string $file): bool => ActiveTheme::patternHasHeader($directory.'/'.$file)), + )); + + if ($files === []) { + return CheckResult::ok('The theme has no pattern files.'); + } + + if (function_exists('wp_is_development_mode') && wp_is_development_mode('theme')) { + return CheckResult::ok('Theme development mode is on: WordPress reads patterns/ on every request.'); + } + + $theme = wp_get_theme(); + $cached = method_exists($theme, 'get_block_patterns') ? array_keys((array) $theme->get_block_patterns()) : $files; + $missing = array_values(array_diff($files, $cached)); + + if ($missing !== []) { + return CheckResult::warning( + sprintf("%d pattern file(s) are missing from WordPress's cached list: they do not exist for the editor or the templates.", count($missing)), + array_map(static fn (string $file): string => 'patterns/'.$file, $missing), + "wp eval 'wp_get_theme()->delete_pattern_cache();' (or set WP_DEVELOPMENT_MODE=theme while developing)", + ); + } + + return CheckResult::ok("WordPress's cached list matches patterns/."); + } +} diff --git a/src/Doctor/Infrastructure/Checks/PatternFilesCheck.php b/src/Doctor/Infrastructure/Checks/PatternFilesCheck.php new file mode 100644 index 00000000..213a81b9 --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/PatternFilesCheck.php @@ -0,0 +1,66 @@ + ! ActiveTheme::patternHasHeader($directory.'/'.$file), + )); + + if ($ignored !== [] || $headerless !== []) { + return CheckResult::warning( + sprintf('%d pattern file(s) are never registered by WordPress.', count($ignored) + count($headerless)), + [ + ...array_map(static fn (string $file): string => $file.': WordPress reads only .php files in patterns/', $ignored), + ...array_map(static fn (string $file): string => $file.': no Title or Slug in its header', $headerless), + ], + 'Make each one a .php file whose header is a docblock with Title and Slug (/** Title: … Slug: my-theme/… */)', + ); + } + + return CheckResult::ok('Every pattern file is one WordPress registers.'); + } +} diff --git a/src/Doctor/Infrastructure/Checks/ThemeBuildCheck.php b/src/Doctor/Infrastructure/Checks/ThemeBuildCheck.php new file mode 100644 index 00000000..1bc01208 --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/ThemeBuildCheck.php @@ -0,0 +1,84 @@ +availability->isMissing()) { + return CheckResult::error( + 'No theme is installed: the front end cannot render.', + [], + 'php artisan pollora:make:theme my-theme', + ); + } + + $directory = ActiveTheme::directory(); + + if ($directory === null || ! is_file($directory.'/vite.config.js')) { + return CheckResult::ok('The active theme has no Vite build.'); + } + + $container = $this->assets->getContainer('theme'); + + if (! $container instanceof AssetContainer) { + return CheckResult::skipped('The active theme registers no asset container.'); + } + + if (is_file($container->getHotFile())) { + return CheckResult::ok("Vite's dev server serves the assets.", [ + 'hot file: '.$container->getHotFile().' (delete it if the dev server is stopped)', + ]); + } + + $manifest = public_path(trim($container->getBuildDirectory(), '/').'/'.ltrim($container->getManifestPath(), '/')); + + if (! is_file($manifest)) { + return CheckResult::error( + 'The theme is not built: its asset URLs are empty and the page renders unstyled.', + ['no manifest at '.$manifest], + sprintf('cd %s && npm install && npm run build', ActiveTheme::relativeDirectory()), + ); + } + + return CheckResult::ok('The theme is built.'); + } +} diff --git a/src/Doctor/Infrastructure/Checks/ThemeDirectoryCheck.php b/src/Doctor/Infrastructure/Checks/ThemeDirectoryCheck.php new file mode 100644 index 00000000..69438523 --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/ThemeDirectoryCheck.php @@ -0,0 +1,57 @@ + preg_match('/%theme_[a-z_]+%/', (string) file_get_contents($directory.'/'.$file)) === 1, + )); + + if ($carrying !== []) { + return CheckResult::error( + sprintf('%d file(s) still carry %%theme_*%% placeholders: the theme was copied from a template instead of generated.', count($carrying)), + array_slice($carrying, 0, 20), + sprintf('php artisan pollora:make:theme %s --repository=', ActiveTheme::slug() ?? 'my-theme'), + ); + } + + return CheckResult::ok('No placeholder left in the theme.'); + } +} diff --git a/src/Doctor/Infrastructure/Checks/WordPressCorePatchCheck.php b/src/Doctor/Infrastructure/Checks/WordPressCorePatchCheck.php new file mode 100644 index 00000000..86f093d3 --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/WordPressCorePatchCheck.php @@ -0,0 +1,78 @@ +includesDirectory ?? (defined('ABSPATH') && defined('WPINC') ? ABSPATH.WPINC : null); + $file = $includes === null ? null : rtrim($includes, '/').'/l10n.php'; + + if ($file === null || ! is_file($file)) { + return CheckResult::skipped('WordPress is not loaded.'); + } + + if (! str_contains((string) file_get_contents($file), 'function __wp(')) { + return CheckResult::error( + "WordPress core is not patched: its __() still takes the place of Laravel's and Pollora's.", + [$file.' declares __(), not __wp()'], + 'composer patches-relock && composer patches-repatch', + ); + } + + $translation = $this->translationInfo instanceof Closure + ? ($this->translationInfo)() + : resolve(SystemInfoCollector::class)->collectTranslationInfo(); + + if (! $translation['override_active']) { + return CheckResult::warning( + "The core is patched, but __() is not Pollora's: WordPress translations through __() are lost.", + ['__() is declared in '.$translation['helper_file']], + 'Check that pollora/helper-overrider is installed, then run composer dump-autoload', + ); + } + + return CheckResult::ok("The core is patched, and __() is Pollora's."); + } +} diff --git a/src/Doctor/Infrastructure/Providers/DoctorServiceProvider.php b/src/Doctor/Infrastructure/Providers/DoctorServiceProvider.php new file mode 100644 index 00000000..a51060ef --- /dev/null +++ b/src/Doctor/Infrastructure/Providers/DoctorServiceProvider.php @@ -0,0 +1,64 @@ + + */ + private const array CHECKS = [ + WordPressCorePatchCheck::class, + PatchesLockCheck::class, + EnvironmentFileCheck::class, + DiscoveryCacheCheck::class, + ThemeBuildCheck::class, + ThemeDirectoryCheck::class, + ThemePlaceholdersCheck::class, + PatternFilesCheck::class, + PatternCacheCheck::class, + BlockThemeRoutesCheck::class, + BlockRegistrationCheck::class, + ]; + + public function register(): void + { + $this->app->tag(self::CHECKS, self::CHECKS_TAG); + $this->app->singleton(Doctor::class, fn ($app): Doctor => new Doctor($app->tagged(self::CHECKS_TAG))); + + if ($this->app->runningInConsole()) { + $this->commands([DoctorCommand::class]); + } + } + + public function boot(Filter $filter): void + { + $filter->add('site_status_tests', fn (array $tests): array => $this->app->make(SiteHealthTests::class)->register($tests)); + } +} diff --git a/src/Doctor/Infrastructure/Support/ActiveTheme.php b/src/Doctor/Infrastructure/Support/ActiveTheme.php new file mode 100644 index 00000000..fc272a0b --- /dev/null +++ b/src/Doctor/Infrastructure/Support/ActiveTheme.php @@ -0,0 +1,89 @@ + $extensions + * @return list paths relative to the theme directory + */ + public static function files(string $subdirectory, array $extensions, int $limit = 5000): array + { + $directory = self::directory(); + $root = $directory === null ? null : rtrim($directory.'/'.$subdirectory, '/'); + + if ($root === null || ! is_dir($root)) { + return []; + } + + $iterator = new \RecursiveIteratorIterator(new \RecursiveCallbackFilterIterator( + new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), + static fn (\SplFileInfo $file): bool => ! in_array($file->getFilename(), ['node_modules', 'vendor', '.git'], true), + )); + + $files = []; + + foreach ($iterator as $file) { + if (in_array(strtolower($file->getExtension()), $extensions, true)) { + $files[] = substr($file->getPathname(), strlen($directory) + 1); + } + + if (count($files) >= $limit) { + break; + } + } + + sort($files); + + return $files; + } + + /** + * Whether a pattern file has the Title and Slug WordPress needs to register it. + */ + public static function patternHasHeader(string $file): bool + { + $header = (string) file_get_contents($file, false, null, 0, 8192); + + return preg_match('/^[ \t\/*#@]*Title:/mi', $header) === 1 && preg_match('/^[ \t\/*#@]*Slug:/mi', $header) === 1; + } +} diff --git a/src/Doctor/UI/Console/DoctorCommand.php b/src/Doctor/UI/Console/DoctorCommand.php new file mode 100644 index 00000000..83210664 --- /dev/null +++ b/src/Doctor/UI/Console/DoctorCommand.php @@ -0,0 +1,75 @@ + '✓', + 'warning' => '!', + 'error' => '✗', + 'skipped' => '–', + ]; + + public function handle(Doctor $doctor): int + { + $report = $doctor->run(RunContext::Console); + + $counts = array_count_values(array_map(fn (array $entry): string => $entry['result']->status->value, $report)); + $errors = $counts[CheckStatus::Error->value] ?? 0; + $warnings = $counts[CheckStatus::Warning->value] ?? 0; + + if ($this->option('json')) { + $this->line((string) json_encode([ + 'errors' => $errors, + 'warnings' => $warnings, + 'checks' => array_map(fn (array $entry): array => [ + 'id' => $entry['check']->id(), + 'label' => $entry['check']->label(), + ...$entry['result']->toArray(), + ], $report), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return $errors > 0 ? self::FAILURE : self::SUCCESS; + } + + $this->newLine(); + + foreach ($report as ['check' => $check, 'result' => $result]) { + $this->line(sprintf(' %s %s — %s', self::SYMBOLS[$result->status->value], $check->label(), $result->summary)); + + foreach ($result->details as $detail) { + $this->line(' '.$detail.''); + } + + if ($result->fix !== null && in_array($result->status, [CheckStatus::Error, CheckStatus::Warning], true)) { + $this->line(' → '.$result->fix.''); + } + } + + $this->newLine(); + $this->line(sprintf(' %d error(s), %d warning(s).', $errors, $warnings)); + $this->line(' Checks that only a web request can make run in Tools › Site Health, in wp-admin.'); + $this->newLine(); + + return $errors > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/src/Doctor/UI/Http/SiteHealthTests.php b/src/Doctor/UI/Http/SiteHealthTests.php new file mode 100644 index 00000000..e3d1dab9 --- /dev/null +++ b/src/Doctor/UI/Http/SiteHealthTests.php @@ -0,0 +1,75 @@ + 'good', + 'warning' => 'recommended', + 'error' => 'critical', + 'skipped' => 'good', + ]; + + public function __construct(private Doctor $doctor) {} + + /** + * Filter callback for `site_status_tests`. + * + * @param array $tests + * @return array + */ + public function register(array $tests): array + { + foreach ($this->doctor->checksFor(RunContext::Http) as $check) { + $tests['direct']['pollora_'.str_replace('-', '_', $check->id())] = [ + 'label' => $check->label(), + 'test' => fn (): array => $this->result($check, $this->doctor->runCheck($check, RunContext::Http)), + ]; + } + + return $tests; + } + + /** + * @return array + */ + public function result(CheckInterface $check, CheckResult $result): array + { + $description = '

'.esc_html($result->summary).'

'; + + if ($result->details !== []) { + $description .= '
    '.implode('', array_map(fn (string $detail): string => '
  • '.esc_html($detail).'
  • ', $result->details)).'
'; + } + + $actions = ''; + + if ($result->fix !== null && in_array($result->status, [CheckStatus::Error, CheckStatus::Warning], true)) { + $actions = '

'.esc_html__('To fix it:', 'pollora').' '.esc_html($result->fix).'

'; + } + + return [ + 'label' => $result->status === CheckStatus::Ok ? $check->label() : $check->label().' — '.$result->summary, + 'status' => self::STATUSES[$result->status->value], + 'badge' => ['label' => 'Pollora', 'color' => 'blue'], + 'description' => $description, + 'actions' => $actions, + 'test' => 'pollora_'.str_replace('-', '_', $check->id()), + ]; + } +} diff --git a/src/Providers/PolloraServiceProvider.php b/src/Providers/PolloraServiceProvider.php index 1de85a9a..22784206 100755 --- a/src/Providers/PolloraServiceProvider.php +++ b/src/Providers/PolloraServiceProvider.php @@ -17,6 +17,7 @@ use Pollora\Config\Infrastructure\Providers\ConfigServiceProvider; use Pollora\Dashboard\Infrastructure\Providers\DashboardServiceProvider; use Pollora\Discovery\Infrastructure\Providers\DiscoveryServiceProvider; +use Pollora\Doctor\Infrastructure\Providers\DoctorServiceProvider; use Pollora\Events\WordPress\WordPressEventServiceProvider; use Pollora\Exceptions\Infrastructure\Providers\ExceptionServiceProvider; use Pollora\Foundation\Providers\ArtisanServiceProvider; @@ -133,6 +134,7 @@ public function register(): void // Dashboard $this->app->register(DashboardServiceProvider::class); + $this->app->register(DoctorServiceProvider::class); } /** diff --git a/tests/Feature/Doctor/DoctorTest.php b/tests/Feature/Doctor/DoctorTest.php new file mode 100644 index 00000000..33ccfb18 --- /dev/null +++ b/tests/Feature/Doctor/DoctorTest.php @@ -0,0 +1,147 @@ + $contexts + */ +function fakeCheck(string $id, CheckResult|Throwable $result, array $contexts = [RunContext::Console, RunContext::Http]): CheckInterface +{ + return new readonly class($id, $result, $contexts) implements CheckInterface + { + /** @param list $contexts */ + public function __construct(private string $id, private CheckResult|Throwable $result, private array $contexts) {} + + public function id(): string + { + return $this->id; + } + + public function label(): string + { + return ucfirst(str_replace('-', ' ', $this->id)); + } + + public function runsIn(): array + { + return $this->contexts; + } + + public function run(RunContext $context): CheckResult + { + if ($this->result instanceof Throwable) { + throw $this->result; + } + + return $this->result; + } + }; +} + +/** Run pollora:doctor on these checks. @return array{exit: int, output: string} */ +function runDoctor(array $checks, array $options = []): array +{ + app()->instance(Doctor::class, new Doctor($checks)); + resolve(Kernel::class)->registerCommand(resolve(DoctorCommand::class)); + + $exit = Artisan::call('pollora:doctor', $options); + + return ['exit' => $exit, 'output' => Artisan::output()]; +} + +describe('Doctor', function (): void { + it('runs only the checks of the context', function (): void { + $doctor = new Doctor([ + fakeCheck('both', CheckResult::ok('fine')), + fakeCheck('web-only', CheckResult::ok('fine'), [RunContext::Http]), + ]); + + expect(array_map(fn (CheckInterface $check): string => $check->id(), $doctor->checksFor(RunContext::Console)))->toBe(['both']) + ->and($doctor->checksFor(RunContext::Http))->toHaveCount(2); + }); + + it('reports a check that throws as an error of that check, and runs the others', function (): void { + $report = (new Doctor([ + fakeCheck('broken', new RuntimeException('cannot read the lock')), + fakeCheck('fine', CheckResult::ok('fine')), + ]))->run(RunContext::Console); + + expect($report[0]['result']->status->value)->toBe('error') + ->and($report[0]['result']->summary)->toContain('cannot read the lock') + ->and($report[1]['result']->status->value)->toBe('ok'); + }); +}); + +describe('pollora:doctor', function (): void { + it('exits 1 on an error, and prints the fix under it', function (): void { + $result = runDoctor([fakeCheck('patches-lock', CheckResult::error('The lock is stale.', ['a: b'], 'composer patches-relock'))]); + + expect($result['exit'])->toBe(1) + ->and($result['output'])->toContain('The lock is stale.') + ->and($result['output'])->toContain('a: b') + ->and($result['output'])->toContain('→ composer patches-relock') + ->and($result['output'])->toContain('1 error(s), 0 warning(s)'); + }); + + it('exits 0 on warnings alone', function (): void { + expect(runDoctor([fakeCheck('pattern-cache', CheckResult::warning('Stale.', [], 'wp eval …'))])['exit'])->toBe(0); + }); + + it('does not run a check that needs a web request', function (): void { + $result = runDoctor([fakeCheck('block-registration', CheckResult::error('Missing blocks.'), [RunContext::Http])]); + + expect($result['exit'])->toBe(0) + ->and($result['output'])->not->toContain('Missing blocks.'); + }); + + it('prints JSON with --json', function (): void { + $result = runDoctor([fakeCheck('theme-build', CheckResult::error('Not built.', [], 'npm run build'))], ['--json' => true]); + $json = json_decode($result['output'], true); + + expect($result['exit'])->toBe(1) + ->and($json['errors'])->toBe(1) + ->and($json['checks'][0])->toMatchArray(['id' => 'theme-build', 'status' => 'error', 'summary' => 'Not built.', 'fix' => 'npm run build']); + }); +}); + +describe('Site Health', function (): void { + it('adds one direct test per check that runs in a web request', function (): void { + $tests = (new SiteHealthTests(new Doctor([ + fakeCheck('theme-build', CheckResult::ok('Built.')), + fakeCheck('discovery-cache', CheckResult::ok('Fine.'), [RunContext::Console]), + ])))->register(['direct' => [], 'async' => []]); + + expect(array_keys($tests['direct']))->toBe(['pollora_theme_build']); + }); + + it('turns an error into a critical result that carries the fix', function (): void { + $check = fakeCheck('patches-lock', CheckResult::error('The lock is stale.', ['a: b'], 'composer patches-relock')); + $tests = (new SiteHealthTests(new Doctor([$check])))->register(['direct' => []]); + $result = ($tests['direct']['pollora_patches_lock']['test'])(); + + expect($result['status'])->toBe('critical') + ->and($result['badge']['label'])->toBe('Pollora') + ->and($result['label'])->toContain('The lock is stale.') + ->and($result['description'])->toContain('a: b') + ->and($result['actions'])->toContain('composer patches-relock'); + }); + + it('maps a warning to recommended and an ok to good', function (): void { + $health = new SiteHealthTests(new Doctor([])); + $check = fakeCheck('x', CheckResult::ok('fine')); + + expect($health->result($check, CheckResult::warning('meh'))['status'])->toBe('recommended') + ->and($health->result($check, CheckResult::ok('fine'))['status'])->toBe('good'); + }); +}); diff --git a/tests/Feature/Doctor/InstallChecksTest.php b/tests/Feature/Doctor/InstallChecksTest.php new file mode 100644 index 00000000..29bfbd27 --- /dev/null +++ b/tests/Feature/Doctor/InstallChecksTest.php @@ -0,0 +1,235 @@ +project = sys_get_temp_dir().'/pollora-doctor-'.uniqid(); + mkdir($this->project, 0777, true); + $this->previousBasePath = app()->basePath(); + app()->setBasePath($this->project); +}); + +afterEach(function (): void { + app()->setBasePath($this->previousBasePath); + (new Filesystem)->deleteDirectory($this->project); +}); + +function corePatchCheck(string $project, bool $overrideActive): WordPressCorePatchCheck +{ + return new WordPressCorePatchCheck($project.'/wp-includes', fn (): array => [ + 'override_active' => $overrideActive, + 'helper_file' => '/vendor/laravel/framework/src/Illuminate/Foundation/helpers.php', + ]); +} + +describe('WordPress core patch', function (): void { + beforeEach(function (): void { + mkdir($this->project.'/wp-includes'); + }); + + it('fails when the core still declares __(), as composer-patches leaves it when it skips the patch', function (): void { + file_put_contents($this->project.'/wp-includes/l10n.php', "project, true)->run(RunContext::Console); + + expect($result->status->value)->toBe('error') + ->and($result->fix)->toBe('composer patches-relock && composer patches-repatch'); + }); + + it("warns when the core is patched but __() is not Pollora's", function (): void { + file_put_contents($this->project.'/wp-includes/l10n.php', "project, false)->run(RunContext::Console); + + expect($result->status->value)->toBe('warning') + ->and($result->details[0])->toContain('laravel/framework'); + }); + + it("passes a patched core whose __() is Pollora's", function (): void { + file_put_contents($this->project.'/wp-includes/l10n.php', "project, true)->run(RunContext::Console)->status->value)->toBe('ok'); + }); +}); + +describe('Composer patches lock', function (): void { + /** The patches the installed framework declares — this repository's own composer.json. */ + function declaredFrameworkPatches(): array + { + return json_decode((string) file_get_contents(InstalledVersions::getInstallPath('pollora/framework').'/composer.json'), true)['extra']['patches']; + } + + function writeLock(string $project, array $patches): void + { + $lock = []; + + foreach ($patches as $package => $entries) { + foreach ($entries as $description => $url) { + $lock[$package][] = ['package' => $package, 'description' => $description, 'url' => $url, 'sha256' => 'x']; + } + } + + file_put_contents($project.'/patches.lock.json', json_encode(['_hash' => 'x', 'patches' => $lock])); + } + + it('fails without patches.lock.json', function (): void { + expect((new PatchesLockCheck)->run(RunContext::Console)->status->value)->toBe('error'); + }); + + it('fails when the lock pins an older patch than the framework declares', function (): void { + $patches = declaredFrameworkPatches(); + $package = array_key_first($patches); + $patches[$package][array_key_first($patches[$package])] = 'https://raw.githubusercontent.com/Pollora/framework/an-old-commit/patches/wordpress-core.patch'; + writeLock($this->project, $patches); + + $result = (new PatchesLockCheck)->run(RunContext::Console); + + expect($result->status->value)->toBe('error') + ->and($result->details[0])->toStartWith($package.': '); + }); + + it('passes a lock that lists every declared patch', function (): void { + writeLock($this->project, declaredFrameworkPatches()); + + expect((new PatchesLockCheck)->run(RunContext::Console)->status->value)->toBe('ok'); + }); +}); + +describe('Environment file', function (): void { + it('fails on a Bedrock name Pollora does not read', function (): void { + file_put_contents($this->project.'/.env', "APP_URL=https://site.test\nDB_NAME=site\nDB_USERNAME=db\n"); + + $result = (new EnvironmentFileCheck)->run(RunContext::Console); + + expect($result->status->value)->toBe('error') + ->and($result->details)->toContain('DB_NAME is not read: Pollora reads DB_DATABASE'); + }); + + it('fails when MySQL settings sit on a sqlite connection', function (): void { + config(['database.default' => 'sqlite']); + file_put_contents($this->project.'/.env', "DB_HOST=db\nDB_DATABASE=db\n"); + + expect((new EnvironmentFileCheck)->run(RunContext::Console)->details) + ->toContain('DB_HOST is set, but the connection is sqlite: DB_CONNECTION is missing or not mysql'); + }); + + it('warns when WP_HOME disagrees with the APP_URL in use', function (): void { + config(['database.default' => 'mysql']); + file_put_contents($this->project.'/.env', "APP_URL=https://site.test\nWP_HOME=https://old.test\n"); + + expect((new EnvironmentFileCheck)->run(RunContext::Console)->status->value)->toBe('warning'); + }); + + it("passes Laravel's names", function (): void { + config(['database.default' => 'mysql']); + file_put_contents($this->project.'/.env', "APP_URL=https://site.test\nDB_CONNECTION=mysql\nDB_HOST=db\nDB_DATABASE=db\nDB_USERNAME=db\n"); + + expect((new EnvironmentFileCheck)->run(RunContext::Console)->status->value)->toBe('ok'); + }); +}); + +describe('Discovery cache', function (): void { + function discoveryCheck(bool $cacheEnabled, array $missing): DiscoveryCacheCheck + { + $location = Mockery::mock(DiscoveryLocationInterface::class); + $manager = Mockery::mock(DiscoveryManager::class); + $manager->shouldReceive('getLocations')->andReturn(collect([$location])); + $cache = Mockery::mock(DiscoveryCacheManager::class); + $cache->shouldReceive('isCacheEnabled')->andReturn($cacheEnabled); + $cache->shouldReceive('classesMissingFromCache')->with($location)->andReturn($missing); + + return new DiscoveryCacheCheck($manager, $cache); + } + + it('fails when classes on disk are missing from the cache, and names them', function (): void { + $result = discoveryCheck(true, ['App\\Hooks\\NewAction'])->run(RunContext::Console); + + expect($result->status->value)->toBe('error') + ->and($result->details)->toBe(['App\\Hooks\\NewAction']) + ->and($result->fix)->toBe('php artisan discovery:clear'); + }); + + it('passes when the cache is off', function (): void { + expect(discoveryCheck(false, ['App\\Anything'])->run(RunContext::Console)->status->value)->toBe('ok'); + }); +}); + +describe('DiscoveryCacheManager::classesMissingFromCache()', function (): void { + /** A cache driver holding what a test puts in it. */ + final class DoctorArrayDiscoverCacheDriver implements DiscoverCacheDriver + { + /** @var array> */ + public static array $entries = []; + + public function has(string $id): bool + { + return array_key_exists($id, self::$entries); + } + + public function get(string $id): array + { + return self::$entries[$id]; + } + + public function put(string $id, array $discovered): void + { + self::$entries[$id] = $discovered; + } + + public function forget(string $id): void + { + unset(self::$entries[$id]); + } + } + + function cacheManager(bool $debug): DiscoveryCacheManager + { + config(['structure-discoverer.cache.driver' => DoctorArrayDiscoverCacheDriver::class]); + $detector = Mockery::mock(DebugDetectorInterface::class); + $detector->shouldReceive('isDebugMode')->andReturn($debug); + + return new DiscoveryCacheManager(app(), $detector); + } + + beforeEach(function (): void { + DoctorArrayDiscoverCacheDriver::$entries = []; + putFile($this->project.'/app/Hooks/NewAction.php', "location = new DiscoveryLocation('DoctorFixture\\', $this->project.'/app'); + }); + + it('names a class on disk that the cached entry lacks', function (): void { + DoctorArrayDiscoverCacheDriver::$entries['discovery_'.md5($this->location->getPath())] = []; + + expect(cacheManager(debug: false)->classesMissingFromCache($this->location))->toBe(['DoctorFixture\\Hooks\\NewAction']); + }); + + it('has nothing to compare when the location was never cached, or in debug mode', function (): void { + expect(cacheManager(debug: false)->classesMissingFromCache($this->location))->toBeNull() + ->and(cacheManager(debug: true)->classesMissingFromCache($this->location))->toBeNull(); + }); +}); diff --git a/tests/Feature/Doctor/ThemeChecksTest.php b/tests/Feature/Doctor/ThemeChecksTest.php new file mode 100644 index 00000000..fa99f04f --- /dev/null +++ b/tests/Feature/Doctor/ThemeChecksTest.php @@ -0,0 +1,233 @@ +root = sys_get_temp_dir().'/pollora-doctor-theme-'.uniqid(); + $this->theme = $this->root.'/themes/journal'; + mkdir($this->theme, 0777, true); + Functions\when('get_stylesheet_directory')->alias(fn (): string => $this->theme); + Functions\when('get_stylesheet')->justReturn('journal'); +}); + +afterEach(function (): void { + (new Filesystem)->deleteDirectory($this->root); +}); + +if (! function_exists('putFile')) { + function putFile(string $path, string $contents): void + { + if (! is_dir(dirname($path))) { + mkdir(dirname($path), 0777, true); + } + + file_put_contents($path, $contents); + } +} + +describe('Theme and its build', function (): void { + function buildCheck(string $root, bool $themeMissing): ThemeBuildCheck + { + $registrar = Mockery::mock(ThemeRegistrarInterface::class); + $registrar->shouldReceive('getActiveTheme')->andReturn($themeMissing ? null : Mockery::mock(ThemeModuleInterface::class)); + $container = new Container; + $container->instance(ThemeRegistrarInterface::class, $registrar); + + $assets = new AssetManager(Mockery::mock(AssetRetrievalService::class)); + $assets->addContainer('theme', [ + 'hot_file' => $root.'/public/journal.hot', + 'build_directory' => 'build/theme/journal', + 'manifest_path' => 'manifest.json', + ]); + app()->usePublicPath($root.'/public'); + + return new ThemeBuildCheck(new ThemeAvailability($container), $assets); + } + + it('fails when no theme is installed', function (): void { + Functions\when('get_stylesheet_directory')->justReturn($this->root.'/themes/missing'); + + expect(buildCheck($this->root, themeMissing: true)->run(RunContext::Console)->status->value)->toBe('error'); + }); + + it('fails when a Vite theme has neither a manifest nor a dev server, and says where to build', function (): void { + putFile($this->theme.'/vite.config.js', 'export default {}'); + $this->app->setBasePath($this->root); + + $result = buildCheck($this->root, themeMissing: false)->run(RunContext::Console); + + expect($result->status->value)->toBe('error') + ->and($result->fix)->toBe('cd themes/journal && npm install && npm run build'); + }); + + it('passes a built theme, and one served by the dev server', function (): void { + putFile($this->theme.'/vite.config.js', 'export default {}'); + putFile($this->root.'/public/build/theme/journal/manifest.json', '{}'); + expect(buildCheck($this->root, themeMissing: false)->run(RunContext::Console)->status->value)->toBe('ok'); + + unlink($this->root.'/public/build/theme/journal/manifest.json'); + putFile($this->root.'/public/journal.hot', 'https://localhost:5173'); + expect(buildCheck($this->root, themeMissing: false)->run(RunContext::Console)->status->value)->toBe('ok'); + }); +}); + +describe('Theme directory', function (): void { + it('fails when the theme is a symlink to a directory of another name', function (): void { + (new Filesystem)->deleteDirectory($this->theme); + mkdir($this->root.'/repos/theme-journal', 0777, true); + symlink($this->root.'/repos/theme-journal', $this->theme); + + $result = (new ThemeDirectoryCheck)->run(RunContext::Console); + + expect($result->status->value)->toBe('error') + ->and($result->summary)->toContain('public/build/theme/theme-journal'); + }); + + it('passes a real directory', function (): void { + expect((new ThemeDirectoryCheck)->run(RunContext::Console)->status->value)->toBe('ok'); + }); +}); + +describe('Theme placeholders', function (): void { + it('fails on a template copied instead of generated', function (): void { + putFile($this->theme.'/app/Providers/AssetServiceProvider.php', "theme.'/node_modules/pkg/index.js', '%theme_name%'); + + $result = (new ThemePlaceholdersCheck)->run(RunContext::Console); + + expect($result->status->value)->toBe('error') + ->and($result->details)->toBe(['app/Providers/AssetServiceProvider.php']); + }); + + it('passes a generated theme', function (): void { + putFile($this->theme.'/app/Providers/AssetServiceProvider.php', "run(RunContext::Console)->status->value)->toBe('ok'); + }); +}); + +describe('Theme pattern files', function (): void { + it('warns about an .html pattern and a pattern without a Slug', function (): void { + putFile($this->theme.'/patterns/masthead.html', ''); + putFile($this->theme.'/patterns/footer.php', ""); + putFile($this->theme.'/patterns/header.php', ""); + + $result = (new PatternFilesCheck)->run(RunContext::Console); + + expect($result->status->value)->toBe('warning') + ->and($result->details)->toBe([ + 'patterns/masthead.html: WordPress reads only .php files in patterns/', + 'patterns/footer.php: no Title or Slug in its header', + ]); + }); +}); + +describe('Theme pattern cache', function (): void { + beforeEach(function (): void { + putFile($this->theme.'/patterns/header.php', ""); + putFile($this->theme.'/patterns/new.php', ""); + Functions\when('wp_is_development_mode')->justReturn(false); + }); + + it("warns about a pattern file missing from WordPress's cached list", function (): void { + Functions\when('wp_get_theme')->justReturn(new class + { + public function get_block_patterns(): array + { + return ['header.php' => ['slug' => 'journal/header']]; + } + }); + + $result = (new PatternCacheCheck)->run(RunContext::Console); + + expect($result->status->value)->toBe('warning') + ->and($result->details)->toBe(['patterns/new.php']); + }); + + it('passes in theme development mode, where WordPress does not cache', function (): void { + Functions\when('wp_is_development_mode')->justReturn(true); + Functions\when('wp_get_theme')->justReturn(new class + { + public function get_block_patterns(): array + { + return []; + } + }); + + expect((new PatternCacheCheck)->run(RunContext::Console)->status->value)->toBe('ok'); + }); +}); + +describe('Routes over block templates', function (): void { + it('warns about a Route::wp() route on a block theme', function (): void { + Functions\when('wp_is_block_theme')->justReturn(true); + $route = (new Route(['GET'], 'single_x', fn (): string => ''))->setIsWordPressRoute(true)->setCondition('is_single'); + $routes = new RouteCollection; + $routes->add($route); + + $router = Mockery::mock(Router::class); + $router->shouldReceive('getRoutes')->andReturn($routes); + + $result = (new BlockThemeRoutesCheck($router))->run(RunContext::Console); + + expect($result->status->value)->toBe('warning') + ->and($result->details[0])->toStartWith("Route::wp('is_single')"); + }); + + it('does not apply to a classic theme', function (): void { + Functions\when('wp_is_block_theme')->justReturn(false); + + expect((new BlockThemeRoutesCheck(Mockery::mock(Router::class)))->run(RunContext::Console)->status->value)->toBe('skipped'); + }); +}); + +describe('Theme blocks registered', function (): void { + beforeEach(function (): void { + putFile($this->theme.'/resources/views/blocks/hero/block.json', '{"name": "journal/hero"}'); + putFile($this->theme.'/resources/views/blocks/card/block.json', '{"name": "journal/card"}'); + }); + + it('fails when a block of the theme is not registered in the request', function (): void { + WP_Block_Type_Registry::$registered = ['journal/hero']; + + $result = (new BlockRegistrationCheck)->run(RunContext::Http); + + expect($result->status->value)->toBe('error') + ->and($result->details)->toBe(['journal/card (resources/views/blocks/card/block.json)']); + }); + + it('passes when every block is registered', function (): void { + WP_Block_Type_Registry::$registered = ['journal/hero', 'journal/card']; + + expect((new BlockRegistrationCheck)->run(RunContext::Http)->status->value)->toBe('ok'); + }); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 8c7d283e..a1beee54 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -26,7 +26,7 @@ ->afterEach(function (): void { \Brain\Monkey\tearDown(); }) - ->in('Feature/Route', 'Feature/Theme', 'Feature/Discovery', 'Feature/Hook', 'Feature/VersionCheck', 'Feature/Dashboard', 'Feature/Schedule', 'Feature/Block', 'Feature/Plugin', 'Feature/Asset', 'Feature/WordPress'); + ->in('Feature/Route', 'Feature/Theme', 'Feature/Discovery', 'Feature/Hook', 'Feature/VersionCheck', 'Feature/Dashboard', 'Feature/Doctor', 'Feature/Schedule', 'Feature/Block', 'Feature/Plugin', 'Feature/Asset', 'Feature/WordPress'); /* |-------------------------------------------------------------------------- diff --git a/tests/e2e/specs/doctor.spec.ts b/tests/e2e/specs/doctor.spec.ts new file mode 100644 index 00000000..3a42e0fa --- /dev/null +++ b/tests/e2e/specs/doctor.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@wordpress/e2e-test-utils-playwright'; + +/** + * pollora:doctor's checks in WordPress's Site Health, read by an administrator. + * + * The point of running them there: an administrator's web request is the boot a + * visitor gets. Blocks registered under WP-CLI but missing over HTTP were exactly + * that kind of failure, and the console check could never have seen it. + */ +test('Site Health lists the Pollora checks, the web-only block check included', async ({ admin, page }) => { + await admin.visitAdminPage('site-health.php'); + + // Site Health lists its results once every test, asynchronous ones included, has answered. + await expect(page.getByText('Results are still loading')).toHaveCount(0, { timeout: 60_000 }); + + // Passed tests sit in a collapsed list: read the triggers, visible or not. + const results = page.locator('.health-check-accordion-trigger'); + + for (const label of ['WordPress core patch', 'Composer patches lock', 'Theme and its build', 'Theme blocks registered']) { + await expect(results.filter({ hasText: label }).filter({ hasText: 'Pollora' }), label).toHaveCount(1); + } + + // The console-only check has no place there. + await expect(results.filter({ hasText: 'Discovery cache' })).toHaveCount(0); +}); From 289fe99a5dd26a2db6eadc76a49ea8797321eed8 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 30 Sep 2026 10:24:00 +0200 Subject: [PATCH 4/9] fix: enqueue the Vite client with no version The modules Vite serves import /@vite/client by its bare URL; enqueued with WordPress's version appended, the client loaded twice, as two modules with two HMR connections. Regression from v13.34.0-beta. --- CHANGELOG.md | 3 +++ src/Asset/Infrastructure/Services/AssetEnqueuer.php | 4 +++- tests/Feature/Asset/AssetEnqueuerTest.php | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 577f9c5f..5d7e6192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.0-beta...develop) +### Fixed +- While Vite ran hot, its client was enqueued with WordPress's version appended (`@vite/client?ver=7.1.2`). The modules Vite serves import `/@vite/client` by its bare URL, so the browser loaded the client twice, as two modules with two HMR connections. It is enqueued with no version, like the entries (regression from v13.34.0-beta) + ### Changed - `pollora:make:theme` activates the generated theme only where the site needs one. A site with no usable theme — a first install — gets it without a question; a site that already has one keeps it unless the answer is "yes", now the default "no", so `--no-interaction` never replaces a working theme. `--activate` and `--no-activate` settle it without asking, and `pollora:install` passes `--activate`. Activation goes through `switch_theme()`, which fires `switch_theme` and `after_switch_theme`; the options were written directly before, so those hooks never ran diff --git a/src/Asset/Infrastructure/Services/AssetEnqueuer.php b/src/Asset/Infrastructure/Services/AssetEnqueuer.php index 0d9e3989..f6ad6ab9 100755 --- a/src/Asset/Infrastructure/Services/AssetEnqueuer.php +++ b/src/Asset/Infrastructure/Services/AssetEnqueuer.php @@ -395,7 +395,9 @@ protected function loadViteClient(string $hook): void $url = $this->viteManager instanceof ViteManager ? $this->viteManager->clientUrl() : ''; if ($url !== '') { - wp_enqueue_script_module('vite-client/'.md5($url), $url); + // No version: the modules Vite serves import /@vite/client by its bare URL, + // and a ?ver= copy would be a second client, with its own HMR connection. + wp_enqueue_script_module('vite-client/'.md5($url), $url, [], null); } }, 1); diff --git a/tests/Feature/Asset/AssetEnqueuerTest.php b/tests/Feature/Asset/AssetEnqueuerTest.php index 431e1c7e..de0fb38c 100644 --- a/tests/Feature/Asset/AssetEnqueuerTest.php +++ b/tests/Feature/Asset/AssetEnqueuerTest.php @@ -350,7 +350,8 @@ function viteEnqueuer(ViteManager $viteManager, array $settings = []): AssetEnqu $callback(); expect([$hook, $priority])->toBe(['wp_enqueue_scripts', 1]) - ->and(array_column($this->modules, 'src'))->toBe(['https://site.test:5173/@vite/client']); + ->and(array_column($this->modules, 'src'))->toBe(['https://site.test:5173/@vite/client']) + ->and(array_column($this->modules, 'version'))->toBe([null]); }); }); From d826777b59f169958b75b0011b55d167ab8e32bd Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 30 Sep 2026 10:32:56 +0200 Subject: [PATCH 5/9] feat: doctor checks cover plugins and modules, dev servers and caches The build, symlink, placeholder and block checks now cover every Pollora plugin and enabled module, not only the theme. New: a hot file whose dev server is stopped or not exposed (DDEV's router answers 502 or 404, so a TCP probe is not enough), a build written to another folder than Pollora reads, blocks in the legacy resources/blocks, configuration or routes cached outside production. --- CHANGELOG.md | 4 +- .../Infrastructure/Checks/AssetBuildCheck.php | 176 +++++++++++++ .../Checks/BlockRegistrationCheck.php | 60 ++--- .../Checks/DevelopmentCachesCheck.php | 65 +++++ .../Checks/LegacyBlocksDirectoryCheck.php | 60 +++++ .../Checks/SymlinkedDirectoryCheck.php | 60 +++++ .../Checks/TemplatePlaceholdersCheck.php | 97 ++++++++ .../Infrastructure/Checks/ThemeBuildCheck.php | 84 ------- .../Checks/ThemeDirectoryCheck.php | 57 ----- .../Checks/ThemePlaceholdersCheck.php | 59 ----- .../Providers/DoctorServiceProvider.php | 18 +- .../Infrastructure/Support/ProjectModule.php | 78 ++++++ .../Infrastructure/Support/ProjectModules.php | 104 ++++++++ .../Services/ModuleAssetManager.php | 17 ++ tests/Feature/Doctor/ModuleChecksTest.php | 231 ++++++++++++++++++ tests/Feature/Doctor/ThemeChecksTest.php | 122 --------- tests/e2e/specs/doctor.spec.ts | 2 +- 17 files changed, 934 insertions(+), 360 deletions(-) create mode 100644 src/Doctor/Infrastructure/Checks/AssetBuildCheck.php create mode 100644 src/Doctor/Infrastructure/Checks/DevelopmentCachesCheck.php create mode 100644 src/Doctor/Infrastructure/Checks/LegacyBlocksDirectoryCheck.php create mode 100644 src/Doctor/Infrastructure/Checks/SymlinkedDirectoryCheck.php create mode 100644 src/Doctor/Infrastructure/Checks/TemplatePlaceholdersCheck.php delete mode 100644 src/Doctor/Infrastructure/Checks/ThemeBuildCheck.php delete mode 100644 src/Doctor/Infrastructure/Checks/ThemeDirectoryCheck.php delete mode 100644 src/Doctor/Infrastructure/Checks/ThemePlaceholdersCheck.php create mode 100644 src/Doctor/Infrastructure/Support/ProjectModule.php create mode 100644 src/Doctor/Infrastructure/Support/ProjectModules.php create mode 100644 tests/Feature/Doctor/ModuleChecksTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b293a4d3..c6048bf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.0-beta...develop) ### Added -- `pollora:doctor`: checks a project for the failures that stay silent — the site renders, the command exits 0 — and prints, under each, the command that fixes it. Eleven checks, each from a failure met in practice: WordPress core not patched or `__()` not Pollora's, `patches.lock.json` missing or older than the framework's patches, `.env` names Pollora does not read (`DB_NAME`, `WP_HOME`…) or MySQL settings on a sqlite connection, classes missing from the discovery cache, no theme or an unbuilt one, a theme directory symlinked under another name, `%theme_*%` placeholders left in a theme copied from a template, pattern files WordPress never registers (`.html`, no Title or Slug) or missing from its cache, `Route::wp()` routes answering in place of a block theme's templates. `--json` for scripts; exits 1 on an error -- The same checks in WordPress's **Site Health** (Tools › Site Health), with a "Pollora" badge, plus one that only a web request can make: every block of the active theme is registered. That is the boot a visitor gets — blocks were once registered under WP-CLI but not over HTTP, which a console check would have passed +- `pollora:doctor`: checks a project for the failures that stay silent — the site renders, the command exits 0 — and prints, under each, the command that fixes it. Each check comes from a failure met in practice: WordPress core not patched or `__()` not Pollora's; `patches.lock.json` missing or older than the framework's patches; `.env` names Pollora does not read (`DB_NAME`, `WP_HOME`…) or MySQL settings on a sqlite connection; configuration or routes cached outside production; classes missing from the discovery cache; for the theme, every Pollora plugin and every enabled module: a build missing, written to another folder than Pollora reads, or a hot file pointing at a dev server that is stopped or not exposed, a directory symlinked under another name, `%theme_*%`/`%plugin_*%` placeholders or `.stub` files left from a copied template, blocks still in the legacy `resources/blocks`; pattern files WordPress never registers or has not cached; `Route::wp()` routes answering in place of a block theme's templates. `--json` for scripts; exits 1 on an error +- The same checks in WordPress's **Site Health** (Tools › Site Health), with a "Pollora" badge, plus one that only a web request can make: every block of the theme, the Pollora plugins and the modules is registered. That is the boot a visitor gets — blocks were once registered under WP-CLI but not over HTTP, which a console check would have passed ### Changed - `pollora:make:theme` activates the generated theme only where the site needs one. A site with no usable theme — a first install — gets it without a question; a site that already has one keeps it unless the answer is "yes", now the default "no", so `--no-interaction` never replaces a working theme. `--activate` and `--no-activate` settle it without asking, and `pollora:install` passes `--activate`. Activation goes through `switch_theme()`, which fires `switch_theme` and `after_switch_theme`; the options were written directly before, so those hooks never ran diff --git a/src/Doctor/Infrastructure/Checks/AssetBuildCheck.php b/src/Doctor/Infrastructure/Checks/AssetBuildCheck.php new file mode 100644 index 00000000..caf5f097 --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/AssetBuildCheck.php @@ -0,0 +1,176 @@ +availability->isMissing()) { + return CheckResult::error('No theme is installed: the front end cannot render.', [], 'php artisan pollora:make:theme my-theme'); + } + + $problems = []; + $fixes = []; + $built = 0; + + foreach ($this->modules->all() as $module) { + if (! is_file($module->root.'/vite.config.js')) { + continue; + } + + $problem = $this->problem($module); + + if ($problem === null) { + $built++; + + continue; + } + + [$problems[], $fixes[]] = $problem; + } + + if ($problems !== []) { + return CheckResult::error( + sprintf('%d build(s) cannot be served: their assets do not load.', count($problems)), + $problems, + implode(' ; ', array_unique($fixes)), + ); + } + + return CheckResult::ok(sprintf('%d Vite build(s) where Pollora reads them.', $built)); + } + + /** + * @return array{0: string, 1: string}|null + */ + private function problem(ProjectModule $module): ?array + { + if (is_file($module->hotFile)) { + $url = rtrim(trim((string) file_get_contents($module->hotFile)), '/'); + + $status = $url === '' ? null : $this->probe($url); + + if ($status !== null && $status >= 200 && $status < 300) { + return null; + } + + // A proxy in front of a stopped dev server answers 502-504: that is "not running" too. + if ($status === null || in_array($status, [502, 503, 504], true)) { + return [ + sprintf('%s: its hot file points to %s, where no Vite dev server answers', $module->label(), $url), + sprintf('cd %s && npm run dev, or delete %s', $module->relativeRoot(), $this->relative($module->hotFile)), + ]; + } + + return [ + sprintf('%s: its hot file points to %s, which answers %d for Vite\'s client: what answers there is not the dev server — it is stopped, or its port is not exposed', $module->label(), $url, $status), + sprintf('cd %s && npm run dev, with its port exposed to the browser (DDEV: web_extra_exposed_ports, container_port 5173); or delete %s', $module->relativeRoot(), $this->relative($module->hotFile)), + ]; + } + + if (is_file($module->manifest())) { + return null; + } + + $elsewhere = $this->buildElsewhere($module); + + if ($elsewhere !== null) { + return [ + sprintf('%s: built into %s, but Pollora reads %s', $module->label(), $elsewhere, $this->relative(dirname($module->manifest()))), + sprintf('in %s/vite.config.js, build into public/%s', $module->relativeRoot(), trim($module->buildDirectory, '/')), + ]; + } + + return [ + sprintf('%s: not built — no manifest at %s', $module->label(), $this->relative($module->manifest())), + sprintf('cd %s && npm install && npm run build', $module->relativeRoot()), + ]; + } + + /** A manifest for this module under another public/build folder, relative to the project. */ + private function buildElsewhere(ProjectModule $module): ?string + { + $candidates = glob(public_path('build/*/'.basename(dirname($module->manifest())).'/manifest.json'), GLOB_NOSORT) ?: []; + $candidates = [...$candidates, ...(glob(public_path('build/*/'.basename($module->root).'/manifest.json'), GLOB_NOSORT) ?: [])]; + + foreach (array_unique($candidates) as $candidate) { + if ($candidate !== $module->manifest()) { + return $this->relative(dirname($candidate)); + } + } + + return null; + } + + /** + * The status Vite's client answers at this URL, as the page will request it; null when nothing answers. + * + * A TCP connection proves nothing: DDEV's router listens on the dev server's port + * itself. It answers 502 when Vite is stopped, 404 when the port is not exposed. + */ + private function probe(string $url): ?int + { + if ($this->probe instanceof Closure) { + return ($this->probe)($url); + } + + try { + return Http::withoutVerifying()->timeout(2)->get($url.'/@vite/client')->status(); + } catch (Throwable) { + return null; + } + } + + private function relative(string $path): string + { + $base = rtrim(base_path(), '/').'/'; + + return str_starts_with($path, $base) ? substr($path, strlen($base)) : $path; + } +} diff --git a/src/Doctor/Infrastructure/Checks/BlockRegistrationCheck.php b/src/Doctor/Infrastructure/Checks/BlockRegistrationCheck.php index 64551017..02ef988c 100644 --- a/src/Doctor/Infrastructure/Checks/BlockRegistrationCheck.php +++ b/src/Doctor/Infrastructure/Checks/BlockRegistrationCheck.php @@ -7,18 +7,23 @@ use Pollora\Doctor\Domain\Contracts\CheckInterface; use Pollora\Doctor\Domain\Enums\RunContext; use Pollora\Doctor\Domain\Models\CheckResult; -use Pollora\Doctor\Infrastructure\Support\ActiveTheme; +use Pollora\Doctor\Infrastructure\Support\ProjectModules; /** - * Every block of the active theme is registered in a web request. + * Every block of the theme, the Pollora plugins and the modules is registered in a web request. * - * Up to v13.32.0-beta.7 blocks were registered under WP-CLI only: the console - * saw them all while the editor, the page and the REST API had none. That is - * why this check runs in Site Health — an administrator's web request — and - * never in the console, where it would pass the broken site. + * Up to v13.32.0-beta.7 blocks were registered under WP-CLI only: the console saw + * them all while the editor, the page and the REST API had none. That is why this + * check runs in Site Health — an administrator's web request — and never in the + * console, where it would pass the broken site. */ final readonly class BlockRegistrationCheck implements CheckInterface { + /** Where Pollora looks for blocks, the legacy location included until v15. */ + private const array DIRECTORIES = ['resources/views/blocks', 'resources/blocks']; + + public function __construct(private ProjectModules $modules) {} + public function id(): string { return 'block-registration'; @@ -26,7 +31,7 @@ public function id(): string public function label(): string { - return 'Theme blocks registered'; + return 'Blocks registered'; } public function runsIn(): array @@ -40,39 +45,38 @@ public function run(RunContext $context): CheckResult return CheckResult::skipped('WordPress is not loaded.'); } - $directory = (string) ActiveTheme::directory(); $registry = \WP_Block_Type_Registry::get_instance(); - $declared = []; + $registered = 0; $missing = []; - foreach (ActiveTheme::files('resources/views/blocks', ['json']) as $file) { - if (basename($file) !== 'block.json') { - continue; - } - - $name = json_decode((string) file_get_contents($directory.'/'.$file), true)['name'] ?? null; - - if (! is_string($name)) { - $missing[] = $file.': no "name" in block.json'; - - continue; - } - - $declared[] = $name; - - if (! $registry->is_registered($name)) { - $missing[] = $name.' ('.$file.')'; + foreach ($this->modules->all() as $module) { + foreach (self::DIRECTORIES as $directory) { + foreach ($module->files($directory, ['json']) as $file) { + if (basename($file) !== 'block.json') { + continue; + } + + $name = json_decode((string) file_get_contents($module->root.'/'.$file), true)['name'] ?? null; + + if (! is_string($name)) { + $missing[] = sprintf('%s: %s has no "name"', $module->label(), $file); + } elseif ($registry->is_registered($name)) { + $registered++; + } else { + $missing[] = sprintf('%s: %s (%s)', $module->label(), $name, $file); + } + } } } if ($missing !== []) { return CheckResult::error( - sprintf('%d block(s) of the theme are not registered: the editor and the pages do not have them.', count($missing)), + sprintf('%d block(s) are not registered: the editor and the pages do not have them.', count($missing)), $missing, "Check storage/logs/laravel.log for the block's registration error; a dynamic block needs a valid block.json and its render.blade.php", ); } - return CheckResult::ok(sprintf('%d block(s) of the theme are registered.', count($declared))); + return CheckResult::ok(sprintf('%d block(s) registered.', $registered)); } } diff --git a/src/Doctor/Infrastructure/Checks/DevelopmentCachesCheck.php b/src/Doctor/Infrastructure/Checks/DevelopmentCachesCheck.php new file mode 100644 index 00000000..471180e3 --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/DevelopmentCachesCheck.php @@ -0,0 +1,65 @@ +app->environment('production')) { + return CheckResult::ok('Production: caches are expected.'); + } + + $cached = []; + + if ($this->app->configurationIsCached()) { + $cached[] = 'configuration cached ('.$this->app->getCachedConfigPath().'): changes to .env and config/ are ignored'; + } + + if ($this->app->routesAreCached()) { + $cached[] = 'routes cached ('.$this->app->getCachedRoutesPath().'): changes to routes/ are ignored'; + } + + if ($cached !== []) { + return CheckResult::warning( + sprintf('Caches meant for production are on in the %s environment.', $this->app->environment()), + $cached, + 'php artisan optimize:clear', + ); + } + + return CheckResult::ok('Nothing freezes the configuration or the routes.'); + } +} diff --git a/src/Doctor/Infrastructure/Checks/LegacyBlocksDirectoryCheck.php b/src/Doctor/Infrastructure/Checks/LegacyBlocksDirectoryCheck.php new file mode 100644 index 00000000..af3c73ea --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/LegacyBlocksDirectoryCheck.php @@ -0,0 +1,60 @@ +modules->all() as $module) { + $blocks = array_values(array_filter($module->files('resources/blocks', ['json']), static fn (string $file): bool => basename($file) === 'block.json')); + + if ($blocks !== []) { + $legacy[] = sprintf('%s: %d block(s) in %s/resources/blocks', $module->label(), count($blocks), $module->relativeRoot()); + } + } + + if ($legacy !== []) { + return CheckResult::warning( + sprintf('%d module(s) keep blocks in resources/blocks, which stops loading in v15.', count($legacy)), + $legacy, + 'Move each block to resources/views/blocks/, and point vite.config.js at the new folder', + ); + } + + return CheckResult::ok('Every block is in resources/views/blocks.'); + } +} diff --git a/src/Doctor/Infrastructure/Checks/SymlinkedDirectoryCheck.php b/src/Doctor/Infrastructure/Checks/SymlinkedDirectoryCheck.php new file mode 100644 index 00000000..2245ff3b --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/SymlinkedDirectoryCheck.php @@ -0,0 +1,60 @@ +modules->all() as $module) { + $target = realpath($module->root); + + if (is_link($module->root) && $target !== false && basename($target) !== basename($module->root)) { + $problems[] = sprintf('%s: %s → %s, so its build goes to a "%s" folder, not "%s"', $module->label(), $module->relativeRoot(), $target, basename($target), basename($module->root)); + } + } + + if ($problems !== []) { + return CheckResult::error( + sprintf('%d directory(ies) are symlinks under another name: their assets are built where the site does not look.', count($problems)), + $problems, + "Use a real copy (rsync) at that path, or give the link's target the same name", + ); + } + + return CheckResult::ok('No directory is a symlink under another name.'); + } +} diff --git a/src/Doctor/Infrastructure/Checks/TemplatePlaceholdersCheck.php b/src/Doctor/Infrastructure/Checks/TemplatePlaceholdersCheck.php new file mode 100644 index 00000000..2bf7fa8e --- /dev/null +++ b/src/Doctor/Infrastructure/Checks/TemplatePlaceholdersCheck.php @@ -0,0 +1,97 @@ + '/%theme_[a-z_]+%/i', + 'plugin' => '/%plugin_[a-z_]+%/i', + ]; + + private const array COMMANDS = [ + 'theme' => 'php artisan pollora:make:theme %s --repository=', + 'plugin' => 'php artisan pollora:make:plugin %s', + ]; + + public function __construct(private ProjectModules $modules) {} + + public function id(): string + { + return 'template-placeholders'; + } + + public function label(): string + { + return 'Template placeholders'; + } + + public function runsIn(): array + { + return [RunContext::Console, RunContext::Http]; + } + + public function run(RunContext $context): CheckResult + { + $problems = []; + $fixes = []; + + foreach ($this->modules->all() as $module) { + if (! isset(self::PATTERNS[$module->type])) { + continue; + } + + $files = $this->filesWithPlaceholders($module); + + if ($files === []) { + continue; + } + + $problems[] = sprintf('%s: %s', $module->label(), implode(', ', array_slice($files, 0, 5)).(count($files) > 5 ? sprintf(' (+%d)', count($files) - 5) : '')); + $fixes[] = sprintf(self::COMMANDS[$module->type], $module->name); + } + + if ($problems !== []) { + return CheckResult::error( + sprintf('%d module(s) were copied from a template instead of generated: they still carry its placeholders.', count($problems)), + $problems, + implode(' ; ', $fixes), + ); + } + + return CheckResult::ok('No template placeholder left.'); + } + + /** + * @return list + */ + private function filesWithPlaceholders(ProjectModule $module): array + { + $pattern = self::PATTERNS[$module->type]; + $files = []; + + foreach ($module->files('', ['php', 'css', 'js', 'jsx', 'ts', 'tsx', 'json', 'html', 'stub']) as $file) { + if (str_ends_with($file, '.stub') || preg_match($pattern, $file) === 1 || preg_match($pattern, (string) file_get_contents($module->root.'/'.$file)) === 1) { + $files[] = $file; + } + } + + return $files; + } +} diff --git a/src/Doctor/Infrastructure/Checks/ThemeBuildCheck.php b/src/Doctor/Infrastructure/Checks/ThemeBuildCheck.php deleted file mode 100644 index 1bc01208..00000000 --- a/src/Doctor/Infrastructure/Checks/ThemeBuildCheck.php +++ /dev/null @@ -1,84 +0,0 @@ -availability->isMissing()) { - return CheckResult::error( - 'No theme is installed: the front end cannot render.', - [], - 'php artisan pollora:make:theme my-theme', - ); - } - - $directory = ActiveTheme::directory(); - - if ($directory === null || ! is_file($directory.'/vite.config.js')) { - return CheckResult::ok('The active theme has no Vite build.'); - } - - $container = $this->assets->getContainer('theme'); - - if (! $container instanceof AssetContainer) { - return CheckResult::skipped('The active theme registers no asset container.'); - } - - if (is_file($container->getHotFile())) { - return CheckResult::ok("Vite's dev server serves the assets.", [ - 'hot file: '.$container->getHotFile().' (delete it if the dev server is stopped)', - ]); - } - - $manifest = public_path(trim($container->getBuildDirectory(), '/').'/'.ltrim($container->getManifestPath(), '/')); - - if (! is_file($manifest)) { - return CheckResult::error( - 'The theme is not built: its asset URLs are empty and the page renders unstyled.', - ['no manifest at '.$manifest], - sprintf('cd %s && npm install && npm run build', ActiveTheme::relativeDirectory()), - ); - } - - return CheckResult::ok('The theme is built.'); - } -} diff --git a/src/Doctor/Infrastructure/Checks/ThemeDirectoryCheck.php b/src/Doctor/Infrastructure/Checks/ThemeDirectoryCheck.php deleted file mode 100644 index 69438523..00000000 --- a/src/Doctor/Infrastructure/Checks/ThemeDirectoryCheck.php +++ /dev/null @@ -1,57 +0,0 @@ - preg_match('/%theme_[a-z_]+%/', (string) file_get_contents($directory.'/'.$file)) === 1, - )); - - if ($carrying !== []) { - return CheckResult::error( - sprintf('%d file(s) still carry %%theme_*%% placeholders: the theme was copied from a template instead of generated.', count($carrying)), - array_slice($carrying, 0, 20), - sprintf('php artisan pollora:make:theme %s --repository=', ActiveTheme::slug() ?? 'my-theme'), - ); - } - - return CheckResult::ok('No placeholder left in the theme.'); - } -} diff --git a/src/Doctor/Infrastructure/Providers/DoctorServiceProvider.php b/src/Doctor/Infrastructure/Providers/DoctorServiceProvider.php index a51060ef..a30f5d3b 100644 --- a/src/Doctor/Infrastructure/Providers/DoctorServiceProvider.php +++ b/src/Doctor/Infrastructure/Providers/DoctorServiceProvider.php @@ -6,16 +6,18 @@ use Illuminate\Support\ServiceProvider; use Pollora\Doctor\Application\Services\Doctor; +use Pollora\Doctor\Infrastructure\Checks\AssetBuildCheck; use Pollora\Doctor\Infrastructure\Checks\BlockRegistrationCheck; use Pollora\Doctor\Infrastructure\Checks\BlockThemeRoutesCheck; +use Pollora\Doctor\Infrastructure\Checks\DevelopmentCachesCheck; use Pollora\Doctor\Infrastructure\Checks\DiscoveryCacheCheck; use Pollora\Doctor\Infrastructure\Checks\EnvironmentFileCheck; +use Pollora\Doctor\Infrastructure\Checks\LegacyBlocksDirectoryCheck; use Pollora\Doctor\Infrastructure\Checks\PatchesLockCheck; use Pollora\Doctor\Infrastructure\Checks\PatternCacheCheck; use Pollora\Doctor\Infrastructure\Checks\PatternFilesCheck; -use Pollora\Doctor\Infrastructure\Checks\ThemeBuildCheck; -use Pollora\Doctor\Infrastructure\Checks\ThemeDirectoryCheck; -use Pollora\Doctor\Infrastructure\Checks\ThemePlaceholdersCheck; +use Pollora\Doctor\Infrastructure\Checks\SymlinkedDirectoryCheck; +use Pollora\Doctor\Infrastructure\Checks\TemplatePlaceholdersCheck; use Pollora\Doctor\Infrastructure\Checks\WordPressCorePatchCheck; use Pollora\Doctor\UI\Console\DoctorCommand; use Pollora\Doctor\UI\Http\SiteHealthTests; @@ -29,7 +31,7 @@ class DoctorServiceProvider extends ServiceProvider public const string CHECKS_TAG = 'pollora.doctor.checks'; /** - * In the order a developer should read them: the install, then the theme. + * In the order a developer should read them: the install, then what the project builds. * * @var list */ @@ -37,13 +39,15 @@ class DoctorServiceProvider extends ServiceProvider WordPressCorePatchCheck::class, PatchesLockCheck::class, EnvironmentFileCheck::class, + DevelopmentCachesCheck::class, DiscoveryCacheCheck::class, - ThemeBuildCheck::class, - ThemeDirectoryCheck::class, - ThemePlaceholdersCheck::class, + AssetBuildCheck::class, + SymlinkedDirectoryCheck::class, + TemplatePlaceholdersCheck::class, PatternFilesCheck::class, PatternCacheCheck::class, BlockThemeRoutesCheck::class, + LegacyBlocksDirectoryCheck::class, BlockRegistrationCheck::class, ]; diff --git a/src/Doctor/Infrastructure/Support/ProjectModule.php b/src/Doctor/Infrastructure/Support/ProjectModule.php new file mode 100644 index 00000000..1b4fbf9d --- /dev/null +++ b/src/Doctor/Infrastructure/Support/ProjectModule.php @@ -0,0 +1,78 @@ +type.' '.$this->name; + } + + /** The root relative to the project, for a command a person can paste. */ + public function relativeRoot(): string + { + $base = rtrim(base_path(), '/').'/'; + + return str_starts_with($this->root, $base) ? substr($this->root, strlen($base)) : $this->root; + } + + public function manifest(): string + { + return public_path(trim($this->buildDirectory, '/').'/'.ltrim($this->manifestPath, '/')); + } + + /** + * Files under the module with one of the extensions, dependencies and builds left out. + * + * @param list $extensions + * @return list paths relative to the root + */ + public function files(string $subdirectory, array $extensions, int $limit = 5000): array + { + $directory = rtrim($this->root.'/'.$subdirectory, '/'); + + if (! is_dir($directory)) { + return []; + } + + $iterator = new \RecursiveIteratorIterator(new \RecursiveCallbackFilterIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + static fn (\SplFileInfo $file): bool => ! in_array($file->getFilename(), ['node_modules', 'vendor', '.git'], true), + )); + + $files = []; + + foreach ($iterator as $file) { + if (in_array(strtolower($file->getExtension()), $extensions, true)) { + $files[] = substr($file->getPathname(), strlen($this->root) + 1); + } + + if (count($files) >= $limit) { + break; + } + } + + sort($files); + + return $files; + } +} diff --git a/src/Doctor/Infrastructure/Support/ProjectModules.php b/src/Doctor/Infrastructure/Support/ProjectModules.php new file mode 100644 index 00000000..dedc9cc2 --- /dev/null +++ b/src/Doctor/Infrastructure/Support/ProjectModules.php @@ -0,0 +1,104 @@ + + */ + public function all(): array + { + return [...$this->theme(), ...$this->plugins(), ...$this->modules()]; + } + + /** + * @return list + */ + public function theme(): array + { + $directory = ActiveTheme::directory(); + $slug = ActiveTheme::slug(); + + if ($directory === null || $slug === null || ! is_dir($directory)) { + return []; + } + + return [$this->make('theme', $slug, $directory, strtolower($slug), 'theme')]; + } + + /** + * @return list + */ + public function plugins(): array + { + if (! $this->app->bound(PluginRegistrar::class)) { + return []; + } + + $plugins = []; + + foreach ($this->app->make(PluginRegistrar::class)->getRegisteredPlugins() as $plugin) { + $plugins[] = $this->make('plugin', $plugin->getLowerName(), rtrim($plugin->getPath(), '/'), $plugin->getLowerName(), 'plugin'); + } + + return $plugins; + } + + /** + * @return list + */ + public function modules(): array + { + if (! $this->app->bound('modules')) { + return []; + } + + $modules = []; + + try { + foreach ($this->app->make('modules')->allEnabled() as $module) { + $slug = Str::kebab((string) $module->getName()); + $modules[] = $this->make('module', (string) $module->getName(), rtrim((string) $module->getPath(), '/'), $slug, 'module'); + } + } catch (Throwable) { + return []; + } + + return $modules; + } + + private function make(string $type, string $name, string $root, string $assetName, string $assetType): ProjectModule + { + $expected = $this->app->make(ModuleAssetManager::class)->expectedAssetConfiguration($assetName, $assetType); + $container = $this->app->bound(AssetManager::class) ? $this->app->make(AssetManager::class)->getContainer($expected['container']) : null; + + return new ProjectModule( + type: $type, + name: $name, + root: $root, + hotFile: $container?->getHotFile() ?? $expected['hot_file'], + buildDirectory: $container?->getBuildDirectory() ?? $expected['build_directory'], + manifestPath: $container?->getManifestPath() ?? $expected['manifest_path'], + ); + } +} diff --git a/src/Modules/Infrastructure/Services/ModuleAssetManager.php b/src/Modules/Infrastructure/Services/ModuleAssetManager.php index c6052140..d98ded73 100644 --- a/src/Modules/Infrastructure/Services/ModuleAssetManager.php +++ b/src/Modules/Infrastructure/Services/ModuleAssetManager.php @@ -93,6 +93,23 @@ public function getModuleRoots(): array return $this->moduleRoots; } + /** + * The asset container name and paths a module gets, whether or not it has been set up yet. + * + * @return array{container: string, hot_file: string, build_directory: string, manifest_path: string} + */ + public function expectedAssetConfiguration(string $moduleName, string $moduleType, ?string $moduleSlug = null): array + { + $configuration = $this->getAssetConfiguration($moduleName, '', $moduleType); + + return [ + 'container' => $this->getContainerName($moduleType, $moduleSlug ?? $moduleName), + 'hot_file' => $configuration['hot_file'], + 'build_directory' => $configuration['build_directory'], + 'manifest_path' => $configuration['manifest_path'], + ]; + } + /** * Get the container name based on module type and slug. */ diff --git a/tests/Feature/Doctor/ModuleChecksTest.php b/tests/Feature/Doctor/ModuleChecksTest.php new file mode 100644 index 00000000..137a8e01 --- /dev/null +++ b/tests/Feature/Doctor/ModuleChecksTest.php @@ -0,0 +1,231 @@ +root = sys_get_temp_dir().'/pollora-doctor-modules-'.uniqid(); + mkdir($this->root.'/public', 0777, true); + $this->previousBasePath = app()->basePath(); + app()->setBasePath($this->root); + app()->usePublicPath($this->root.'/public'); + + $module = fn (string $type, string $name, string $root): ProjectModule => new ProjectModule( + $type, $name, $root, $this->root.'/public/'.$name.'.hot', 'build/'.$type.'/'.$name, + ); + + $this->theme = $module('theme', 'journal', $this->root.'/themes/journal'); + $this->plugin = $module('plugin', 'acme-forms', $this->root.'/public/content/plugins/acme-forms'); + $this->module = $module('module', 'blocks-demo', $this->root.'/Modules/BlocksDemo'); + + foreach ([$this->theme, $this->plugin, $this->module] as $each) { + mkdir($each->root, 0777, true); + } + + $this->modules = Mockery::mock(ProjectModules::class); + $this->modules->shouldReceive('all')->andReturnUsing(fn (): array => [$this->theme, $this->plugin, $this->module]); +}); + +afterEach(function (): void { + app()->setBasePath($this->previousBasePath); + (new Filesystem)->deleteDirectory($this->root); +}); + +describe('Theme, plugin and module builds', function (): void { + function availability(bool $themeMissing): ThemeAvailability + { + $registrar = Mockery::mock(ThemeRegistrarInterface::class); + $registrar->shouldReceive('getActiveTheme')->andReturn($themeMissing ? null : Mockery::mock(ThemeModuleInterface::class)); + $container = new Container; + $container->instance(ThemeRegistrarInterface::class, $registrar); + + return new ThemeAvailability($container); + } + + it('fails when no theme is installed', function (): void { + Brain\Monkey\Functions\when('get_stylesheet_directory')->justReturn('/nope'); + + expect((new AssetBuildCheck(availability(true), $this->modules))->run(RunContext::Console)->status->value)->toBe('error'); + }); + + it('names an unbuilt plugin, and a module built into another folder than Pollora reads', function (): void { + putModuleFile($this->plugin->root.'/vite.config.js', ''); + putModuleFile($this->module->root.'/vite.config.js', ''); + putModuleFile($this->root.'/public/build/modules/blocks-demo/manifest.json', '{}'); + + $result = (new AssetBuildCheck(availability(false), $this->modules))->run(RunContext::Console); + + expect($result->status->value)->toBe('error') + ->and($result->details)->toBe([ + 'plugin acme-forms: not built — no manifest at public/build/plugin/acme-forms/manifest.json', + 'module blocks-demo: built into public/build/modules/blocks-demo, but Pollora reads public/build/module/blocks-demo', + ]) + ->and($result->fix)->toContain('cd public/content/plugins/acme-forms && npm install && npm run build'); + }); + + it('tells a stopped dev server from one the browser cannot reach', function (): void { + putModuleFile($this->theme->root.'/vite.config.js', ''); + putModuleFile($this->theme->hotFile, 'https://site.test:5173'); + putModuleFile($this->plugin->root.'/vite.config.js', ''); + putModuleFile($this->plugin->hotFile, 'https://site.test:5174'); + + // 502: a proxy with nothing behind it. 404: something answers, but not Vite. + $probe = fn (string $url): int => str_contains($url, '5173') ? 502 : 404; + $result = (new AssetBuildCheck(availability(false), $this->modules, $probe))->run(RunContext::Console); + + expect($result->details[0])->toContain('where no Vite dev server answers') + ->and($result->details[1])->toContain("answers 404 for Vite's client") + ->and($result->fix)->toContain('web_extra_exposed_ports'); + }); + + it('passes built modules, and one whose dev server answers', function (): void { + putModuleFile($this->theme->root.'/vite.config.js', ''); + putModuleFile($this->theme->hotFile, 'https://site.test:5173'); + putModuleFile($this->plugin->root.'/vite.config.js', ''); + putModuleFile($this->root.'/public/build/plugin/acme-forms/manifest.json', '{}'); + + $result = (new AssetBuildCheck(availability(false), $this->modules, fn (): int => 200))->run(RunContext::Console); + + expect($result->status->value)->toBe('ok') + ->and($result->summary)->toStartWith('2 Vite build(s)'); + }); +}); + +describe('Symlinked directories', function (): void { + it('fails for a plugin linked to a directory of another name', function (): void { + (new Filesystem)->deleteDirectory($this->plugin->root); + mkdir($this->root.'/repos/plugin-acme-forms', 0777, true); + symlink($this->root.'/repos/plugin-acme-forms', $this->plugin->root); + + $result = (new SymlinkedDirectoryCheck($this->modules))->run(RunContext::Console); + + expect($result->status->value)->toBe('error') + ->and($result->details)->toHaveCount(1) + ->and($result->details[0])->toStartWith('plugin acme-forms:'); + }); + + it('passes real directories', function (): void { + expect((new SymlinkedDirectoryCheck($this->modules))->run(RunContext::Console)->status->value)->toBe('ok'); + }); +}); + +describe('Template placeholders', function (): void { + it('names a copied theme and a copied plugin, stubs included, and says how to generate each', function (): void { + putModuleFile($this->theme->root.'/app/Providers/AssetServiceProvider.php', "theme->root.'/node_modules/pkg/index.js', '%theme_name%'); + putModuleFile($this->plugin->root.'/app/Providers/PluginServiceProvider.stub', 'plugin->root.'/%plugin_name%.php', 'module->root.'/module.json', '{"name": "%plugin_name%"}'); + + $result = (new TemplatePlaceholdersCheck($this->modules))->run(RunContext::Console); + + expect($result->status->value)->toBe('error') + ->and($result->details)->toBe([ + 'theme journal: app/Providers/AssetServiceProvider.php', + 'plugin acme-forms: %plugin_name%.php, app/Providers/PluginServiceProvider.stub', + ]) + ->and($result->fix)->toContain('pollora:make:theme journal') + ->and($result->fix)->toContain('pollora:make:plugin acme-forms'); + }); +}); + +describe('Blocks in the legacy folder', function (): void { + it('warns about blocks still in resources/blocks, for each kind', function (): void { + putModuleFile($this->plugin->root.'/resources/blocks/accordion/block.json', '{"name": "acme/accordion"}'); + putModuleFile($this->module->root.'/resources/blocks/testimonial/block.json', '{"name": "demo/testimonial"}'); + putModuleFile($this->theme->root.'/resources/views/blocks/hero/block.json', '{"name": "journal/hero"}'); + + $result = (new LegacyBlocksDirectoryCheck($this->modules))->run(RunContext::Console); + + expect($result->status->value)->toBe('warning') + ->and($result->details)->toBe([ + 'plugin acme-forms: 1 block(s) in public/content/plugins/acme-forms/resources/blocks', + 'module blocks-demo: 1 block(s) in Modules/BlocksDemo/resources/blocks', + ]); + }); +}); + +describe('Blocks registered', function (): void { + beforeEach(function (): void { + putModuleFile($this->theme->root.'/resources/views/blocks/hero/block.json', '{"name": "journal/hero"}'); + putModuleFile($this->plugin->root.'/resources/views/blocks/form/block.json', '{"name": "acme/form"}'); + putModuleFile($this->module->root.'/resources/blocks/testimonial/block.json', '{"name": "demo/testimonial"}'); + }); + + it('names the blocks of the theme, the plugins and the modules missing from the request', function (): void { + WP_Block_Type_Registry::$registered = ['journal/hero']; + + $result = (new BlockRegistrationCheck($this->modules))->run(RunContext::Http); + + expect($result->status->value)->toBe('error') + ->and($result->details)->toBe([ + 'plugin acme-forms: acme/form (resources/views/blocks/form/block.json)', + 'module blocks-demo: demo/testimonial (resources/blocks/testimonial/block.json)', + ]); + }); + + it('passes when every block is registered', function (): void { + WP_Block_Type_Registry::$registered = ['journal/hero', 'acme/form', 'demo/testimonial']; + + expect((new BlockRegistrationCheck($this->modules))->run(RunContext::Http)->summary)->toBe('3 block(s) registered.'); + }); +}); + +describe('Configuration and route caches', function (): void { + it('warns about a configuration cached outside production', function (): void { + app()->detectEnvironment(fn (): string => 'local'); + // What Laravel records at boot when it loads the configuration from its cache. + app()->instance('config_loaded_from_cache', true); + + $result = (new DevelopmentCachesCheck(app()))->run(RunContext::Console); + + expect($result->status->value)->toBe('warning') + ->and($result->fix)->toBe('php artisan optimize:clear'); + }); + + it('accepts caches in production', function (): void { + app()->detectEnvironment(fn (): string => 'production'); + app()->instance('config_loaded_from_cache', true); + + expect((new DevelopmentCachesCheck(app()))->run(RunContext::Console)->status->value)->toBe('ok'); + }); +}); diff --git a/tests/Feature/Doctor/ThemeChecksTest.php b/tests/Feature/Doctor/ThemeChecksTest.php index fa99f04f..69a7c784 100644 --- a/tests/Feature/Doctor/ThemeChecksTest.php +++ b/tests/Feature/Doctor/ThemeChecksTest.php @@ -3,33 +3,14 @@ declare(strict_types=1); use Brain\Monkey\Functions; -use Illuminate\Container\Container; use Illuminate\Filesystem\Filesystem; use Illuminate\Routing\RouteCollection; use Illuminate\Routing\Router; -use Pollora\Asset\Application\Services\AssetManager; -use Pollora\Asset\Application\Services\AssetRetrievalService; use Pollora\Doctor\Domain\Enums\RunContext; -use Pollora\Doctor\Infrastructure\Checks\BlockRegistrationCheck; use Pollora\Doctor\Infrastructure\Checks\BlockThemeRoutesCheck; use Pollora\Doctor\Infrastructure\Checks\PatternCacheCheck; use Pollora\Doctor\Infrastructure\Checks\PatternFilesCheck; -use Pollora\Doctor\Infrastructure\Checks\ThemeBuildCheck; -use Pollora\Doctor\Infrastructure\Checks\ThemeDirectoryCheck; -use Pollora\Doctor\Infrastructure\Checks\ThemePlaceholdersCheck; use Pollora\Route\Infrastructure\Models\Route; -use Pollora\Theme\Application\Services\ThemeAvailability; -use Pollora\Theme\Domain\Contracts\ThemeModuleInterface; -use Pollora\Theme\Domain\Contracts\ThemeRegistrarInterface; - -// Declared only for these tests when WordPress is absent: the registry the block check reads. -if (! class_exists('WP_Block_Type_Registry')) { - eval('final class WP_Block_Type_Registry { - public static array $registered = []; - public static function get_instance(): self { return new self; } - public function is_registered(string $name): bool { return in_array($name, self::$registered, true); } - }'); -} beforeEach(function (): void { $this->root = sys_get_temp_dir().'/pollora-doctor-theme-'.uniqid(); @@ -54,87 +35,6 @@ function putFile(string $path, string $contents): void } } -describe('Theme and its build', function (): void { - function buildCheck(string $root, bool $themeMissing): ThemeBuildCheck - { - $registrar = Mockery::mock(ThemeRegistrarInterface::class); - $registrar->shouldReceive('getActiveTheme')->andReturn($themeMissing ? null : Mockery::mock(ThemeModuleInterface::class)); - $container = new Container; - $container->instance(ThemeRegistrarInterface::class, $registrar); - - $assets = new AssetManager(Mockery::mock(AssetRetrievalService::class)); - $assets->addContainer('theme', [ - 'hot_file' => $root.'/public/journal.hot', - 'build_directory' => 'build/theme/journal', - 'manifest_path' => 'manifest.json', - ]); - app()->usePublicPath($root.'/public'); - - return new ThemeBuildCheck(new ThemeAvailability($container), $assets); - } - - it('fails when no theme is installed', function (): void { - Functions\when('get_stylesheet_directory')->justReturn($this->root.'/themes/missing'); - - expect(buildCheck($this->root, themeMissing: true)->run(RunContext::Console)->status->value)->toBe('error'); - }); - - it('fails when a Vite theme has neither a manifest nor a dev server, and says where to build', function (): void { - putFile($this->theme.'/vite.config.js', 'export default {}'); - $this->app->setBasePath($this->root); - - $result = buildCheck($this->root, themeMissing: false)->run(RunContext::Console); - - expect($result->status->value)->toBe('error') - ->and($result->fix)->toBe('cd themes/journal && npm install && npm run build'); - }); - - it('passes a built theme, and one served by the dev server', function (): void { - putFile($this->theme.'/vite.config.js', 'export default {}'); - putFile($this->root.'/public/build/theme/journal/manifest.json', '{}'); - expect(buildCheck($this->root, themeMissing: false)->run(RunContext::Console)->status->value)->toBe('ok'); - - unlink($this->root.'/public/build/theme/journal/manifest.json'); - putFile($this->root.'/public/journal.hot', 'https://localhost:5173'); - expect(buildCheck($this->root, themeMissing: false)->run(RunContext::Console)->status->value)->toBe('ok'); - }); -}); - -describe('Theme directory', function (): void { - it('fails when the theme is a symlink to a directory of another name', function (): void { - (new Filesystem)->deleteDirectory($this->theme); - mkdir($this->root.'/repos/theme-journal', 0777, true); - symlink($this->root.'/repos/theme-journal', $this->theme); - - $result = (new ThemeDirectoryCheck)->run(RunContext::Console); - - expect($result->status->value)->toBe('error') - ->and($result->summary)->toContain('public/build/theme/theme-journal'); - }); - - it('passes a real directory', function (): void { - expect((new ThemeDirectoryCheck)->run(RunContext::Console)->status->value)->toBe('ok'); - }); -}); - -describe('Theme placeholders', function (): void { - it('fails on a template copied instead of generated', function (): void { - putFile($this->theme.'/app/Providers/AssetServiceProvider.php', "theme.'/node_modules/pkg/index.js', '%theme_name%'); - - $result = (new ThemePlaceholdersCheck)->run(RunContext::Console); - - expect($result->status->value)->toBe('error') - ->and($result->details)->toBe(['app/Providers/AssetServiceProvider.php']); - }); - - it('passes a generated theme', function (): void { - putFile($this->theme.'/app/Providers/AssetServiceProvider.php', "run(RunContext::Console)->status->value)->toBe('ok'); - }); -}); - describe('Theme pattern files', function (): void { it('warns about an .html pattern and a pattern without a Slug', function (): void { putFile($this->theme.'/patterns/masthead.html', ''); @@ -209,25 +109,3 @@ public function get_block_patterns(): array expect((new BlockThemeRoutesCheck(Mockery::mock(Router::class)))->run(RunContext::Console)->status->value)->toBe('skipped'); }); }); - -describe('Theme blocks registered', function (): void { - beforeEach(function (): void { - putFile($this->theme.'/resources/views/blocks/hero/block.json', '{"name": "journal/hero"}'); - putFile($this->theme.'/resources/views/blocks/card/block.json', '{"name": "journal/card"}'); - }); - - it('fails when a block of the theme is not registered in the request', function (): void { - WP_Block_Type_Registry::$registered = ['journal/hero']; - - $result = (new BlockRegistrationCheck)->run(RunContext::Http); - - expect($result->status->value)->toBe('error') - ->and($result->details)->toBe(['journal/card (resources/views/blocks/card/block.json)']); - }); - - it('passes when every block is registered', function (): void { - WP_Block_Type_Registry::$registered = ['journal/hero', 'journal/card']; - - expect((new BlockRegistrationCheck)->run(RunContext::Http)->status->value)->toBe('ok'); - }); -}); diff --git a/tests/e2e/specs/doctor.spec.ts b/tests/e2e/specs/doctor.spec.ts index 3a42e0fa..e75a012b 100644 --- a/tests/e2e/specs/doctor.spec.ts +++ b/tests/e2e/specs/doctor.spec.ts @@ -16,7 +16,7 @@ test('Site Health lists the Pollora checks, the web-only block check included', // Passed tests sit in a collapsed list: read the triggers, visible or not. const results = page.locator('.health-check-accordion-trigger'); - for (const label of ['WordPress core patch', 'Composer patches lock', 'Theme and its build', 'Theme blocks registered']) { + for (const label of ['WordPress core patch', 'Composer patches lock', 'Theme, plugin and module builds', 'Blocks registered']) { await expect(results.filter({ hasText: label }).filter({ hasText: 'Pollora' }), label).toHaveCount(1); } From 24f068e099429456650a7b1550549b3e55e99630 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 30 Sep 2026 10:55:30 +0200 Subject: [PATCH 6/9] fix: stop comparing a development build with releases --- CHANGELOG.md | 1 + .../Domain/Services/SystemInfoCollector.php | 3 +- .../Providers/DashboardServiceProvider.php | 6 +--- src/Dashboard/UI/Console/StatusCommand.php | 4 +-- src/Dashboard/UI/Http/DashboardController.php | 4 +-- .../Domain/Services/VersionComparator.php | 25 +++++++++++++-- src/VersionCheck/UI/Http/SiteHealthCheck.php | 17 +++++++++- .../Dashboard/SystemInfoCollectorTest.php | 14 ++++++++ .../Unit/VersionCheck/SiteHealthCheckTest.php | 12 +++++++ .../VersionCheck/VersionComparatorTest.php | 32 +++++++++++++++++++ 10 files changed, 104 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d7e6192..3f7b3e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - While Vite ran hot, its client was enqueued with WordPress's version appended (`@vite/client?ver=7.1.2`). The modules Vite serves import `/@vite/client` by its bare URL, so the browser loaded the client twice, as two modules with two HMR connections. It is enqueued with no version, like the entries (regression from v13.34.0-beta) +- On a branch install (`dev-develop`, `13.x-dev`), Site Health and the admin notice announced "Pollora 13.4.4 is available": `version_compare()` ranks a branch name below every release. A development build is no longer compared with releases — Site Health says it is one, the notice stays silent — and the dashboard, the admin menu badge and `pollora:status` share that one rule, which they each duplicated and missed `13.x-dev`. Site Health's info tab now labels the compared version "Latest stable version", since pre-releases are not counted ### Changed - `pollora:make:theme` activates the generated theme only where the site needs one. A site with no usable theme — a first install — gets it without a question; a site that already has one keeps it unless the answer is "yes", now the default "no", so `--no-interaction` never replaces a working theme. `--activate` and `--no-activate` settle it without asking, and `pollora:install` passes `--activate`. Activation goes through `switch_theme()`, which fires `switch_theme` and `after_switch_theme`; the options were written directly before, so those hooks never ran diff --git a/src/Dashboard/Domain/Services/SystemInfoCollector.php b/src/Dashboard/Domain/Services/SystemInfoCollector.php index 5dd9ecbc..f23775ac 100644 --- a/src/Dashboard/Domain/Services/SystemInfoCollector.php +++ b/src/Dashboard/Domain/Services/SystemInfoCollector.php @@ -135,7 +135,7 @@ private function collectLaravelLocale(): string } /** - * @return array{current: ?string, latest: ?string, update_available: bool} + * @return array{current: ?string, latest: ?string, update_available: bool, development: bool} */ public function collectFrameworkInfo(): array { @@ -143,6 +143,7 @@ public function collectFrameworkInfo(): array 'current' => $this->versionComparator->getCurrentVersion(), 'latest' => $this->versionComparator->getLatestVersion(), 'update_available' => $this->versionComparator->isUpdateAvailable(), + 'development' => $this->versionComparator->isDevelopmentBuild(), ]; } diff --git a/src/Dashboard/Infrastructure/Providers/DashboardServiceProvider.php b/src/Dashboard/Infrastructure/Providers/DashboardServiceProvider.php index af1b87c5..3e53b449 100644 --- a/src/Dashboard/Infrastructure/Providers/DashboardServiceProvider.php +++ b/src/Dashboard/Infrastructure/Providers/DashboardServiceProvider.php @@ -87,11 +87,7 @@ private function countNotifications(): int $count = 0; try { - $comparator = $this->app->make(VersionComparator::class); - $current = $comparator->getCurrentVersion(); - $isDev = is_string($current) && str_starts_with($current, 'dev-'); - - if (! $isDev && $comparator->isUpdateAvailable()) { + if ($this->app->make(VersionComparator::class)->isUpdateAvailable()) { $count++; } } catch (\Throwable) { diff --git a/src/Dashboard/UI/Console/StatusCommand.php b/src/Dashboard/UI/Console/StatusCommand.php index 55144e6b..a36e1cef 100644 --- a/src/Dashboard/UI/Console/StatusCommand.php +++ b/src/Dashboard/UI/Console/StatusCommand.php @@ -68,14 +68,14 @@ private function renderTranslations(array $translations): void } /** - * @param array{current: ?string, latest: ?string, update_available: bool} $framework + * @param array{current: ?string, latest: ?string, update_available: bool, development: bool} $framework */ private function renderFrameworkStatus(array $framework): void { $current = $framework['current'] ?? 'unknown'; $latest = $framework['latest'] ?? 'unknown'; $updateAvailable = $framework['update_available']; - $isDev = is_string($current) && str_starts_with($current, 'dev-'); + $isDev = $framework['development']; if ($isDev) { $this->line(sprintf('Pollora %s (latest stable: v%s)', $current, $latest)); diff --git a/src/Dashboard/UI/Http/DashboardController.php b/src/Dashboard/UI/Http/DashboardController.php index 69bf8ace..3b8a08a3 100644 --- a/src/Dashboard/UI/Http/DashboardController.php +++ b/src/Dashboard/UI/Http/DashboardController.php @@ -121,13 +121,13 @@ private function renderStyles(): void } /** - * @param array{current: ?string, latest: ?string, update_available: bool} $framework + * @param array{current: ?string, latest: ?string, update_available: bool, development: bool} $framework */ private function renderHeader(array $framework): void { $current = $framework['current'] ?? __('Unknown', 'pollora'); $updateAvailable = $framework['update_available']; - $isDev = is_string($current) && str_starts_with($current, 'dev-'); + $isDev = $framework['development']; $logoPath = dirname(__DIR__, 4).'/resources/images/pollora-logo.svg'; diff --git a/src/VersionCheck/Domain/Services/VersionComparator.php b/src/VersionCheck/Domain/Services/VersionComparator.php index 8a903034..83bae374 100644 --- a/src/VersionCheck/Domain/Services/VersionComparator.php +++ b/src/VersionCheck/Domain/Services/VersionComparator.php @@ -24,21 +24,40 @@ public function __construct( /** * Determine whether a newer version of Pollora is available. * - * Returns false if either version cannot be determined, ensuring - * no false-positive update notifications are shown. + * Returns false if either version cannot be determined, or if the + * installed version is a development build, ensuring no false-positive + * update notifications are shown. */ public function isUpdateAvailable(): bool { $current = $this->checker->getCurrentVersion(); $latest = $this->checker->getLatestVersion(); - if ($current === null || $latest === null) { + if ($current === null || $latest === null || $this->isDevelopmentBuild()) { return false; } return version_compare($latest, $current, '>'); } + /** + * Determine whether the installed version is a development build. + * + * A branch install ("dev-develop", "13.x-dev") has no release number, + * and version_compare() ranks it below every release, so it cannot be + * compared with the latest stable version. + */ + public function isDevelopmentBuild(): bool + { + $current = $this->checker->getCurrentVersion(); + + if ($current === null) { + return false; + } + + return str_starts_with($current, 'dev-') || str_ends_with($current, '-dev'); + } + /** * Get the currently installed version. * diff --git a/src/VersionCheck/UI/Http/SiteHealthCheck.php b/src/VersionCheck/UI/Http/SiteHealthCheck.php index d6cbf089..0f75357d 100644 --- a/src/VersionCheck/UI/Http/SiteHealthCheck.php +++ b/src/VersionCheck/UI/Http/SiteHealthCheck.php @@ -46,7 +46,7 @@ public function addDebugInfo(array $info): array 'value' => $current, ], 'latest_version' => [ - 'label' => __('Latest version', 'pollora'), + 'label' => __('Latest stable version', 'pollora'), 'value' => $latest, ], 'up_to_date' => [ @@ -85,6 +85,7 @@ public function addTests(array $tests): array * - **good** (blue badge): Pollora is up to date * - **recommended** (orange badge): A newer version is available * - **recommended** (orange badge): Version status could not be determined + * - **good** (blue badge): A development build is installed, not compared with releases * * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, test: string, actions?: string} Test result */ @@ -106,6 +107,20 @@ public function testVersionStatus(): array ]; } + if ($this->comparator->isDevelopmentBuild()) { + return [ + 'label' => __('Pollora is running a development build', 'pollora'), + 'status' => 'good', + 'badge' => [ + 'label' => 'Pollora', + 'color' => 'blue', + ], + /* translators: 1: installed development build, 2: latest stable version */ + 'description' => '

'.sprintf(__('You are running Pollora %1$s, which is not compared with releases. The latest stable version is %2$s.', 'pollora'), $current, $latest).'

', + 'test' => 'pollora_update', + ]; + } + if (! $this->comparator->isUpdateAvailable()) { return [ 'label' => __('Pollora is up to date', 'pollora'), diff --git a/tests/Unit/Dashboard/SystemInfoCollectorTest.php b/tests/Unit/Dashboard/SystemInfoCollectorTest.php index 4b59f4d4..0a5b8bdc 100644 --- a/tests/Unit/Dashboard/SystemInfoCollectorTest.php +++ b/tests/Unit/Dashboard/SystemInfoCollectorTest.php @@ -44,9 +44,23 @@ function createCollector( 'current' => '13.4.0', 'latest' => '13.4.0', 'update_available' => false, + 'development' => false, ]); }); + it('flags a development build', function (): void { + $checker = Mockery::mock(VersionCheckerInterface::class, [ + 'getCurrentVersion' => 'dev-develop', + 'getLatestVersion' => '13.4.4', + ]); + + $collector = createCollector(new VersionComparator($checker)); + $info = $collector->collectFrameworkInfo(); + + expect($info['development'])->toBeTrue(); + expect($info['update_available'])->toBeFalse(); + }); + it('detects update available', function (): void { $checker = Mockery::mock(VersionCheckerInterface::class, [ 'getCurrentVersion' => '13.3.0', diff --git a/tests/Unit/VersionCheck/SiteHealthCheckTest.php b/tests/Unit/VersionCheck/SiteHealthCheckTest.php index 94c10877..e54fba1a 100644 --- a/tests/Unit/VersionCheck/SiteHealthCheckTest.php +++ b/tests/Unit/VersionCheck/SiteHealthCheckTest.php @@ -68,6 +68,18 @@ expect($result['label'])->toContain('13.3.0'); }); + it('returns good status for a development build', function (): void { + $checker = Mockery::mock(VersionCheckerInterface::class); + $checker->shouldReceive('getCurrentVersion')->andReturn('dev-develop'); + $checker->shouldReceive('getLatestVersion')->andReturn('13.4.4'); + + $health = new SiteHealthCheck(new VersionComparator($checker)); + $result = $health->testVersionStatus(); + + expect($result['status'])->toBe('good'); + expect($result['label'])->not->toContain('13.4.4'); + }); + it('returns recommended status when version cannot be determined', function (): void { $checker = Mockery::mock(VersionCheckerInterface::class); $checker->shouldReceive('getCurrentVersion')->andReturn(null); diff --git a/tests/Unit/VersionCheck/VersionComparatorTest.php b/tests/Unit/VersionCheck/VersionComparatorTest.php index 6084e10a..6453d9b8 100644 --- a/tests/Unit/VersionCheck/VersionComparatorTest.php +++ b/tests/Unit/VersionCheck/VersionComparatorTest.php @@ -56,6 +56,38 @@ expect($comparator->isUpdateAvailable())->toBeFalse(); }); + it('reports no update for a development build', function (string $current): void { + $checker = Mockery::mock(VersionCheckerInterface::class); + $checker->shouldReceive('getCurrentVersion')->andReturn($current); + $checker->shouldReceive('getLatestVersion')->andReturn('13.4.4'); + + $comparator = new VersionComparator($checker); + + expect($comparator->isDevelopmentBuild())->toBeTrue(); + expect($comparator->isUpdateAvailable())->toBeFalse(); + })->with(['dev-develop', '13.x-dev']); + + it('reports no update when a pre-release is ahead of the latest stable', function (): void { + $checker = Mockery::mock(VersionCheckerInterface::class); + $checker->shouldReceive('getCurrentVersion')->andReturn('13.34.0-beta'); + $checker->shouldReceive('getLatestVersion')->andReturn('13.4.4'); + + $comparator = new VersionComparator($checker); + + expect($comparator->isDevelopmentBuild())->toBeFalse(); + expect($comparator->isUpdateAvailable())->toBeFalse(); + }); + + it('reports an update when the stable release of a pre-release is out', function (): void { + $checker = Mockery::mock(VersionCheckerInterface::class); + $checker->shouldReceive('getCurrentVersion')->andReturn('13.34.0-beta'); + $checker->shouldReceive('getLatestVersion')->andReturn('13.34.0'); + + $comparator = new VersionComparator($checker); + + expect($comparator->isUpdateAvailable())->toBeTrue(); + }); + it('delegates getCurrentVersion to checker', function (): void { $checker = Mockery::mock(VersionCheckerInterface::class); $checker->shouldReceive('getCurrentVersion')->andReturn('13.2.0'); From c79ce40ecdc6104797012a7c5ec80f7d6f5b25d6 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 30 Sep 2026 10:57:34 +0200 Subject: [PATCH 7/9] fix: dispatch MenuDeleted with the menu delete_nav_menu passes --- CHANGELOG.md | 1 + .../WordPress/Menu/MenuEventDispatcher.php | 16 +++++++-- .../Menu/MenuEventDispatcherTest.php | 33 +++++++++++++++++++ tests/Unit/helpers.php | 9 +++++ 4 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Events/WordPress/Menu/MenuEventDispatcherTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d7e6192..b381701e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.0-beta...develop) ### Fixed +- Deleting a navigation menu (`wp menu delete`, or the Menus screen) raised a `TypeError` once the menu was already gone: `delete_nav_menu` is the `delete_{$taxonomy}` hook, which passes the term ID first, and the listener expected a `WP_Term`. `MenuDeleted` now receives the menu WordPress copied before deleting it - While Vite ran hot, its client was enqueued with WordPress's version appended (`@vite/client?ver=7.1.2`). The modules Vite serves import `/@vite/client` by its bare URL, so the browser loaded the client twice, as two modules with two HMR connections. It is enqueued with no version, like the entries (regression from v13.34.0-beta) ### Changed diff --git a/src/Events/WordPress/Menu/MenuEventDispatcher.php b/src/Events/WordPress/Menu/MenuEventDispatcher.php index db2f26c2..67f2c434 100755 --- a/src/Events/WordPress/Menu/MenuEventDispatcher.php +++ b/src/Events/WordPress/Menu/MenuEventDispatcher.php @@ -61,10 +61,22 @@ public function handleWpUpdateNavMenu(int $menu_id): void /** * Handle menu deletion. + * + * `delete_nav_menu` is the `delete_{$taxonomy}` hook of wp_delete_term(): + * it passes the deleted term's ID first and its object, copied before + * deletion, third. + * + * @param int $termId ID of the deleted menu + * @param int $ttId Term taxonomy ID of the deleted menu + * @param mixed $deletedTerm The menu as it was before deletion */ - public function handleDeleteNavMenu(WP_Term $menu): void + public function handleDeleteNavMenu(int $termId, int $ttId, mixed $deletedTerm): void { - $this->dispatch(MenuDeleted::class, [$menu]); + if (! $deletedTerm instanceof WP_Term) { + return; + } + + $this->dispatch(MenuDeleted::class, [$deletedTerm]); } /** diff --git a/tests/Unit/Events/WordPress/Menu/MenuEventDispatcherTest.php b/tests/Unit/Events/WordPress/Menu/MenuEventDispatcherTest.php new file mode 100644 index 00000000..7c443faf --- /dev/null +++ b/tests/Unit/Events/WordPress/Menu/MenuEventDispatcherTest.php @@ -0,0 +1,33 @@ +events = Mockery::mock(Dispatcher::class); + $this->dispatcher = new MenuEventDispatcher($this->events, Mockery::mock(Action::class)); + }); + + it('dispatches MenuDeleted with the term WordPress passes to delete_nav_menu', function (): void { + $menu = new WP_Term; + $menu->term_id = 12; + $menu->taxonomy = 'nav_menu'; + + $this->events->shouldReceive('dispatch') + ->once() + ->withArgs(fn (object $event): bool => $event instanceof MenuDeleted && $event->menu === $menu); + + $this->dispatcher->handleDeleteNavMenu(12, 12, $menu, []); + }); + + it('dispatches nothing when the deleted term is not a WP_Term', function (): void { + $this->events->shouldNotReceive('dispatch'); + + $this->dispatcher->handleDeleteNavMenu(12, 12, null, []); + }); +}); diff --git a/tests/Unit/helpers.php b/tests/Unit/helpers.php index 41565f20..ca92dba6 100644 --- a/tests/Unit/helpers.php +++ b/tests/Unit/helpers.php @@ -254,6 +254,15 @@ public function __construct() } } +if (! class_exists('WP_Term')) { + class WP_Term + { + public $term_id = 0; + + public $taxonomy = ''; + } +} + if (! class_exists('WP_Screen')) { class WP_Screen { From b6d19f710c50d95930c58915efc4be9e19d55739 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 30 Sep 2026 11:20:55 +0200 Subject: [PATCH 8/9] chore: release v13.34.0-beta.2 Gathers the [Unreleased] entries under the version. Points the documentation submodule at pollora:doctor and make:theme activation. --- CHANGELOG.md | 4 +++- documentation | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 291c2503..ce75291b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ All notable changes to the Pollora framework will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.0-beta...develop) +## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.0-beta.2...develop) + +## [v13.34.0-beta.2](https://github.com/Pollora/framework/compare/v13.34.0-beta...v13.34.0-beta.2) - 2026-09-30 ### Added - `pollora:doctor`: checks a project for the failures that stay silent — the site renders, the command exits 0 — and prints, under each, the command that fixes it. Each check comes from a failure met in practice: WordPress core not patched or `__()` not Pollora's; `patches.lock.json` missing or older than the framework's patches; `.env` names Pollora does not read (`DB_NAME`, `WP_HOME`…) or MySQL settings on a sqlite connection; configuration or routes cached outside production; classes missing from the discovery cache; for the theme, every Pollora plugin and every enabled module: a build missing, written to another folder than Pollora reads, or a hot file pointing at a dev server that is stopped or not exposed, a directory symlinked under another name, `%theme_*%`/`%plugin_*%` placeholders or `.stub` files left from a copied template, blocks still in the legacy `resources/blocks`; pattern files WordPress never registers or has not cached; `Route::wp()` routes answering in place of a block theme's templates. `--json` for scripts; exits 1 on an error diff --git a/documentation b/documentation index 2b02a7eb..b81d9121 160000 --- a/documentation +++ b/documentation @@ -1 +1 @@ -Subproject commit 2b02a7ebf31343f5812d2e791dedbf6e0ac22dad +Subproject commit b81d912124281a4848f0a377889485703b7502c5 From a11038cca25423c27fbe21ad4272a64479b7db6c Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 30 Sep 2026 11:22:37 +0200 Subject: [PATCH 9/9] docs: point the documentation submodule at the current release --- documentation | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation b/documentation index b81d9121..d03b1a68 160000 --- a/documentation +++ b/documentation @@ -1 +1 @@ -Subproject commit b81d912124281a4848f0a377889485703b7502c5 +Subproject commit d03b1a687efba45f5866b0b41bfcdcfb48e2c845