Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions dev/test/proof-gutenberg-compat.test.ts
Original file line number Diff line number Diff line change
@@ -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);
29 changes: 29 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 13 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 10 additions & 0 deletions scripts/proof-gutenberg-compat.d.mts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof buildPatternOverridesFixture>>;
expectedTree: WpBlock[];
imageBase64: string;
}): Promise<{ status: string; lanes: Array<{ name: string; status: string; error?: string }> }>;
127 changes: 127 additions & 0 deletions scripts/proof-gutenberg-compat.mjs
Original file line number Diff line number Diff line change
@@ -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', 'blocks', built.fixture.blockName.split('/')[1], '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(/<!-- wp:([^\s]+)\s+({[\s\S]*?})\s*-->/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 `<!-- wp:${name} ${JSON.stringify(attrs)} -->`;
});
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]));
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 = {};
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) };
}
assert.deepEqual(runtime.pageErrors, []);
assert.deepEqual(runtime.consoleErrors, []);
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;
}
11 changes: 8 additions & 3 deletions scripts/proof-playwright.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -2114,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');
Expand Down
1 change: 1 addition & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
Expand Down
11 changes: 11 additions & 0 deletions vitest.gutenberg-compat.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
Loading