diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 438d529c..bbf29267 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -966,11 +966,30 @@ async function captureExpectedEditorPresentationIdentities( ): Promise<{ identities: string[]; complete: boolean } | undefined> { if (target.kind !== "post" || !target.postId) return undefined const response = await runPlaygroundCommand("wordpress.editor-open.capture-presentation-contract", server, { - code: bootstrapPhpCode(runtimeSpec, ` -$post = get_post(${target.postId}); + code: bootstrapPhpCode(runtimeSpec, editorPresentationContractPhpCode(target.postId), []), + }) + assertPlaygroundResponseOk("wordpress.editor-open.capture-presentation-contract", response) + const value = parseEditorPresentationContract(response.text) as { identities?: unknown; complete?: unknown } + if (!Array.isArray(value.identities) || value.complete !== true || !value.identities.every((identity) => typeof identity === "string" && /^[a-f0-9]{64}$/.test(identity))) { + throw new Error("wordpress.editor-open presentation contract returned an invalid identity set") + } + return { identities: [...new Set(value.identities)].sort(), complete: true } +} + +export function editorPresentationContractPhpCode(postId: number): string { + return ` +$post = get_post(${postId}); if ( ! $post instanceof WP_Post ) { throw new RuntimeException( 'Editor target post is unavailable.' ); } -wp_styles(); +$GLOBALS['post'] = $post; +if ( ! function_exists( 'set_current_screen' ) ) { require_once ABSPATH . 'wp-admin/includes/screen.php'; } +set_current_screen( 'post' ); +$wp_styles = wp_styles(); wp_scripts(); +$queued_before_editor_assets = $wp_styles->queue; +do_action( 'enqueue_block_assets' ); +$editor_style_handles = array_values( array_diff( $wp_styles->queue, $queued_before_editor_assets ) ); +$wp_styles->all_deps( $editor_style_handles ); +$editor_style_handles = array_values( array_unique( array_merge( $editor_style_handles, $wp_styles->to_do ) ) ); $settings = get_block_editor_settings( array(), new WP_Block_Editor_Context( array( 'post' => $post ) ) ); $identities = array(); foreach ( (array) ( $settings['styles'] ?? array() ) as $style ) { @@ -979,17 +998,15 @@ foreach ( (array) ( $settings['styles'] ?? array() ) as $style ) { foreach ( $matches[1] as $identity ) { $identities[] = strtolower( $identity ); } } } +foreach ( $editor_style_handles as $handle ) { + $style = $wp_styles->registered[$handle] ?? null; + $version = is_object( $style ) ? ( $style->ver ?? null ) : null; + if ( is_string( $version ) && preg_match( '/^[a-f0-9]{64}$/iD', $version ) ) { $identities[] = strtolower( $version ); } +} $identities = array_values( array_unique( $identities ) ); sort( $identities, SORT_STRING ); echo PHP_EOL . '${EDITOR_PRESENTATION_CONTRACT_MARKER}' . base64_encode( (string) wp_json_encode( array( 'identities' => $identities, 'complete' => true ) ) ) . PHP_EOL; -`, []), - }) - assertPlaygroundResponseOk("wordpress.editor-open.capture-presentation-contract", response) - const value = parseEditorPresentationContract(response.text) as { identities?: unknown; complete?: unknown } - if (!Array.isArray(value.identities) || value.complete !== true || !value.identities.every((identity) => typeof identity === "string" && /^[a-f0-9]{64}$/.test(identity))) { - throw new Error("wordpress.editor-open presentation contract returned an invalid identity set") - } - return { identities: [...new Set(value.identities)].sort(), complete: true } +` } export function parseEditorPresentationContract(output: string): unknown { diff --git a/tests/editor-presentation-contract.test.ts b/tests/editor-presentation-contract.test.ts new file mode 100644 index 00000000..54bc0aa1 --- /dev/null +++ b/tests/editor-presentation-contract.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict" +import { editorPresentationContractPhpCode, parseEditorPresentationContract } from "../packages/runtime-playground/src/editor-command-runners.js" +import { runCommandText } from "../scripts/test-kit.js" + +const inlineIdentity = "A".repeat(64) +const externalIdentity = "b".repeat(64) +const unrelatedIdentity = "c".repeat(64) + +async function captureContract(inlineCss: string[], editorVersions: unknown[], prequeuedVersions: unknown[] = []): Promise<{ identities: string[]; complete: boolean }> { + const php = ` +define( 'ABSPATH', '/tmp/' ); +class WP_Post {} +class WP_Block_Editor_Context { public function __construct( public array $context ) {} } +$fixture_inline_css = ${JSON.stringify(inlineCss)}; +$fixture_editor_versions = ${JSON.stringify(editorVersions)}; +$fixture_prequeued_versions = ${JSON.stringify(prequeuedVersions)}; +$fixture_styles = new class { + public array $queue = array(); + public array $registered = array(); + public array $to_do = array(); + public function all_deps( $handles ) { $this->to_do = $handles; } +}; +foreach ( $fixture_prequeued_versions as $index => $version ) { + $handle = 'unrelated-' . $index; + $fixture_styles->queue[] = $handle; + $fixture_styles->registered[$handle] = (object) array( 'ver' => $version ); +} +function get_post( $post_id ) { return new WP_Post(); } +function set_current_screen( $screen ) {} +function wp_styles() { return $GLOBALS['fixture_styles']; } +function wp_scripts() {} +function do_action( $hook ) { + if ( 'enqueue_block_assets' !== $hook ) return; + foreach ( $GLOBALS['fixture_editor_versions'] as $index => $version ) { + $handle = 'editor-' . $index; + $GLOBALS['fixture_styles']->queue[] = $handle; + $GLOBALS['fixture_styles']->registered[$handle] = (object) array( 'ver' => $version ); + } +} +function get_block_editor_settings( $settings, $context ) { + return array( 'styles' => array_map( static fn ( $css ) => array( 'css' => $css ), $GLOBALS['fixture_inline_css'] ) ); +} +function wp_json_encode( $value ) { return json_encode( $value ); } +${editorPresentationContractPhpCode(17)} +` + const output = await runCommandText("php", ["-r", php]) + return parseEditorPresentationContract(output) as { identities: string[]; complete: boolean } +} + +assert.deepEqual(await captureContract([`:root{--blocks-engine-presentation:${inlineIdentity};}`], []), { + identities: [inlineIdentity.toLowerCase()], + complete: true, +}, "inline-only delivery remains in the expected contract") + +assert.deepEqual(await captureContract([], [externalIdentity.toUpperCase()]), { + identities: [externalIdentity], + complete: true, +}, "external-only editor delivery contributes its canonical version identity") + +assert.deepEqual(await captureContract([ + `/* --blocks-engine-presentation:${inlineIdentity} */`, + `/* --blocks-engine-presentation:${externalIdentity} */`, +], [externalIdentity.toUpperCase()]), { + identities: [inlineIdentity.toLowerCase(), externalIdentity].sort(), + complete: true, +}, "mixed delivery is normalized, deduplicated, and sorted") + +assert.deepEqual(await captureContract([], ["6.7.1", "not-a-hash", ` ${externalIdentity}`, `${externalIdentity}0`, null], [unrelatedIdentity]), { + identities: [], + complete: true, +}, "non-hash versions and unrelated prequeued stylesheets are ignored")