diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index c6a12cd8..12ffff36 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -853,6 +853,7 @@ interface EditorPresentationCapture { canvasDocumentType: "iframe" | "parent" iframeCount: number stylesheetUrls: string[] + loadedStylesheetUrls: string[] inlineStyleContents: string[] } @@ -872,12 +873,14 @@ function externalStylesheetPresentationIdentity(url: string): string | undefined export function summarizeEditorPresentation(capture: EditorPresentationCapture, expectedIdentities: readonly string[] = []): BrowserEditorPresentationSummary { const iframeStylesheetUrls = [...new Set(capture.stylesheetUrls.map((url) => url.trim()).filter(Boolean))].sort() + const loadedStylesheetUrls = new Set(capture.loadedStylesheetUrls.map((url) => url.trim()).filter(Boolean)) const inlineIdentities = capture.inlineStyleContents.flatMap((content) => [...content.matchAll(/blocks-engine-presentation:([a-f0-9]{64})/gi)].map((match) => match[1]!.toLowerCase())) // Only an expected identity can be certified from a URL version. An // unrequested or arbitrary version stays out of the observed set so the // expected-set comparison remains fail-closed. const expected = new Set(expectedIdentities.map((identity) => identity.trim().toLowerCase()).filter(Boolean)) const externalIdentities = iframeStylesheetUrls.flatMap((url) => { + if (!loadedStylesheetUrls.has(url)) return [] const identity = externalStylesheetPresentationIdentity(url) return identity && expected.has(identity) ? [identity] : [] }) @@ -928,6 +931,9 @@ export async function captureEditorPresentation(page: import("playwright").Page, canvasDocumentType, iframeCount, stylesheetUrls: stylesheets.flatMap((stylesheet) => stylesheet.href ? [stylesheet.href] : []), + // A link is not proof of delivery: failed stylesheet requests leave + // sheet unset and must not satisfy an expected presentation identity. + loadedStylesheetUrls: stylesheets.flatMap((stylesheet) => stylesheet.href && stylesheet.sheet ? [stylesheet.href] : []), inlineStyleContents: Array.from(document.querySelectorAll("style"), (style) => style.textContent ?? ""), } }, { canvasDocumentType, iframeCount: canvasDocumentType === "iframe" ? 1 : 0 }).catch(() => null) as (EditorPresentationCapture & { documentIdentity: string; documentAgeMs: number }) | null @@ -995,13 +1001,6 @@ 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 ) { @@ -1010,10 +1009,19 @@ 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 ); } +// WordPress collects iframe assets in an isolated styles instance, so the +// resolved asset HTML is the authoritative external stylesheet contract. +$resolved_styles = (string) ( $settings['__unstableResolvedAssets']['styles'] ?? '' ); +if ( preg_match_all( '~]*\\bhref=([^[:space:]>]+)~i', $resolved_styles, $links ) ) { + foreach ( $links[1] as $href ) { + $url = trim( html_entity_decode( $href, ENT_QUOTES, 'UTF-8' ), chr(34) . chr(39) ); + $query = wp_parse_url( $url, PHP_URL_QUERY ); + if ( ! is_string( $query ) ) { continue; } + parse_str( $query, $parameters ); + $version = $parameters['ver'] ?? null; + if ( is_array( $version ) ) { continue; } + 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 ); diff --git a/tests/editor-actions.test.ts b/tests/editor-actions.test.ts index 19295c55..40f2d37e 100644 --- a/tests/editor-actions.test.ts +++ b/tests/editor-actions.test.ts @@ -72,6 +72,7 @@ const styledPresentation = summarizeEditorPresentation({ canvasDocumentType: "iframe", iframeCount: 2, stylesheetUrls: ["https://example.test/styles/editor.css", "https://example.test/styles/editor.css", "https://example.test/styles/theme.css"], + loadedStylesheetUrls: ["https://example.test/styles/editor.css", "https://example.test/styles/theme.css"], inlineStyleContents: [ "/* blocks-engine-presentation:ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789 */", "/* blocks-engine-presentation:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 */", @@ -86,7 +87,7 @@ assert.deepEqual(styledPresentation, { generatedPresentationIdentityCount: 1, generatedPresentationIdentities: ["abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"], }) -assert.deepEqual(summarizeEditorPresentation({ canvasDocumentType: "parent", iframeCount: 0, stylesheetUrls: [], inlineStyleContents: [".editor { color: black; }"] }), { +assert.deepEqual(summarizeEditorPresentation({ canvasDocumentType: "parent", iframeCount: 0, stylesheetUrls: [], loadedStylesheetUrls: [], inlineStyleContents: [".editor { color: black; }"] }), { schema: "wp-codebox/editor-presentation/v1", canvasDocumentType: "parent", iframeCount: 0, @@ -106,6 +107,7 @@ const externalOnlyPresentation = summarizeEditorPresentation({ canvasDocumentType: "iframe", iframeCount: 1, stylesheetUrls: [`https://example.test/wp-content/themes/fixture/assets/a.css?ver=${externalIdentityA.toUpperCase()}`], + loadedStylesheetUrls: [`https://example.test/wp-content/themes/fixture/assets/a.css?ver=${externalIdentityA.toUpperCase()}`], inlineStyleContents: [], }, [externalIdentityA]) assert.deepEqual(externalOnlyPresentation.generatedPresentationIdentities, [externalIdentityA], "expected external stylesheet version certifies its identity") @@ -117,6 +119,7 @@ assert.deepEqual(summarizeEditorPresentation({ canvasDocumentType: "iframe", iframeCount: 1, stylesheetUrls: [`https://example.test/a.css?ver=${externalIdentityA}`, `https://example.test/b.css?ver=${externalIdentityB}`], + loadedStylesheetUrls: [`https://example.test/a.css?ver=${externalIdentityA}`, `https://example.test/b.css?ver=${externalIdentityB}`], inlineStyleContents: [`:root{--blocks-engine-presentation:${inlineIdentity};}`], }, [externalIdentityA, externalIdentityB]).generatedPresentationIdentities, [externalIdentityA, externalIdentityB, inlineIdentity].sort(), "inline and external identities combine") @@ -130,23 +133,30 @@ assert.deepEqual(summarizeEditorPresentation({ "https://example.test/b.css?ver=6.7.1", "https://example.test/c.css", ], + loadedStylesheetUrls: [ + `https://example.test/a.css?ver=${unrequestedIdentity}`, + "https://example.test/b.css?ver=6.7.1", + "https://example.test/c.css", + ], inlineStyleContents: [], }, [externalIdentityA]).generatedPresentationIdentities, [], "unrequested and non-hash versions are not identities") assert.deepEqual(summarizeEditorPresentation({ canvasDocumentType: "iframe", iframeCount: 1, stylesheetUrls: [`https://example.test/a.css?ver=${externalIdentityA}`], + loadedStylesheetUrls: [`https://example.test/a.css?ver=${externalIdentityA}`], inlineStyleContents: [], }).generatedPresentationIdentities, [], "no expected set certifies no external identity") -// A delayed stylesheet that has not yet appeared leaves the expected identity -// unobserved rather than reporting it as satisfied. +// A failed stylesheet stays visible in the evidence but cannot certify an +// expected identity. assert.deepEqual(summarizeEditorPresentation({ canvasDocumentType: "iframe", iframeCount: 1, stylesheetUrls: [`https://example.test/a.css?ver=${externalIdentityA}`], + loadedStylesheetUrls: [], inlineStyleContents: [], -}, [externalIdentityA, externalIdentityB]).generatedPresentationIdentities, [externalIdentityA], "a not-yet-loaded stylesheet stays unobserved") +}, [externalIdentityA, externalIdentityB]).generatedPresentationIdentities, [], "a failed stylesheet stays unobserved") const idleCanvas = await captureEditorIdleCanvas({ evaluate: async (callback: () => unknown) => { diff --git a/tests/editor-presentation-contract.test.ts b/tests/editor-presentation-contract.test.ts index aea1cb34..81ed6802 100644 --- a/tests/editor-presentation-contract.test.ts +++ b/tests/editor-presentation-contract.test.ts @@ -7,9 +7,8 @@ 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 }> { +async function captureContract(inlineCss: string[], resolvedStylesheetUrls: string[]): Promise<{ identities: string[]; complete: boolean }> { const root = await mkdtemp(join(tmpdir(), "wp-codebox-editor-presentation-")) const includes = join(root, "wp-admin", "includes") await mkdir(includes, { recursive: true }) @@ -20,33 +19,13 @@ define( 'ABSPATH', ${JSON.stringify(`${root}/`)} ); 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 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 ); + $fixture_resolved_stylesheet_urls = ${JSON.stringify(resolvedStylesheetUrls)}; + function get_post( $post_id ) { return new WP_Post(); } + function wp_parse_url( $url, $component = -1 ) { return parse_url( $url, $component ); } + function get_block_editor_settings( $settings, $context ) { + $links = implode( '', array_map( static fn ( $url ) => '', $GLOBALS['fixture_resolved_stylesheet_urls'] ) ); + return array( 'styles' => array_map( static fn ( $css ) => array( 'css' => $css ), $GLOBALS['fixture_inline_css'] ), '__unstableResolvedAssets' => array( 'styles' => $links ) ); } -} -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)} ` @@ -63,7 +42,7 @@ assert.deepEqual(await captureContract([`:root{--blocks-engine-presentation:${in complete: true, }, "inline-only delivery remains in the expected contract") -assert.deepEqual(await captureContract([], [externalIdentity.toUpperCase()]), { +assert.deepEqual(await captureContract([], [`https://example.test/editor.css?ver=${externalIdentity.toUpperCase()}`]), { identities: [externalIdentity], complete: true, }, "external-only editor delivery contributes its canonical version identity") @@ -71,12 +50,12 @@ assert.deepEqual(await captureContract([], [externalIdentity.toUpperCase()]), { assert.deepEqual(await captureContract([ `/* --blocks-engine-presentation:${inlineIdentity} */`, `/* --blocks-engine-presentation:${externalIdentity} */`, -], [externalIdentity.toUpperCase()]), { +], [`https://example.test/editor.css?ver=${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]), { +assert.deepEqual(await captureContract([], ["https://example.test/a.css?ver=6.7.1", "https://example.test/b.css?ver=not-a-hash", `https://example.test/c.css?ver=%20${externalIdentity}`, `https://example.test/d.css?ver=${externalIdentity}0`]), { identities: [], complete: true, }, "non-hash versions and unrelated prequeued stylesheets are ignored")