diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index abe80bbb..438d529c 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -30,6 +30,7 @@ const EDITOR_PRESENTATION_MIN_OBSERVATION_MS = 4_000 const EDITOR_PRESENTATION_POLL_MS = 50 const EDITOR_PRESENTATION_IFRAME_DISCOVERY_MS = 1_000 const EDITOR_PRESENTATION_MAX_CAPTURE_MS = 10_000 +const EDITOR_PRESENTATION_VERSION_PARAM = "ver" const EDITOR_PRESENTATION_CONTRACT_MARKER = "WP_CODEBOX_EDITOR_PRESENTATION_CONTRACT:" const EDITOR_VALIDITY_WARNING_SELECTORS = [ ".block-editor-warning", @@ -846,11 +847,32 @@ interface EditorPresentationCapture { inlineStyleContents: string[] } -export function summarizeEditorPresentation(capture: EditorPresentationCapture): BrowserEditorPresentationSummary { +// A generated stylesheet delivered as an external asset carries its content +// hash in the canonical cache-busting version parameter rather than in inline +// marker text. Read that version so bounded external delivery stays observable. +function externalStylesheetPresentationIdentity(url: string): string | undefined { + let version: string | null = null + try { + version = new URL(url, "https://wp-codebox.invalid").searchParams.get(EDITOR_PRESENTATION_VERSION_PARAM) + } catch { + return undefined + } + const identity = version?.trim().toLowerCase() + return identity && /^[a-f0-9]{64}$/.test(identity) ? identity : undefined +} + +export function summarizeEditorPresentation(capture: EditorPresentationCapture, expectedIdentities: readonly string[] = []): BrowserEditorPresentationSummary { const iframeStylesheetUrls = [...new Set(capture.stylesheetUrls.map((url) => url.trim()).filter(Boolean))].sort() - const generatedPresentationIdentities = [...new Set( - capture.inlineStyleContents.flatMap((content) => [...content.matchAll(/blocks-engine-presentation:([a-f0-9]{64})/gi)].map((match) => match[1]!.toLowerCase())), - )].sort() + 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) => { + const identity = externalStylesheetPresentationIdentity(url) + return identity && expected.has(identity) ? [identity] : [] + }) + const generatedPresentationIdentities = [...new Set([...inlineIdentities, ...externalIdentities])].sort() return { schema: "wp-codebox/editor-presentation/v1", canvasDocumentType: capture.canvasDocumentType, @@ -901,7 +923,7 @@ export async function captureEditorPresentation(page: import("playwright").Page, } }, { canvasDocumentType, iframeCount: canvasDocumentType === "iframe" ? 1 : 0 }).catch(() => null) as (EditorPresentationCapture & { documentIdentity: string; documentAgeMs: number }) | null if (capture) { - const summary = summarizeEditorPresentation(capture) + const summary = summarizeEditorPresentation(capture, expectedIdentities) const fingerprint = `${capture.documentIdentity}\n${JSON.stringify(summary)}` const observedAtMs = Date.now() const expectedIdentitiesObserved = expectedIdentities.length > 0 diff --git a/tests/editor-actions.test.ts b/tests/editor-actions.test.ts index 98054d79..19295c55 100644 --- a/tests/editor-actions.test.ts +++ b/tests/editor-actions.test.ts @@ -96,6 +96,58 @@ assert.deepEqual(summarizeEditorPresentation({ canvasDocumentType: "parent", ifr generatedPresentationIdentities: [], }) +// Bounded external delivery: a generated stylesheet enqueued as a real asset +// carries its content hash in the version parameter instead of inline marker +// text. Automattic/blocks-engine#1478 delivers presentation styles this way. +const externalIdentityA = "1".repeat(64) +const externalIdentityB = "2".repeat(64) +const unrequestedIdentity = "3".repeat(64) +const externalOnlyPresentation = summarizeEditorPresentation({ + canvasDocumentType: "iframe", + iframeCount: 1, + stylesheetUrls: [`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") +assert.equal(externalOnlyPresentation.generatedPresentationIdentityCount, 1) + +// Mixed inline and external delivery contributes both identities exactly once. +const inlineIdentity = "4".repeat(64) +assert.deepEqual(summarizeEditorPresentation({ + canvasDocumentType: "iframe", + iframeCount: 1, + stylesheetUrls: [`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") + +// Fail closed: an unrequested hash, a non-hash version, and a missing expected +// set never manufacture an observed identity from a URL alone. +assert.deepEqual(summarizeEditorPresentation({ + canvasDocumentType: "iframe", + iframeCount: 1, + stylesheetUrls: [ + `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}`], + 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. +assert.deepEqual(summarizeEditorPresentation({ + canvasDocumentType: "iframe", + iframeCount: 1, + stylesheetUrls: [`https://example.test/a.css?ver=${externalIdentityA}`], + inlineStyleContents: [], +}, [externalIdentityA, externalIdentityB]).generatedPresentationIdentities, [externalIdentityA], "a not-yet-loaded stylesheet stays unobserved") + const idleCanvas = await captureEditorIdleCanvas({ evaluate: async (callback: () => unknown) => { const globals = globalThis as typeof globalThis & { document?: unknown; getComputedStyle?: unknown }