From edaf07af9ea4f3138b3650eba0353388e5761416 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Mon, 28 Sep 2026 13:24:34 +0700 Subject: [PATCH 1/4] test: add isolated WordPress and Gutenberg compatibility lanes --- dev/test/proof-gutenberg-compat.test.ts | 25 +++++ scripts/proof-gutenberg-compat.d.mts | 10 ++ scripts/proof-gutenberg-compat.mjs | 127 ++++++++++++++++++++++++ scripts/proof-playwright.mjs | 4 +- vitest.config.ts | 1 + vitest.gutenberg-compat.config.ts | 11 ++ 6 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 dev/test/proof-gutenberg-compat.test.ts create mode 100644 scripts/proof-gutenberg-compat.d.mts create mode 100644 scripts/proof-gutenberg-compat.mjs create mode 100644 vitest.gutenberg-compat.config.ts diff --git a/dev/test/proof-gutenberg-compat.test.ts b/dev/test/proof-gutenberg-compat.test.ts new file mode 100644 index 0000000..e95dc0b --- /dev/null +++ b/dev/test/proof-gutenberg-compat.test.ts @@ -0,0 +1,25 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { expect, it } from 'vitest'; +import { buildPatternOverridesFixture } from '../../scripts/build-pattern-overrides-fixture.js'; +import { proveGutenbergCompatibility } from '../../scripts/proof-gutenberg-compat.mjs'; +import { getWp } from '../../src/headless/wp.js'; +import { PROOF_IMAGE_BASE64 } from '../../src/proof/fixture-image.js'; + +it('runs identical generated bytes through three isolated compatibility lanes', async () => { + const outputDir = process.env.BLOCK_RUNNER_COMPAT_OUTPUT_DIR + ? path.resolve(process.env.BLOCK_RUNNER_COMPAT_OUTPUT_DIR) + : await mkdtemp(path.join(tmpdir(), 'block-runner-compat-')); + await mkdir(outputDir, { recursive: true }); + process.stderr.write(`Compatibility evidence: ${outputDir}\n`); + try { + const built = await buildPatternOverridesFixture(path.join(outputDir, 'fixture')); + const expectedTree = (await getWp()).parse(built.nativeContainerMarkup); + const result = await proveGutenbergCompatibility({ root: process.cwd(), outputDir, built, expectedTree, imageBase64: PROOF_IMAGE_BASE64 }); + expect(result.status, JSON.stringify(result.lanes.map(({ name, status, error }) => ({ name, status, error })))).toBe('passed'); + } catch (error) { + await writeFile(path.join(outputDir, 'failure.json'), JSON.stringify({ status: 'failed', error: String(error) }, null, 2)); + throw error; + } +}, 1_800_000); diff --git a/scripts/proof-gutenberg-compat.d.mts b/scripts/proof-gutenberg-compat.d.mts new file mode 100644 index 0000000..a0cc6a9 --- /dev/null +++ b/scripts/proof-gutenberg-compat.d.mts @@ -0,0 +1,10 @@ +import type { buildPatternOverridesFixture } from './build-pattern-overrides-fixture.js'; +import type { WpBlock } from '../src/types.js'; + +export function proveGutenbergCompatibility(options: { + root: string; + outputDir: string; + built: Awaited>; + expectedTree: WpBlock[]; + imageBase64: string; +}): Promise<{ status: string; lanes: Array<{ name: string; status: string; error?: string }> }>; diff --git a/scripts/proof-gutenberg-compat.mjs b/scripts/proof-gutenberg-compat.mjs new file mode 100644 index 0000000..12cd74c --- /dev/null +++ b/scripts/proof-gutenberg-compat.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const execute = promisify(execFile); +const hash = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`; +const shape = (nodes) => nodes.map((node) => [node.name, shape(node.innerBlocks ?? [])]); +const count = (nodes) => nodes.reduce((sum, node) => sum + 1 + count(node.innerBlocks ?? []), 0); +const gates = ['client_registry', 'editor_inserter', 'editor_field_editing', 'editor_save', 'editor_reopen', 'frontend_status', 'frontend_semantics', 'frontend_media', 'frontend_assets', 'frontend_runtime_errors', 'pattern_overrides']; + +/** Development-only observations: never invokes the release receipt runner. */ +export async function proveGutenbergCompatibility({ root, outputDir, built, expectedTree, imageBase64 }) { + const identity = { + zip: hash(await readFile(built.pluginZip)), input: hash(await readFile(built.inputPath)), + template: hash(await readFile(path.join(built.pluginDirectory, 'src', 'edit.js'))), + }; + const receipt = { kind: 'development-compatibility', identity, lanes: [], status: 'failed' }; + const lanes = [ + { name: 'wordpress-7.1', core: '7.1', port: 8891 }, + { name: 'wordpress-7.1.2', core: '7.1.2', port: 8892 }, + { name: 'wordpress-7.1.2-gutenberg-24.0.0', core: '7.1.2', gutenberg: '24.0.0', port: 8893 }, + ]; + try { + for (const lane of lanes) { + const directory = path.join(outputDir, lane.name); + await mkdir(directory, { recursive: true }); + const config = path.join(directory, '.wp-env.json'); + const stage = path.join(directory, 'stage'); + await mkdir(stage, { recursive: true }); + await copyFile(built.pluginZip, path.join(stage, 'fixture.zip')); + await writeFile(config, JSON.stringify({ core: `WordPress/WordPress#${lane.core}`, phpVersion: '8.3', port: lane.port, + mappings: { 'wp-content/compat-stage': stage }, + config: { WP_DEBUG: true, WP_DEBUG_LOG: true, WP_DEBUG_DISPLAY: false, SCRIPT_DEBUG: true }, + testsEnvironment: false, + }, null, 2)); + const observed = { ...lane, identity, commands: [], status: 'failed' }; + receipt.lanes.push(observed); + let owned = false; + async function command(command, args, timeout = 60_000) { + try { + const result = await execute(command, args, { cwd: root, timeout, maxBuffer: 16 * 1024 * 1024 }); + observed.commands.push({ command, args, exitCode: 0, ...result }); + return result.stdout.trim(); + } catch (error) { + observed.commands.push({ command, args, exitCode: error.code ?? 1, stdout: error.stdout ?? '', stderr: `${error.message}\n${error.stderr ?? ''}` }); + throw error; + } + } + const env = (args, timeout) => command('npx', ['--no-install', 'wp-env', `--config=${config}`, ...args], timeout); + const wp = (args) => env(['run', 'cli', 'wp', ...args]); + try { + await command('docker', ['info', '--format', '{{.ServerVersion}}']); + const status = JSON.parse(await env(['status', '--json'])); + assert.ok(['uninitialized', 'stopped'].includes(status.status), 'Compatibility environment is already running; refusing to claim ownership.'); + owned = true; + await env(['start'], 360_000); + await wp(['plugin', 'install', '/var/www/html/wp-content/compat-stage/fixture.zip', '--activate']); + if (lane.gutenberg) await wp(['plugin', 'install', 'gutenberg', `--version=${lane.gutenberg}`, '--activate']); + observed.runtime = JSON.parse(await wp(['eval', `echo wp_json_encode(array('wordpress'=>get_bloginfo('version'),'php'=>PHP_VERSION,'theme'=>array('name'=>wp_get_theme()->get('Name'),'version'=>wp_get_theme()->get('Version')),'gutenberg'=>defined('GUTENBERG_VERSION')?GUTENBERG_VERSION:null));`])); + assert.equal(observed.runtime.wordpress, lane.core); + assert.equal(observed.runtime.gutenberg, lane.gutenberg ?? null); + assert.match(observed.runtime.php, /^8\.3\./); + const baseUrl = `http://localhost:${lane.port}`; + const fixture = JSON.parse(JSON.stringify(built.fixture).replaceAll('http://localhost:8888', baseUrl)); + // The compatibility matrix reuses field/persistence/pattern checks, not layout or accessibility acceptance. + delete fixture.browserMatrix; + const pattern = fixture.patternOverrides; + const media = JSON.parse(await wp(['eval', `require_once ABSPATH.'wp-admin/includes/image.php'; $result=[]; for($i=0;$i<2;$i++){ $upload=wp_upload_bits('compat-'.$i.'.png',null,base64_decode('${imageBase64}')); if($upload['error']) throw new RuntimeException($upload['error']); $id=wp_insert_attachment(['post_mime_type'=>'image/png','post_title'=>'Compatibility image '.$i,'post_status'=>'inherit'],$upload['file']); wp_update_attachment_metadata($id,wp_generate_attachment_metadata($id,$upload['file'])); $result[]=['id'=>$id,'url'=>wp_get_attachment_url($id)]; } echo wp_json_encode($result);`])); + pattern.instances.forEach((instance, i) => Object.values(instance.content).forEach((attrs) => { + if ('id' in attrs && 'url' in attrs) Object.assign(attrs, media[i]); + })); + fixture.frontend.expectedMedia = media.map((item) => item.url); + const savePattern = async (title, content) => JSON.parse(await wp(['eval', `$id=wp_insert_post(['post_type'=>'wp_block','post_status'=>'publish','post_title'=>base64_decode('${Buffer.from(title).toString('base64')}'),'post_content'=>base64_decode('${Buffer.from(content).toString('base64')}')],true); if(is_wp_error($id)) throw new RuntimeException($id->get_error_message()); update_post_meta($id,'wp_pattern_sync_status','sync'); echo wp_json_encode(['ref'=>$id,'canonicalContent'=>get_post_field('post_content',$id)]);`])); + const positive = await savePattern(pattern.title, pattern.canonicalContent); + pattern.ref = positive.ref; + pattern.storedCanonicalContent = positive.canonicalContent; + const negativeMarkup = pattern.canonicalContent.replace(//g, (comment, name, raw) => { + const attrs = JSON.parse(raw); + if (attrs.metadata?.name !== pattern.negative.name || !attrs.metadata.bindings) return comment; + delete attrs.metadata.bindings.__default; + delete attrs.metadata.bindings[pattern.negative.attribute]; + return ``; + }); + const title = `${pattern.title} missing binding`; + Object.assign(pattern.negative, await savePattern(title, negativeMarkup), { title }); + const browserConfig = path.join(directory, 'browser.json'); + const browserOutput = path.join(directory, 'browser-result.json'); + await writeFile(browserConfig, JSON.stringify({ fixture, baseUrl, requiredGates: gates })); + await command(process.execPath, [path.join(root, 'scripts/proof-playwright.mjs'), '--config', browserConfig, '--out', browserOutput], 360_000); + const browser = JSON.parse(await readFile(browserOutput, 'utf8')); + observed.runtime.browser = browser.environment.browser; + observed.gates = Object.fromEntries(gates.map((gate) => [gate, browser.gates[gate]?.status])); + for (const gate of gates) assert.equal(browser.gates[gate]?.status, 'pass', `${lane.name}: ${gate}: ${browser.gates[gate]?.reason}`); + const states = browser.gates.editor_reopen.details; + observed.children = {}; + for (const phase of ['preEdit', 'saved', 'reopened']) { + const roots = states[phase].tree.filter((block) => block.name === fixture.blockName); + assert.equal(roots.length, 1); + assert.deepEqual(shape(roots[0].innerBlocks), shape(expectedTree), `${phase} must contain exactly the compiled tree`); + observed.children[phase] = { count: count(roots[0].innerBlocks), shape: shape(roots[0].innerBlocks) }; + } + const runtime = browser.gates.frontend_runtime_errors.details.runtime; + observed.console = runtime; + assert.deepEqual(runtime.pageErrors, []); + assert.deepEqual(runtime.consoleErrors, []); + observed.templateDeprecations = runtime.consoleWarnings.filter((warning) => warning.includes('The template prop of InnerBlocks and useInnerBlocksProps')); + if (lane.gutenberg) assert.ok(observed.templateDeprecations.length, 'Expected template deprecation was not observed.'); + assert.equal(hash(await readFile(built.pluginZip)), identity.zip); + observed.status = 'passed'; + } catch (error) { + observed.error = error.stack ?? String(error); + // Continue independent lanes while retaining the failed lane. + } finally { + if (owned) await env(['stop']).catch((error) => { observed.status = 'failed'; observed.cleanupError = String(error); }); + await writeFile(path.join(directory, 'receipt.json'), `${JSON.stringify(observed, null, 2)}\n`); + process.stderr.write(`${lane.name}: ${observed.status} (${directory})\n`); + } + } + receipt.status = receipt.lanes.every((lane) => lane.status === 'passed') ? 'passed' : 'failed'; + } finally { + await writeFile(path.join(outputDir, 'compatibility.json'), `${JSON.stringify(receipt, null, 2)}\n`); + } + return receipt; +} diff --git a/scripts/proof-playwright.mjs b/scripts/proof-playwright.mjs index c2f5377..a309802 100644 --- a/scripts/proof-playwright.mjs +++ b/scripts/proof-playwright.mjs @@ -90,12 +90,14 @@ await page.context().tracing.start({ screenshots: true, snapshots: true, sources page.setDefaultTimeout(20_000); page.setDefaultNavigationTimeout(20_000); const consoleErrors = []; +const consoleWarnings = []; const pageErrors = []; const responses = []; let publication; let patternLifecycle; page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()); + if (message.type() === 'warning') consoleWarnings.push(message.text()); }); page.on('pageerror', (error) => pageErrors.push(error.message)); page.on('response', (response) => { @@ -272,7 +274,7 @@ try { if (!gates[gate]) blocked(gate, error instanceof Error ? error.message : String(error)); } } finally { - const runtime = { consoleErrors, pageErrors, responses }; + const runtime = { consoleErrors, consoleWarnings, pageErrors, responses }; if (gates.frontend_runtime_errors?.details) { gates.frontend_runtime_errors.details.runtime = runtime; } diff --git a/vitest.config.ts b/vitest.config.ts index 3c3a938..86d3f06 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ 'dev/test/proof-real-wordpress.test.ts', 'dev/test/proof-query-wordpress.test.ts', 'dev/test/proof-tabs-wordpress.test.ts', + 'dev/test/proof-gutenberg-compat.test.ts', 'dev/test/plugin.consumer.test.ts', 'dev/test/proof-native-style-adapter-builder.test.ts', ], diff --git a/vitest.gutenberg-compat.config.ts b/vitest.gutenberg-compat.config.ts new file mode 100644 index 0000000..ec52ff0 --- /dev/null +++ b/vitest.gutenberg-compat.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +/** Development compatibility observations, separate from release acceptance. */ +export default defineConfig({ + test: { + environment: 'node', + include: ['dev/test/proof-gutenberg-compat.test.ts'], + fileParallelism: false, + testTimeout: 1_800_000, + }, +}); From db2601dd089d08b210b178f082f9132387ecda7b Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Mon, 28 Sep 2026 13:25:14 +0700 Subject: [PATCH 2/4] test: bind compatibility hash to generated block source --- scripts/proof-gutenberg-compat.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/proof-gutenberg-compat.mjs b/scripts/proof-gutenberg-compat.mjs index 12cd74c..b30d3a6 100644 --- a/scripts/proof-gutenberg-compat.mjs +++ b/scripts/proof-gutenberg-compat.mjs @@ -15,7 +15,7 @@ const gates = ['client_registry', 'editor_inserter', 'editor_field_editing', 'ed export async function proveGutenbergCompatibility({ root, outputDir, built, expectedTree, imageBase64 }) { const identity = { zip: hash(await readFile(built.pluginZip)), input: hash(await readFile(built.inputPath)), - template: hash(await readFile(path.join(built.pluginDirectory, 'src', 'edit.js'))), + template: hash(await readFile(path.join(built.pluginDirectory, 'src', 'blocks', built.fixture.blockName.split('/')[1], 'edit.js'))), }; const receipt = { kind: 'development-compatibility', identity, lanes: [], status: 'failed' }; const lanes = [ From a54e1104b24ba1e666a2fa488d9ed4ec49480925 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Mon, 28 Sep 2026 13:26:09 +0700 Subject: [PATCH 3/4] docs: explain isolated template compatibility proof --- docs/development.md | 29 +++++++++++++++++++++++++++++ docs/reference.md | 13 +++++++++++++ 2 files changed, 42 insertions(+) diff --git a/docs/development.md b/docs/development.md index 846f095..60c9a41 100644 --- a/docs/development.md +++ b/docs/development.md @@ -91,3 +91,32 @@ not turn unrun browser/editor work or a model/tool failure into a zero product s package, installer, and activation checks are run with `npm run release:check`; see [`dev/release/0.9-testing`](https://github.com/humanmade/block-runner/blob/main/dev/release/0.9-testing/README.md) for the receipt matrix and the draft product-preview brief. + +## WordPress and Gutenberg compatibility probe + +The development compatibility suite builds the existing pattern-override plugin once, +then installs the same ZIP in three sequential, isolated PHP 8.3 environments: +WordPress 7.1; WordPress 7.1.2; and WordPress 7.1.2 with Gutenberg 24.0.0. +It uses ports 8891–8893 and refuses to claim an already-running lane. + +```sh +npm ci +npx --no-install playwright install chromium +BLOCK_RUNNER_COMPAT_OUTPUT_DIR=/tmp/block-runner-compat \ + npx --no-install vitest run --config vitest.gutenberg-compat.config.ts +``` + +Use a fresh output directory for each run. Without that variable, the suite creates +an isolated temporary directory and prints its path. `compatibility.json` records the +ZIP, input and generated-template hashes plus each lane's result. Each lane retains +`receipt.json` (versions and command output), `browser-result.json` (editor states, +frontend checks and pattern isolation), and browser screenshots/trace under `artifacts/`. +Build or runtime failures retain failure evidence and fail the suite; they are not skips. +On macOS, use OrbStack and check the effective Docker endpoint before running it. + +This is a development compatibility observation, not the public WordPress 7.1 release +receipt or manual acceptance. It compares child trees immediately after insertion and +after save/reopen, exercises bundled media and a supported field edit, and reuses the +existing two-instance pattern-override lifecycle. Browser console warnings remain +visible, including Gutenberg's expected template-prop deprecation. No model calls or +collaborative-editing server are involved. diff --git a/docs/reference.md b/docs/reference.md index 3a78a8f..0801c31 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -457,3 +457,16 @@ Approved font families get block-specific names and shared editor/frontend CSS. notices are retained separately in the production archive because minifiers can remove CSS comments. Unlicensed or unsupported faces use a safe fallback with a source-located warning. Destination theme font presets do not require copying font files. + +### Generated template compatibility + +Generated blocks retain `template: TEMPLATE` in `useInnerBlocksProps` for the WordPress +7.1 floor. [WordPress 7.1's template hook](https://github.com/WordPress/WordPress/blob/b998fef9238af183f9523b3df71618e6e57498b6/wp-includes/js/dist/block-editor.js) +uses the prop. [Gutenberg 24.0's hook](https://github.com/WordPress/gutenberg/blob/50ad79ef7ebc5f1d2a75def068887e33a15f2634/packages/block-editor/src/components/inner-blocks/use-inner-block-template-sync.js) +warns about that prop but still executes it, and reads the block type's template only +when the prop is absent. A settings-only migration needs an approved runtime boundary +or a demonstrated compatibility mechanism; moving it into `block.json` is not this change. + +The [development compatibility probe](development.md#wordpress-and-gutenberg-compatibility-probe) +keeps its observations separate from release acceptance. A successful single-user +save/reopen run does not establish collaborative editing support. From c2cb0f46099197392954e16e40b170120f0f6f1a Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Mon, 28 Sep 2026 13:37:23 +0700 Subject: [PATCH 4/4] test: let native editor release its lock before anonymous proof --- scripts/proof-gutenberg-compat.mjs | 6 +++--- scripts/proof-playwright.mjs | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/proof-gutenberg-compat.mjs b/scripts/proof-gutenberg-compat.mjs index b30d3a6..d013856 100644 --- a/scripts/proof-gutenberg-compat.mjs +++ b/scripts/proof-gutenberg-compat.mjs @@ -93,6 +93,9 @@ export async function proveGutenbergCompatibility({ root, outputDir, built, expe const browser = JSON.parse(await readFile(browserOutput, 'utf8')); observed.runtime.browser = browser.environment.browser; observed.gates = Object.fromEntries(gates.map((gate) => [gate, browser.gates[gate]?.status])); + const runtime = browser.gates.frontend_runtime_errors?.details?.runtime; + observed.console = runtime; + observed.templateDeprecations = (runtime?.consoleWarnings ?? []).filter((warning) => warning.includes('The template prop of InnerBlocks and useInnerBlocksProps')); for (const gate of gates) assert.equal(browser.gates[gate]?.status, 'pass', `${lane.name}: ${gate}: ${browser.gates[gate]?.reason}`); const states = browser.gates.editor_reopen.details; observed.children = {}; @@ -102,11 +105,8 @@ export async function proveGutenbergCompatibility({ root, outputDir, built, expe assert.deepEqual(shape(roots[0].innerBlocks), shape(expectedTree), `${phase} must contain exactly the compiled tree`); observed.children[phase] = { count: count(roots[0].innerBlocks), shape: shape(roots[0].innerBlocks) }; } - const runtime = browser.gates.frontend_runtime_errors.details.runtime; - observed.console = runtime; assert.deepEqual(runtime.pageErrors, []); assert.deepEqual(runtime.consoleErrors, []); - observed.templateDeprecations = runtime.consoleWarnings.filter((warning) => warning.includes('The template prop of InnerBlocks and useInnerBlocksProps')); if (lane.gutenberg) assert.ok(observed.templateDeprecations.length, 'Expected template deprecation was not observed.'); assert.equal(hash(await readFile(built.pluginZip)), identity.zip); observed.status = 'passed'; diff --git a/scripts/proof-playwright.mjs b/scripts/proof-playwright.mjs index a309802..701348f 100644 --- a/scripts/proof-playwright.mjs +++ b/scripts/proof-playwright.mjs @@ -2116,8 +2116,11 @@ async function proveFrontend(page, fixture, baseUrl, activePublication, artifact const responseStart = responses.length; const consoleStart = consoleErrors.length; const pageErrorStart = pageErrors.length; - // Frontend means the published visitor experience, not the authenticated - // editor's admin bar (which can also overlay a scoped screenshot). + // Leave the editor while still authenticated so its unload beacon can release + // the post lock. Clearing cookies first makes Gutenberg 24.0's native + // wp-remove-post-lock request fail with HTTP 400 during frontend navigation. + await page.goto('about:blank', { waitUntil: 'networkidle' }); + // Frontend means the published visitor experience, not the editor's admin bar. await page.context().clearCookies(); const response = await page.goto(new URL(activePublication.permalink, baseUrl).toString(), { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('load');