From 92a9637d98578ad6c663c003415b9173b3381db4 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 24 Sep 2026 19:50:14 +0200 Subject: [PATCH 1/2] fix: show the hero block in the editor as the page shows it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit edit.jsx rendered the scaffold placeholder "Hero – Block Editor" while save.jsx stores "Hero": the editor did not show what the page would hold. It now mirrors save.jsx, as make:block's static stub does. --- app/Providers/BlocksServiceProvider.php | 35 ------------------------- resources/views/blocks/hero/edit.jsx | 5 +++- 2 files changed, 4 insertions(+), 36 deletions(-) delete mode 100644 app/Providers/BlocksServiceProvider.php diff --git a/app/Providers/BlocksServiceProvider.php b/app/Providers/BlocksServiceProvider.php deleted file mode 100644 index 09458c2..0000000 --- a/app/Providers/BlocksServiceProvider.php +++ /dev/null @@ -1,35 +0,0 @@ -registerDirectory( - directory: $directory, - containerName: 'theme', - ); - }); - } -} diff --git a/resources/views/blocks/hero/edit.jsx b/resources/views/blocks/hero/edit.jsx index 281c68f..7baaa23 100644 --- a/resources/views/blocks/hero/edit.jsx +++ b/resources/views/blocks/hero/edit.jsx @@ -1,12 +1,15 @@ import { useBlockProps } from '@wordpress/block-editor'; import { __ } from '@wordpress/i18n'; +/** + * Mirrors save.jsx, so the editor shows the markup the page will hold. + */ export default function Edit() { const blockProps = useBlockProps(); return (
-

{__('Hero – Block Editor', '%theme_name%')}

+

