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")