{__('Hero', '%theme_name%')}

); } From b899ebca627ee77b6929b8acba51741757e0abab Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 24 Sep 2026 19:50:14 +0200 Subject: [PATCH 2/2] refactor: let the framework register the blocks Framework v13.32.0-beta.7 registers every theme's resources/views/blocks itself. BlocksServiceProvider deferred its own registration to `init`, which has already run when a theme's providers boot in a web request: it only ever registered the blocks under WP-CLI. It goes, with the contract test that required it. The install check asked WP-CLI for the registry, the context that hid this. It now asks the REST API, logged in, over HTTP. v13.4.0 registers no block in a web request and is tagged, so its leg passes --expect-blocks=no and the check asserts that absence. The overlay only added and overwrote files, so a file deleted by the commit under test survived from the tag and CI measured a theme that still had it. It now removes, under app/, config/ and resources/, what the commit no longer has. --- .github/workflows/tests.yml | 11 +- bin/ci/check-install.php | 205 ++++++++++++++++++++++++++++-------- bin/tests/contract.php | 29 ----- 3 files changed, 169 insertions(+), 76 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7867fd5..a00f9a8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -147,10 +147,10 @@ jobs: - name: Place the commit under test where the container can reach it run: cp -r theme site/theme-under-test - # --expect-blocks is deliberately not passed: the blocks have to reach - # the registry on both framework generations. They did not until the - # theme's BlocksServiceProvider stopped registering them from boot(), - # before WordPress accepts block registrations at all. + # v13.4.0 registers the theme's blocks under WP-CLI only, never in a web + # request, and it is tagged: --expect-blocks=no writes that down, and + # the check says so the day it changes. dev-main runs a framework that + # registers resources/views/blocks itself (13.32.0-beta.7 and later). - name: Install check working-directory: site run: | @@ -158,7 +158,8 @@ jobs: php theme-under-test/bin/ci/check-install.php \ --url=https://pollora-theme.ddev.site \ --slug=ci-theme \ - --source=/var/www/html/theme-under-test + --source=/var/www/html/theme-under-test \ + --expect-blocks=${{ matrix.skeleton == 'v13.4.0' && 'no' || 'yes' }} ' - name: Laravel log diff --git a/bin/ci/check-install.php b/bin/ci/check-install.php index bc9e170..1d9cfd5 100644 --- a/bin/ci/check-install.php +++ b/bin/ci/check-install.php @@ -45,14 +45,12 @@ $slug = (string) ($options['slug'] ?? 'ci-theme'); $source = isset($options['source']) ? rtrim((string) $options['source'], '/') : null; -// Whether this framework discovers blocks from resources/views/blocks. -// -// Measured, not assumed: on framework 13.4 the theme's BlocksServiceProvider -// boots before WordPress has defined register_block_type(), so -// BlockRegistrar::registerDirectory() returns early and the blocks never -// arrive. The caller says which behaviour it expects rather than the script -// skipping when it does not find them — a check that quietly turns itself off -// is how a broken fix shipped behind a green suite. +// Whether this framework registers the theme's resources/views/blocks in a web +// request. Measured, not assumed: 13.32.0-beta.7 does; 13.4 never did — its +// registration only ever happened under WP-CLI. The caller says which +// behaviour it expects rather than the script skipping when it does not find +// them — a check that quietly turns itself off is how a broken fix shipped +// behind a green suite. $expectBlocks = ($options['expect-blocks'] ?? 'yes') !== 'no'; $host = parse_url($baseUrl, PHP_URL_HOST) ?: ''; @@ -218,7 +216,104 @@ function overlaySource(string $source, string $themeDir, string $slug): void $copied++; } - echo " \033[2m→ overlaid {$copied} files from the commit under test\033[0m\n"; + // A copy only adds and overwrites. A file the commit under test deletes + // would survive from the tag, and CI would measure a theme that still has + // it — BlocksServiceProvider was the first such removal. Limited to the + // source directories: the build and node_modules are not in the repository. + $removed = 0; + + foreach (['app', 'config', 'resources'] as $directory) { + if (! is_dir($themeDir.'/'.$directory)) { + continue; + } + + $generated = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($themeDir.'/'.$directory, FilesystemIterator::SKIP_DOTS) + ); + + foreach ($generated as $file) { + $relative = substr($file->getPathname(), strlen($themeDir) + 1); + + if (! file_exists($source.'/'.$relative)) { + unlink($file->getPathname()); + $removed++; + } + } + } + + echo " \033[2m→ overlaid {$copied} files from the commit under test, removed {$removed} it deletes\033[0m\n"; +} + +/** + * A logged-in session, for what only an editor may read: the REST API's block + * types. The REST API accepts the session's cookie once it is sent with the + * nonce that goes with it. + */ +final class Session +{ + private readonly string $jar; + + /** @var list */ + private array $headers = []; + + public function __construct() + { + $this->jar = tempnam(sys_get_temp_dir(), 'theme-ci-').'.cookies'; + } + + public function __destruct() + { + @unlink($this->jar); + } + + public function logIn(string $loginUrl, string $adminUrl, string $user, string $password): void + { + $this->visit($loginUrl, ['log' => $user, 'pwd' => $password, 'wp-submit' => 'Log In', 'testcookie' => '1']); + + // Handed out to a logged-in session only. + $nonce = trim($this->visit($adminUrl.'/admin-ajax.php?action=rest-nonce')['body']); + + if (preg_match('/^[a-f0-9]{10}$/', $nonce) !== 1) { + throw new \RuntimeException('no REST nonce — the login did not establish a session'); + } + + $this->headers = ['X-WP-Nonce: '.$nonce]; + } + + /** + * @param array $post + * @return array{status: int, body: string} + */ + public function visit(string $url, array $post = []): array + { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_TIMEOUT => 45, + CURLOPT_COOKIEJAR => $this->jar, + CURLOPT_COOKIEFILE => $this->jar, + CURLOPT_HTTPHEADER => $this->headers, + ]); + + if ($post !== []) { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post)); + } + + $body = curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error !== '') { + throw new \RuntimeException("{$url}: {$error}"); + } + + return ['status' => $status, 'body' => is_string($body) ? $body : '']; + } } echo "\n\033[1m=== theme-default — install check ===\033[0m\n"; @@ -425,44 +520,70 @@ function overlaySource(string $source, string $themeDir, string $slug): void return $wrong === [] ? true : implode(', ', $wrong); }); -test( - $expectBlocks - ? 'Every block reaches the WordPress block registry' - : 'No block reaches the registry — this framework cannot discover them', - function () use ($manifests, $expectBlocks, $slug) { - if ($manifests === []) { - return 'no block.json reached the generated theme'; - } - - $registered = explode(',', wpEval( - 'echo implode(",", array_keys(WP_Block_Type_Registry::get_instance()->get_all_registered()));' - )); - - $found = []; - $absent = []; +// Asked of the REST API over HTTP, not of WP-CLI. WP-CLI boots WordPress in +// an order no web request uses: on framework 13.4 the theme's +// BlocksServiceProvider registered the blocks there and nowhere else, so a +// `wp eval` found them while the editor, the page and the REST API had none. +// Since 13.32.0-beta.7 the framework registers resources/views/blocks itself +// and the provider is gone. +$blockAccount = 'theme-ci-blocks'; +$blockPassword = bin2hex(random_bytes(12)); + +run('wp user delete '.escapeshellarg($blockAccount).' --yes'); + +try { + test( + $expectBlocks + ? 'Every block reaches the REST API' + : 'No block reaches the REST API — this framework does not register them in a web request', + function () use ($manifests, $expectBlocks, $slug, $blockAccount, $blockPassword) { + if ($manifests === []) { + return 'no block.json reached the generated theme'; + } - foreach ($manifests as $manifest) { - $name = (string) (json_decode((string) file_get_contents($manifest), true)['name'] ?? ''); + wpOrFail('user create '.escapeshellarg($blockAccount).' theme-ci-blocks@example.test' + .' --role=administrator --user_pass='.escapeshellarg($blockPassword).' --porcelain'); + + $session = new Session; + $session->logIn( + trim(wpEval('echo wp_login_url();')), + rtrim(trim(wpEval('echo admin_url();')), '/'), + $blockAccount, + $blockPassword + ); + + $restUrl = trim(wpEval('echo rest_url();')); + $found = []; + $absent = []; + + foreach ($manifests as $manifest) { + $name = (string) (json_decode((string) file_get_contents($manifest), true)['name'] ?? ''); + $status = $session->visit($restUrl.'wp/v2/block-types/'.$name)['status']; + + match ($status) { + 200 => $found[] = $name, + 404 => $absent[] = $name, + default => throw new \RuntimeException("the REST API answered {$status} for {$name}"), + }; + } - in_array($name, $registered, true) ? $found[] = $name : $absent[] = $name; - } + if ($expectBlocks) { + return $absent === [] + ? true + : implode(', ', $absent).' did not reach the REST API'; + } - if ($expectBlocks) { - return $absent === [] + // Asserted in the negative on purpose: v13.4.0 is tagged and + // cannot change, so what it does is written down. The day a + // backport changes it, this says so instead of passing either way. + return $found === [] ? true - : implode(', ', $absent).' did not reach the registry'; + : implode(', ', $found)." now registers on this framework; drop --expect-blocks=no for {$slug}"; } - - // Asserted in the negative on purpose. Framework 13.4 boots the - // theme's providers before WordPress defines register_block_type(), - // so registerDirectory() returns early. Recording that as an - // expectation means the day it changes — a fix, a backport — this - // says so, instead of a skip quietly passing either way. - return $found === [] - ? true - : implode(', ', $found)." now registers on this framework; drop --expect-blocks=no for {$slug}"; - } -); + ); +} finally { + run('wp user delete '.escapeshellarg($blockAccount).' --yes'); +} section('Login screen — the theme\'s design, or WordPress\'s, and which one it is'); diff --git a/bin/tests/contract.php b/bin/tests/contract.php index e2004a4..1a612d2 100644 --- a/bin/tests/contract.php +++ b/bin/tests/contract.php @@ -217,35 +217,6 @@ function checkNoLeakedCodeName(): void : 'style.css lost '.implode(', ', $missing).' — generated themes will carry this template\'s own header'; }); - // Cheap guard for an expensive lesson. v1.4.0 registered the blocks - // straight from the provider's boot(), which runs before WordPress has - // defined register_block_type(); the registrar answers that by returning - // immediately, so the blocks simply never existed and nothing said so. - // Only the install job can prove they arrive — this says where the call - // has to live, in a second. - test('Blocks are registered on init, not from the provider boot', function () { - $provider = themePath('app/Providers/BlocksServiceProvider.php'); - - if (! is_file($provider)) { - return 'app/Providers/BlocksServiceProvider.php is gone — the blocks have nothing registering them'; - } - - $source = (string) file_get_contents($provider); - - // The call, not the word: the comment above it names the method too. - $call = strpos($source, '->registerDirectory('); - - if ($call === false) { - return 'the provider no longer calls registerDirectory()'; - } - - $hook = strpos($source, "add_action('init'"); - - return $hook !== false && $hook < $call - ? true - : 'registerDirectory() is called before WordPress accepts block registrations, so no block will exist'; - }); - test('Blocks name themselves after the generated theme, not the template', function () { $blocks = glob(themePath('resources/views/blocks/*/block.json')) ?: [];