From 243125e456a9539332a8d2f9145e1ac81f60e77e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 16:17:23 -0400 Subject: [PATCH 01/16] feat(editor): capture matched presentation evidence --- packages/runtime-core/src/command-registry.ts | 6 +- .../src/browser-artifacts.ts | 23 +++ .../src/editor-command-runners.ts | 158 +++++++++++++++++- tests/browser-routed-command-security.test.ts | 28 ++++ tests/editor-actions.test.ts | 19 ++- 5 files changed, 228 insertions(+), 6 deletions(-) diff --git a/packages/runtime-core/src/command-registry.ts b/packages/runtime-core/src/command-registry.ts index f83944dd..f23858b8 100644 --- a/packages/runtime-core/src/command-registry.ts +++ b/packages/runtime-core/src/command-registry.ts @@ -1301,10 +1301,14 @@ export const commandRegistry = [ { name: "url", description: "Explicit editor path or absolute URL to open instead of resolving a target.", format: "path or URL" }, { name: "wait-selector", description: "Optional visible selector assertion evaluated after semantic editor readiness.", format: "CSS selector" }, { name: "wait-timeout", description: "Timeout for navigation and editor-ready waits.", format: "duration, e.g. 15s or 500ms" }, + { name: "presentation-url", description: "Optional frontend URL corresponding to the edited content. Enables bounded frontend-to-editor presentation evidence.", format: "path or URL" }, + { name: "presentation-frontend-selector", description: "Optional frontend content selector for presentation evidence; defaults to body and is bounded to 512 characters.", format: "CSS selector" }, + { name: "presentation-editor-selector", description: "Optional editor canvas selector for presentation evidence; defaults to the block list layout and is bounded to 512 characters.", format: "CSS selector" }, + { name: "presentation-threshold", description: "Maximum geometry delta ratio accepted by presentation evidence; defaults to 0.02.", format: "number between 0 and 1" }, { name: "capture", description: "Comma-separated artifacts to capture after opening the editor.", format: "steps,console,errors,html,screenshot,editor-state,editor-validity" }, { name: "artifact-prefix", description: "Optional artifact directory relative to the runtime artifact root for this invocation; defaults to files/browser. Use files/browser/editor-open/ to isolate per-fixture editor-open evidence in a batch.", format: "relative artifact directory" }, ], - outputShape: "JSON summary with additive editorPresentation iframe stylesheet URLs and generated-presentation identities, plus files/browser/editor-steps.jsonl, editor-summary.json, editor-state.json, optional editor-validity.json, and optional console/errors/html/screenshot artifacts. When artifact-prefix is supplied, every editor-open artifact is written under that directory instead of files/browser.", + outputShape: "JSON summary with additive editorPresentation identities, expected settings identities, idle-canvas evidence, and optional frontend/editor/diff presentation-match screenshots when presentation-url is supplied, plus files/browser/editor-steps.jsonl, editor-summary.json, editor-state.json, optional editor-validity.json, and optional console/errors/html/screenshot artifacts. When artifact-prefix is supplied, every editor-open artifact is written under that directory instead of files/browser.", policyRequirement: "Runtime policy commands must include wordpress.editor-open.", recipe: true, handler: { kind: "playground", method: "runEditorOpen" }, diff --git a/packages/runtime-playground/src/browser-artifacts.ts b/packages/runtime-playground/src/browser-artifacts.ts index 481ac85e..950b52ca 100644 --- a/packages/runtime-playground/src/browser-artifacts.ts +++ b/packages/runtime-playground/src/browser-artifacts.ts @@ -290,6 +290,25 @@ export interface BrowserEditorReadinessSummary { postType?: string } +export interface BrowserEditorIdleCanvasSummary { + schema: "wp-codebox/editor-idle-canvas/v1" + status: "captured" | "unavailable" + onboardingModalCount?: number +} + +export interface BrowserEditorPresentationMatchSummary { + schema: "wp-codebox/editor-presentation-match/v1" + status: "passed" | "failed" | "unavailable" + equivalentCanvasWidths?: boolean + majorGeometryDrift?: boolean + unreadableContent?: boolean + hiddenContent?: boolean + unresolvedAssetCount?: number + frontendScreenshot?: string + editorScreenshot?: string + diffScreenshot?: string +} + export interface BrowserEditorPresentationSummary { schema: "wp-codebox/editor-presentation/v1" canvasDocumentType: "iframe" | "parent" @@ -298,6 +317,10 @@ export interface BrowserEditorPresentationSummary { iframeStylesheetUrls: string[] generatedPresentationIdentityCount: number generatedPresentationIdentities: string[] + expectedGeneratedPresentationIdentities?: string[] + expectedGeneratedPresentationIdentitiesComplete?: boolean + idleCanvas?: BrowserEditorIdleCanvasSummary + matchedRendering?: BrowserEditorPresentationMatchSummary } export interface BrowserEditorSaveSummary { diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index e614e8e7..e2a7dc13 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -3,18 +3,21 @@ import { now, sha256 } from "@automattic/wp-codebox-core/internals" import { durationStringMs } from "./browser-actions.js" import { BrowserArtifactSession } from "./browser-artifact-session.js" import { BrowserCommandArtifactError } from "./browser-command-artifact-error.js" -import type { BrowserArtifact, BrowserArtifactFiles, BrowserArtifactSummary, BrowserEditorCanvasProbeDiagnostic, BrowserEditorCanvasProbeSummary, BrowserEditorCanvasSelectorGroupSummary, BrowserEditorCanvasSelectorSummary, BrowserEditorPresentationSummary, BrowserEditorReadinessSummary, BrowserEditorSaveSummary, BrowserEditorValidateBlocksSummary, BrowserEditorValiditySummary, BrowserProbeAuthSummary, BrowserProbeErrorRecord, BrowserProbeViewport, BrowserStepRecord } from "./browser-artifacts.js" +import type { BrowserArtifact, BrowserArtifactFiles, BrowserArtifactSummary, BrowserEditorCanvasProbeDiagnostic, BrowserEditorCanvasProbeSummary, BrowserEditorCanvasSelectorGroupSummary, BrowserEditorCanvasSelectorSummary, BrowserEditorIdleCanvasSummary, BrowserEditorPresentationSummary, BrowserEditorReadinessSummary, BrowserEditorSaveSummary, BrowserEditorValidateBlocksSummary, BrowserEditorValiditySummary, BrowserProbeAuthSummary, BrowserProbeErrorRecord, BrowserProbeViewport, BrowserStepRecord } from "./browser-artifacts.js" import { attachBrowserCaptureListeners, launchChromiumBrowser } from "./browser-capture-session.js" import { browserStepRecord } from "./browser-interactions.js" import { browserPreviewCleanupErrorIsFatal, browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, browserPreviewReadinessError, browserPreviewSecureContextError, browserPreviewTopology, closeBrowserAndDrainPreviewRoutes, createBrowserPreviewRouteTracker, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js" import { browserCommandResult } from "./browser-result-sanitization.js" import { browserProbeReplayability, browserProbeViewport } from "./browser-probe.js" import { argValue, commaListArg, durationArg, jsonArrayArg } from "./commands.js" -import { DEFAULT_EDITOR_WAIT_SELECTOR, editorActionStepsFromArgs, editorOpenTargetFromArgs, editorValidateContentFromArgs, editorValidateProviderFromArgs, resolveEditorOpenTarget, type EditorActionStep, type EditorBlockSpec, type EditorBlockTarget } from "./editor-actions.js" -import type { PlaygroundRunResponse } from "./playground-command-errors.js" +import { DEFAULT_EDITOR_WAIT_SELECTOR, editorActionStepsFromArgs, editorOpenTargetFromArgs, editorValidateContentFromArgs, editorValidateProviderFromArgs, resolveEditorOpenTarget, type EditorActionStep, type EditorBlockSpec, type EditorBlockTarget, type EditorOpenTarget } from "./editor-actions.js" +import { assertPlaygroundResponseOk, type PlaygroundRunResponse } from "./playground-command-errors.js" import type { PlaygroundCliServer } from "./preview-server.js" import { serializeBrowserError } from "./browser-metrics.js" import { fileSha256, installWordPressAdminAuthCookies } from "./browser-probe-support.js" +import { bootstrapPhpCode } from "./php-bootstrap.js" +import { cleanWpCliOutput } from "./wp-cli-command-handlers.js" +import { comparePngFiles } from "./browser-visual-compare.js" const BROWSER_STEP_DEFAULT_TIMEOUT_MS = 15_000 const BROWSER_SCRIPT_DEFAULT_TIMEOUT_MS = 120_000 @@ -644,7 +647,21 @@ export async function runEditorOpenCommand({ } if (editorReadiness) { + await dismissWordPressOnboardingDialogs(page) editorPresentation = await captureEditorPresentation(page, waitTimeoutMs) + if (editorPresentation) { + const expected = await captureExpectedEditorPresentationIdentities(target, runPlaygroundCommand, runtimeSpec, server) + const idleCanvas = await captureEditorIdleCanvas(page) + editorPresentation = { + ...editorPresentation, + ...(expected ? { + expectedGeneratedPresentationIdentities: expected.identities, + expectedGeneratedPresentationIdentitiesComplete: expected.complete, + } : {}), + idleCanvas, + matchedRendering: await captureEditorPresentationMatch({ args, artifactSession, page, topology, waitTimeoutMs }), + } + } } if (capture.has("editor-state")) { @@ -701,7 +718,12 @@ export async function runEditorOpenCommand({ ...(server.previewProxyDiagnostics ? { previewProxy: server.previewProxyDiagnostics } : {}), ...(browserPreviewNetworkPolicyIsActive(networkPolicy) ? { networkPolicy: browserPreviewNetworkPolicySummary(networkPolicy) } : {}), ...topology.origins, - files: editorOpenArtifactFilesForCapture(capture, artifactPathPrefix), + files: { + ...editorOpenArtifactFilesForCapture(capture, artifactPathPrefix), + ...(editorPresentation?.matchedRendering?.frontendScreenshot && editorPresentation.matchedRendering.editorScreenshot && editorPresentation.matchedRendering.diffScreenshot + ? { screenshots: [editorPresentation.matchedRendering.frontendScreenshot, editorPresentation.matchedRendering.editorScreenshot, editorPresentation.matchedRendering.diffScreenshot] } + : {}), + }, summary: { steps: stepRecords.length, consoleMessages: consoleMessages.length, @@ -905,6 +927,134 @@ export async function captureEditorPresentation(page: import("playwright").Page, return undefined } +// Ask WordPress for the same filtered editor settings used to construct the +// canvas. This is independent of the rendered iframe, so a missing style is +// observable rather than certified from the observed set itself. +async function captureExpectedEditorPresentationIdentities( + target: EditorOpenTarget, + runPlaygroundCommand: (command: string, server: PlaygroundCliServer, options: { code: string } | { scriptPath: string }) => Promise, + runtimeSpec: RuntimeCreateSpec, + server: PlaygroundCliServer, +): 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}); +if ( ! $post instanceof WP_Post ) { WP_CLI::error( 'Editor target post is unavailable.' ); } +$settings = get_block_editor_settings( array(), new WP_Block_Editor_Context( array( 'post' => $post ) ) ); +$identities = array(); +foreach ( (array) ( $settings['styles'] ?? array() ) as $style ) { + if ( ! is_array( $style ) || ! is_string( $style['css'] ?? null ) ) { continue; } + if ( preg_match_all( '/--blocks-engine-presentation:([a-f0-9]{64})/i', $style['css'], $matches ) ) { + foreach ( $matches[1] as $identity ) { $identities[] = strtolower( $identity ); } + } +} +$identities = array_values( array_unique( $identities ) ); +sort( $identities, SORT_STRING ); +WP_CLI::line( wp_json_encode( array( 'identities' => $identities, 'complete' => true ) ) ); +`, []), + }) + assertPlaygroundResponseOk("wordpress.editor-open.capture-presentation-contract", response) + const value = JSON.parse(cleanWpCliOutput(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 async function captureEditorIdleCanvas(page: import("playwright").Page): Promise { + const onboardingModalCount = await page.evaluate(() => { + const selectors = [".components-guide", ".welcome-panel", ".components-modal__frame"] + return selectors.reduce((count, selector) => count + Array.from(document.querySelectorAll(selector)).filter((element) => { + const style = getComputedStyle(element) + return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity || "1") > 0 + }).length, 0) + }).catch(() => undefined) + return onboardingModalCount === undefined + ? { schema: "wp-codebox/editor-idle-canvas/v1", status: "unavailable" } + : { schema: "wp-codebox/editor-idle-canvas/v1", status: "captured", onboardingModalCount } +} + +async function captureEditorPresentationMatch(input: { + args: string[] + artifactSession: BrowserArtifactSession + page: import("playwright").Page + topology: ReturnType + waitTimeoutMs: number +}): Promise> { + const presentationUrl = argValue(input.args, "presentation-url")?.trim() + if (!presentationUrl) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable" } + const frontendSelector = boundedPresentationSelector(input.args, "presentation-frontend-selector", "body") + const editorSelector = boundedPresentationSelector(input.args, "presentation-editor-selector", EDITOR_CANVAS_DEFAULT_LAYOUT_SELECTOR) + const threshold = presentationThreshold(input.args) + const frontendPath = input.artifactSession.absolutePath("presentation-frontend.png") + const editorPath = input.artifactSession.absolutePath("presentation-editor.png") + const frontendRef = input.artifactSession.path("presentation-frontend.png") + const editorRef = input.artifactSession.path("presentation-editor.png") + const diffRef = input.artifactSession.path("presentation-diff.png") + try { + const frame = await resolveEditorCanvasFrame(input.page, EDITOR_CANVAS_DEFAULT_IFRAME_SELECTOR) + const canvas = frame ?? input.page + const editor = canvas.locator(editorSelector).first() + const editorBox = await editor.boundingBox() + if (!editorBox || editorBox.width <= 0 || editorBox.height <= 0) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable" } + const frontend = await input.page.context().newPage() + try { + await frontend.setViewportSize({ width: Math.round(editorBox.width), height: Math.max(1, Math.round(editorBox.height)) }) + await frontend.goto(input.topology.resolveUrl(presentationUrl), { waitUntil: "networkidle", timeout: input.waitTimeoutMs }) + const source = frontend.locator(frontendSelector).first() + if (!await source.isVisible()) return { schema: "wp-codebox/editor-presentation-match/v1", status: "failed" } + const [frontendEvidence, editorEvidence] = await Promise.all([presentationSurfaceEvidence(frontend, frontendSelector), presentationSurfaceEvidence(canvas, editorSelector)]) + await input.artifactSession.writeGenerated("screenshot", "presentation-frontend.png", async (path) => { await source.screenshot({ path, timeout: input.waitTimeoutMs }); }) + await input.artifactSession.writeGenerated("screenshot", "presentation-editor.png", async (path) => { await editor.screenshot({ path, timeout: input.waitTimeoutMs }); }) + let comparison: Awaited> | undefined + await input.artifactSession.writeGenerated("screenshot", "presentation-diff.png", async (path) => { + comparison = await comparePngFiles(frontendPath, editorPath, path, { threshold, includeAA: false, maxRegions: 8 }) + }) + if (!comparison) throw new Error("Presentation comparison did not produce metrics") + const equivalentCanvasWidths = comparison.source.width === comparison.candidate.width + // The same bounded threshold applies to canvas extent and the shared + // visual region; equal widths alone must not certify different renders. + const majorGeometryDrift = comparison.dimensionDeltaRatio > threshold || comparison.overlapMismatchRatio > threshold + const unreadableContent = !frontendEvidence.readable || !editorEvidence.readable + const hiddenContent = !frontendEvidence.visible || !editorEvidence.visible + const unresolvedAssetCount = frontendEvidence.unresolvedAssets + editorEvidence.unresolvedAssets + const passed = equivalentCanvasWidths && !majorGeometryDrift && !unreadableContent && !hiddenContent && unresolvedAssetCount === 0 + return { schema: "wp-codebox/editor-presentation-match/v1", status: passed ? "passed" : "failed", equivalentCanvasWidths, majorGeometryDrift, unreadableContent, hiddenContent, unresolvedAssetCount, frontendScreenshot: frontendRef, editorScreenshot: editorRef, diffScreenshot: diffRef } + } finally { await frontend.close() } + } catch { + return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable" } + } +} + +function boundedPresentationSelector(args: string[], name: string, fallback: string): string { + const selector = argValue(args, name)?.trim() || fallback + if (selector.length > 512) throw new Error(`wordpress.editor-open ${name} must be at most 512 characters`) + return selector +} + +function presentationThreshold(args: string[]): number { + const raw = argValue(args, "presentation-threshold")?.trim() + if (!raw) return 0.02 + const value = Number(raw) + if (!Number.isFinite(value) || value < 0 || value > 1) throw new Error(`wordpress.editor-open presentation-threshold must be between 0 and 1: ${raw}`) + return value +} + +async function presentationSurfaceEvidence(surface: import("playwright").Page | import("playwright").Frame, selector: string): Promise<{ visible: boolean; readable: boolean; unresolvedAssets: number }> { + return surface.evaluate((selector) => { + const element = document.querySelector(selector) + if (!element) return { visible: false, readable: false, unresolvedAssets: 0 } + const style = getComputedStyle(element) + const rect = element.getBoundingClientRect() + const visible = style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity || "1") > 0 && rect.width > 0 && rect.height > 0 + const readable = visible && (element.innerText || "").trim().length > 0 + const unresolvedAssets = Array.from(element.querySelectorAll("img")).filter((image) => image.src && (!image.complete || image.naturalWidth === 0)).length + + Array.from(document.querySelectorAll('link[rel~="stylesheet"]')).filter((link) => !link.disabled && !link.sheet).length + return { visible, readable, unresolvedAssets } + }, selector) +} + export async function dismissWordPressOnboardingDialogs(page: import("playwright").Page): Promise { await page.evaluate(() => { const selectors = [ diff --git a/tests/browser-routed-command-security.test.ts b/tests/browser-routed-command-security.test.ts index 1ed53c55..d0b829b2 100644 --- a/tests/browser-routed-command-security.test.ts +++ b/tests/browser-routed-command-security.test.ts @@ -49,6 +49,8 @@ test("real browser commands sanitize console, artifacts, stdout, and failure std response.setHeader("content-type", "text/html") response.end(request.url?.startsWith("/broken") ? "
Broken editor fixture
" + : request.url?.startsWith("/presentation") + ? "
Deliberately different frontend fixture
" : request.url?.startsWith("/parent-canvas") ? parentCanvasEditorHtml : request.url?.startsWith("/replacing-canvas") @@ -105,9 +107,35 @@ test("real browser commands sanitize console, artifacts, stdout, and failure std iframeStylesheetUrls: [], generatedPresentationIdentityCount: 1, generatedPresentationIdentities: [CANVAS_PRESENTATION_IDENTITY], + idleCanvas: { schema: "wp-codebox/editor-idle-canvas/v1", status: "captured", onboardingModalCount: 0 }, + matchedRendering: { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable" }, }) }) + await withTempDir("wp-codebox-editor-presentation-match-", async (artifactRoot) => { + const result = await runEditorOpenCommand({ + artifactRoot, + runPlaygroundCommand, + runtimeSpec, + server, + spec: { command: "wordpress.editor-open", args: [`url=${PUBLIC_URL}`, "presentation-url=/presentation", "route-host=routed.test", "capture=steps", "wait-timeout=5s"] }, + }) + const output = JSON.parse(result.output) as { summary: { editorPresentation: { matchedRendering: { status: string; frontendScreenshot: string; editorScreenshot: string; diffScreenshot: string; equivalentCanvasWidths: boolean; majorGeometryDrift: boolean; unreadableContent: boolean; hiddenContent: boolean; unresolvedAssetCount: number } } } } + assert.deepEqual(output.summary.editorPresentation.matchedRendering, { + schema: "wp-codebox/editor-presentation-match/v1", + status: "failed", + equivalentCanvasWidths: false, + majorGeometryDrift: true, + unreadableContent: false, + hiddenContent: false, + unresolvedAssetCount: 0, + frontendScreenshot: "files/browser/presentation-frontend.png", + editorScreenshot: "files/browser/presentation-editor.png", + diffScreenshot: "files/browser/presentation-diff.png", + }) + await assertCommandSurfacesSafe(result, artifactRoot, ["files/browser/presentation-frontend.png", "files/browser/presentation-editor.png", "files/browser/presentation-diff.png"]) + }) + await withTempDir("wp-codebox-real-editor-parent-canvas-security-", async (artifactRoot) => { const result = await runEditorOpenCommand({ artifactRoot, diff --git a/tests/editor-actions.test.ts b/tests/editor-actions.test.ts index 446c038d..ce6f49c4 100644 --- a/tests/editor-actions.test.ts +++ b/tests/editor-actions.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict" -import { assertEditorMutationPostcondition, captureEditorState, captureEditorValidity, editorCommandWordPressUrl, editorOpenArtifactError, editorOpenArtifactFilesForCapture, editorOpenArtifactPathPrefixFromArgs, executeEditorActionStep, summarizeEditorPresentation, type EditorStateSnapshot, waitForEditorOpenReadiness } from "../packages/runtime-playground/src/editor-command-runners.js" +import { assertEditorMutationPostcondition, captureEditorIdleCanvas, captureEditorState, captureEditorValidity, editorCommandWordPressUrl, editorOpenArtifactError, editorOpenArtifactFilesForCapture, editorOpenArtifactPathPrefixFromArgs, executeEditorActionStep, summarizeEditorPresentation, type EditorStateSnapshot, waitForEditorOpenReadiness } from "../packages/runtime-playground/src/editor-command-runners.js" import { isBrowserCommandArtifactError } from "../packages/runtime-playground/src/browser-command-artifact-error.js" import { editorActionStepsFromArgs, editorOpenTargetFromArgs, resolveEditorOpenTarget } from "../packages/runtime-playground/src/editor-actions.js" @@ -96,6 +96,23 @@ assert.deepEqual(summarizeEditorPresentation({ canvasDocumentType: "parent", ifr generatedPresentationIdentities: [], }) +const idleCanvas = await captureEditorIdleCanvas({ + evaluate: async (callback: () => unknown) => { + const globals = globalThis as typeof globalThis & { document?: unknown; getComputedStyle?: unknown } + const document = globals.document + const getComputedStyle = globals.getComputedStyle + globals.document = { querySelectorAll: (selector: string) => selector === ".components-guide" ? [{ id: "guide" }] : [] } + globals.getComputedStyle = () => ({ display: "block", visibility: "visible", opacity: "1" }) + try { return callback() } finally { globals.document = document; globals.getComputedStyle = getComputedStyle } + }, +} as never) +assert.deepEqual(idleCanvas, { schema: "wp-codebox/editor-idle-canvas/v1", status: "captured", onboardingModalCount: 1 }) + +const unavailableIdleCanvas = await captureEditorIdleCanvas({ + evaluate: async () => { throw new Error("page detached") }, +} as never) +assert.deepEqual(unavailableIdleCanvas, { schema: "wp-codebox/editor-idle-canvas/v1", status: "unavailable" }) + // Runner mutations use only the generic data/block APIs, resolve nested paths, // and fail closed when the required store action is unavailable. const runnerCalls: Array<{ action: string; args: unknown[] }> = [] From 4195c4bb454504a8c93f089350e46ce6d41fb66d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 16:24:33 -0400 Subject: [PATCH 02/16] fix(editor): run presentation probe without WP-CLI --- packages/runtime-playground/src/editor-command-runners.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index e2a7dc13..8b8cfc3c 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -940,7 +940,7 @@ async function captureExpectedEditorPresentationIdentities( const response = await runPlaygroundCommand("wordpress.editor-open.capture-presentation-contract", server, { code: bootstrapPhpCode(runtimeSpec, ` $post = get_post(${target.postId}); -if ( ! $post instanceof WP_Post ) { WP_CLI::error( 'Editor target post is unavailable.' ); } +if ( ! $post instanceof WP_Post ) { throw new RuntimeException( 'Editor target post is unavailable.' ); } $settings = get_block_editor_settings( array(), new WP_Block_Editor_Context( array( 'post' => $post ) ) ); $identities = array(); foreach ( (array) ( $settings['styles'] ?? array() ) as $style ) { @@ -951,7 +951,7 @@ foreach ( (array) ( $settings['styles'] ?? array() ) as $style ) { } $identities = array_values( array_unique( $identities ) ); sort( $identities, SORT_STRING ); -WP_CLI::line( wp_json_encode( array( 'identities' => $identities, 'complete' => true ) ) ); +echo wp_json_encode( array( 'identities' => $identities, 'complete' => true ) ); `, []), }) assertPlaygroundResponseOk("wordpress.editor-open.capture-presentation-contract", response) From 6bb5cd395f55df52af806a0bb8f7dd276daa906b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 16:35:21 -0400 Subject: [PATCH 03/16] fix(editor): retain actionable presentation evidence --- .../src/browser-artifacts.ts | 1 + .../src/editor-command-runners.ts | 18 +++++++++++------- tests/browser-routed-command-security.test.ts | 2 +- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/runtime-playground/src/browser-artifacts.ts b/packages/runtime-playground/src/browser-artifacts.ts index 950b52ca..c6013794 100644 --- a/packages/runtime-playground/src/browser-artifacts.ts +++ b/packages/runtime-playground/src/browser-artifacts.ts @@ -299,6 +299,7 @@ export interface BrowserEditorIdleCanvasSummary { export interface BrowserEditorPresentationMatchSummary { schema: "wp-codebox/editor-presentation-match/v1" status: "passed" | "failed" | "unavailable" + diagnostic?: string equivalentCanvasWidths?: boolean majorGeometryDrift?: boolean unreadableContent?: boolean diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 8b8cfc3c..7dee1bdb 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -965,10 +965,11 @@ echo wp_json_encode( array( 'identities' => $identities, 'complete' => true ) ); export async function captureEditorIdleCanvas(page: import("playwright").Page): Promise { const onboardingModalCount = await page.evaluate(() => { const selectors = [".components-guide", ".welcome-panel", ".components-modal__frame"] - return selectors.reduce((count, selector) => count + Array.from(document.querySelectorAll(selector)).filter((element) => { + const elements = new Set(selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)))) + return Array.from(elements).filter((element) => { const style = getComputedStyle(element) return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity || "1") > 0 - }).length, 0) + }).length }).catch(() => undefined) return onboardingModalCount === undefined ? { schema: "wp-codebox/editor-idle-canvas/v1", status: "unavailable" } @@ -983,7 +984,7 @@ async function captureEditorPresentationMatch(input: { waitTimeoutMs: number }): Promise> { const presentationUrl = argValue(input.args, "presentation-url")?.trim() - if (!presentationUrl) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable" } + if (!presentationUrl) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: "presentation-url was not supplied" } const frontendSelector = boundedPresentationSelector(input.args, "presentation-frontend-selector", "body") const editorSelector = boundedPresentationSelector(input.args, "presentation-editor-selector", EDITOR_CANVAS_DEFAULT_LAYOUT_SELECTOR) const threshold = presentationThreshold(input.args) @@ -997,13 +998,13 @@ async function captureEditorPresentationMatch(input: { const canvas = frame ?? input.page const editor = canvas.locator(editorSelector).first() const editorBox = await editor.boundingBox() - if (!editorBox || editorBox.width <= 0 || editorBox.height <= 0) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable" } + if (!editorBox || editorBox.width <= 0 || editorBox.height <= 0) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: "editor presentation surface has no visible bounds" } const frontend = await input.page.context().newPage() try { await frontend.setViewportSize({ width: Math.round(editorBox.width), height: Math.max(1, Math.round(editorBox.height)) }) await frontend.goto(input.topology.resolveUrl(presentationUrl), { waitUntil: "networkidle", timeout: input.waitTimeoutMs }) const source = frontend.locator(frontendSelector).first() - if (!await source.isVisible()) return { schema: "wp-codebox/editor-presentation-match/v1", status: "failed" } + if (!await source.isVisible()) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: `frontend presentation selector is not visible: ${frontendSelector}` } const [frontendEvidence, editorEvidence] = await Promise.all([presentationSurfaceEvidence(frontend, frontendSelector), presentationSurfaceEvidence(canvas, editorSelector)]) await input.artifactSession.writeGenerated("screenshot", "presentation-frontend.png", async (path) => { await source.screenshot({ path, timeout: input.waitTimeoutMs }); }) await input.artifactSession.writeGenerated("screenshot", "presentation-editor.png", async (path) => { await editor.screenshot({ path, timeout: input.waitTimeoutMs }); }) @@ -1022,8 +1023,8 @@ async function captureEditorPresentationMatch(input: { const passed = equivalentCanvasWidths && !majorGeometryDrift && !unreadableContent && !hiddenContent && unresolvedAssetCount === 0 return { schema: "wp-codebox/editor-presentation-match/v1", status: passed ? "passed" : "failed", equivalentCanvasWidths, majorGeometryDrift, unreadableContent, hiddenContent, unresolvedAssetCount, frontendScreenshot: frontendRef, editorScreenshot: editorRef, diffScreenshot: diffRef } } finally { await frontend.close() } - } catch { - return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable" } + } catch (error) { + return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: error instanceof Error ? error.message : String(error) } } } @@ -1061,6 +1062,9 @@ export async function dismissWordPressOnboardingDialogs(page: import("playwright ".components-guide__finish-button", '.components-guide .components-button[aria-label="Close"]', '.components-guide .components-button[aria-label="Dismiss"]', + '.components-modal__header .components-button[aria-label="Close"]', + '.components-modal__header .components-button[aria-label="Dismiss"]', + '.components-modal__header button', ".welcome-panel-close", ] const dismissed = new Set() diff --git a/tests/browser-routed-command-security.test.ts b/tests/browser-routed-command-security.test.ts index d0b829b2..9152594a 100644 --- a/tests/browser-routed-command-security.test.ts +++ b/tests/browser-routed-command-security.test.ts @@ -108,7 +108,7 @@ test("real browser commands sanitize console, artifacts, stdout, and failure std generatedPresentationIdentityCount: 1, generatedPresentationIdentities: [CANVAS_PRESENTATION_IDENTITY], idleCanvas: { schema: "wp-codebox/editor-idle-canvas/v1", status: "captured", onboardingModalCount: 0 }, - matchedRendering: { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable" }, + matchedRendering: { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: "presentation-url was not supplied" }, }) }) From fea5b85c5d7fb55a1d071087a2fef0a766d59298 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 16:45:40 -0400 Subject: [PATCH 04/16] fix(editor): isolate frontend presentation capture --- .../src/editor-command-runners.ts | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 7dee1bdb..fba38198 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -999,9 +999,13 @@ async function captureEditorPresentationMatch(input: { const editor = canvas.locator(editorSelector).first() const editorBox = await editor.boundingBox() if (!editorBox || editorBox.width <= 0 || editorBox.height <= 0) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: "editor presentation surface has no visible bounds" } - const frontend = await input.page.context().newPage() + const browser = input.page.context().browser() + if (!browser) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: "browser context is not available for frontend capture" } + const frontendContext = await browser.newContext({ + viewport: { width: Math.round(editorBox.width), height: Math.max(1, Math.round(editorBox.height)) }, + }) + const frontend = await frontendContext.newPage() try { - await frontend.setViewportSize({ width: Math.round(editorBox.width), height: Math.max(1, Math.round(editorBox.height)) }) await frontend.goto(input.topology.resolveUrl(presentationUrl), { waitUntil: "networkidle", timeout: input.waitTimeoutMs }) const source = frontend.locator(frontendSelector).first() if (!await source.isVisible()) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: `frontend presentation selector is not visible: ${frontendSelector}` } @@ -1022,7 +1026,7 @@ async function captureEditorPresentationMatch(input: { const unresolvedAssetCount = frontendEvidence.unresolvedAssets + editorEvidence.unresolvedAssets const passed = equivalentCanvasWidths && !majorGeometryDrift && !unreadableContent && !hiddenContent && unresolvedAssetCount === 0 return { schema: "wp-codebox/editor-presentation-match/v1", status: passed ? "passed" : "failed", equivalentCanvasWidths, majorGeometryDrift, unreadableContent, hiddenContent, unresolvedAssetCount, frontendScreenshot: frontendRef, editorScreenshot: editorRef, diffScreenshot: diffRef } - } finally { await frontend.close() } + } finally { await frontendContext.close() } } catch (error) { return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: error instanceof Error ? error.message : String(error) } } @@ -1057,26 +1061,30 @@ async function presentationSurfaceEvidence(surface: import("playwright").Page | } export async function dismissWordPressOnboardingDialogs(page: import("playwright").Page): Promise { - await page.evaluate(() => { - const selectors = [ - ".components-guide__finish-button", - '.components-guide .components-button[aria-label="Close"]', - '.components-guide .components-button[aria-label="Dismiss"]', - '.components-modal__header .components-button[aria-label="Close"]', - '.components-modal__header .components-button[aria-label="Dismiss"]', - '.components-modal__header button', - ".welcome-panel-close", - ] - const dismissed = new Set() - for (const selector of selectors) { - for (const control of Array.from(document.querySelectorAll(selector))) { - if (!dismissed.has(control) && !control.hasAttribute("disabled")) { - dismissed.add(control) - control.click() + // Gutenberg can mount the guide shortly after the editor first reports ready. + for (let attempt = 0; attempt < 4; attempt += 1) { + await page.evaluate(() => { + const selectors = [ + ".components-guide__finish-button", + '.components-guide .components-button[aria-label="Close"]', + '.components-guide .components-button[aria-label="Dismiss"]', + '.components-modal__header .components-button[aria-label="Close"]', + '.components-modal__header .components-button[aria-label="Dismiss"]', + '.components-modal__header button', + ".welcome-panel-close", + ] + const dismissed = new Set() + for (const selector of selectors) { + for (const control of Array.from(document.querySelectorAll(selector))) { + if (!dismissed.has(control) && !control.hasAttribute("disabled")) { + dismissed.add(control) + control.click() + } } } - } - }) + }) + await page.waitForTimeout(150) + } } export function editorOpenArtifactError(stepCount: number, error: Error, artifact: BrowserArtifact): BrowserCommandArtifactError { From c75ed648018220702069a4aea079ebf423f107d8 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 16:53:46 -0400 Subject: [PATCH 05/16] fix(editor): route isolated presentation capture --- packages/runtime-playground/src/editor-command-runners.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index fba38198..d0ff997a 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -1002,6 +1002,7 @@ async function captureEditorPresentationMatch(input: { const browser = input.page.context().browser() if (!browser) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: "browser context is not available for frontend capture" } const frontendContext = await browser.newContext({ + ...input.topology.contextOptions(), viewport: { width: Math.round(editorBox.width), height: Math.max(1, Math.round(editorBox.height)) }, }) const frontend = await frontendContext.newPage() @@ -1066,6 +1067,8 @@ export async function dismissWordPressOnboardingDialogs(page: import("playwright await page.evaluate(() => { const selectors = [ ".components-guide__finish-button", + ".components-guide__close", + '.components-guide button[aria-label^="Close"]', '.components-guide .components-button[aria-label="Close"]', '.components-guide .components-button[aria-label="Dismiss"]', '.components-modal__header .components-button[aria-label="Close"]', From 17eb965469bd6b02c2245c12d9f79beedcf2c4e5 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 17:03:45 -0400 Subject: [PATCH 06/16] fix(editor): close active welcome guide --- .../runtime-playground/src/editor-command-runners.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index d0ff997a..349eae45 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -1065,6 +1065,15 @@ export async function dismissWordPressOnboardingDialogs(page: import("playwright // Gutenberg can mount the guide shortly after the editor first reports ready. for (let attempt = 0; attempt < 4; attempt += 1) { await page.evaluate(() => { + const wp = (globalThis as typeof globalThis & { + wp?: { data?: { + select?: (store: string) => { isFeatureActive?: (feature: string) => boolean } + dispatch?: (store: string) => { toggleFeature?: (feature: string) => void } + } } + }).wp + if (wp?.data?.select?.("core/edit-post")?.isFeatureActive?.("welcomeGuide")) { + wp.data.dispatch?.("core/edit-post")?.toggleFeature?.("welcomeGuide") + } const selectors = [ ".components-guide__finish-button", ".components-guide__close", From 395dd02dd87d014ce0256ffcc71166ef03e58149 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 17:18:39 -0400 Subject: [PATCH 07/16] fix(editor): capture full presentation canvas --- .../src/editor-command-runners.ts | 30 +++++++++++++++-- tests/browser-routed-command-security.test.ts | 33 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 349eae45..12543776 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -1006,7 +1006,9 @@ async function captureEditorPresentationMatch(input: { viewport: { width: Math.round(editorBox.width), height: Math.max(1, Math.round(editorBox.height)) }, }) const frontend = await frontendContext.newPage() + let restoreEditorCanvasViewport = async () => {} try { + if (frame) restoreEditorCanvasViewport = await expandEditorCanvasViewport(frame, editorBox.height) await frontend.goto(input.topology.resolveUrl(presentationUrl), { waitUntil: "networkidle", timeout: input.waitTimeoutMs }) const source = frontend.locator(frontendSelector).first() if (!await source.isVisible()) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: `frontend presentation selector is not visible: ${frontendSelector}` } @@ -1027,12 +1029,33 @@ async function captureEditorPresentationMatch(input: { const unresolvedAssetCount = frontendEvidence.unresolvedAssets + editorEvidence.unresolvedAssets const passed = equivalentCanvasWidths && !majorGeometryDrift && !unreadableContent && !hiddenContent && unresolvedAssetCount === 0 return { schema: "wp-codebox/editor-presentation-match/v1", status: passed ? "passed" : "failed", equivalentCanvasWidths, majorGeometryDrift, unreadableContent, hiddenContent, unresolvedAssetCount, frontendScreenshot: frontendRef, editorScreenshot: editorRef, diffScreenshot: diffRef } - } finally { await frontendContext.close() } + } finally { + await restoreEditorCanvasViewport() + await frontendContext.close() + } } catch (error) { return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: error instanceof Error ? error.message : String(error) } } } +async function expandEditorCanvasViewport(frame: import("playwright").Frame, contentHeight: number): Promise<() => Promise> { + const frameElement = await frame.frameElement() + const previousStyle = await frameElement.evaluate((element) => (element as HTMLElement).getAttribute("style")) + await frameElement.evaluate((element, height) => { + const iframe = element as HTMLElement + iframe.style.setProperty("height", `${Math.ceil(height)}px`, "important") + iframe.style.setProperty("min-height", `${Math.ceil(height)}px`, "important") + iframe.style.setProperty("max-height", "none", "important") + }, contentHeight) + return async () => { + await frameElement.evaluate((element, style) => { + const iframe = element as HTMLElement + if (style === null) iframe.removeAttribute("style") + else iframe.setAttribute("style", style) + }, previousStyle).catch(() => undefined) + } +} + function boundedPresentationSelector(args: string[], name: string, fallback: string): string { const selector = argValue(args, name)?.trim() || fallback if (selector.length > 512) throw new Error(`wordpress.editor-open ${name} must be at most 512 characters`) @@ -1068,9 +1091,12 @@ export async function dismissWordPressOnboardingDialogs(page: import("playwright const wp = (globalThis as typeof globalThis & { wp?: { data?: { select?: (store: string) => { isFeatureActive?: (feature: string) => boolean } - dispatch?: (store: string) => { toggleFeature?: (feature: string) => void } + dispatch?: (store: string) => { set?: (scope: string, feature: string, value: boolean) => void; toggleFeature?: (feature: string) => void } } } }).wp + const preferences = wp?.data?.dispatch?.("core/preferences") + preferences?.set?.("core/edit-post", "welcomeGuide", false) + preferences?.set?.("core/edit-post", "welcomeGuideTemplate", false) if (wp?.data?.select?.("core/edit-post")?.isFeatureActive?.("welcomeGuide")) { wp.data.dispatch?.("core/edit-post")?.toggleFeature?.("welcomeGuide") } diff --git a/tests/browser-routed-command-security.test.ts b/tests/browser-routed-command-security.test.ts index 9152594a..a8f89647 100644 --- a/tests/browser-routed-command-security.test.ts +++ b/tests/browser-routed-command-security.test.ts @@ -22,6 +22,7 @@ const REPLACED_CANVAS_PRESENTATION_IDENTITY = "d".repeat(64) const DELAYED_CANVAS_PRESENTATION_IDENTITY = "e".repeat(64) const PARENT_CANVAS_PRESENTATION_IDENTITY = "f".repeat(64) const SLOW_PRESENTATION_IDENTITY = "1".repeat(64) +const matchedPresentationMarkup = `
Matched presentation
` const editorShell = `
Editor fixture
` const editorHtml = `${editorShell}` +const onboardingEditorHtml = `${editorShell}
Undismissed guide without controls
` const delayedCanvasEditorHtml = `${editorShell}
Transition
` @@ -51,6 +54,12 @@ test("real browser commands sanitize console, artifacts, stdout, and failure std ? "
Broken editor fixture
" : request.url?.startsWith("/presentation") ? "
Deliberately different frontend fixture
" + : request.url?.startsWith("/matched-frontend") + ? matchedPresentationMarkup + : request.url?.startsWith("/matched-editor") + ? matchedPresentationEditorHtml + : request.url?.startsWith("/onboarding-editor") + ? onboardingEditorHtml : request.url?.startsWith("/parent-canvas") ? parentCanvasEditorHtml : request.url?.startsWith("/replacing-canvas") @@ -136,6 +145,30 @@ test("real browser commands sanitize console, artifacts, stdout, and failure std await assertCommandSurfacesSafe(result, artifactRoot, ["files/browser/presentation-frontend.png", "files/browser/presentation-editor.png", "files/browser/presentation-diff.png"]) }) + await withTempDir("wp-codebox-editor-presentation-iframe-viewport-", async (artifactRoot) => { + const result = await runEditorOpenCommand({ + artifactRoot, + runPlaygroundCommand, + runtimeSpec, + server, + spec: { command: "wordpress.editor-open", args: ["url=http://routed.test/matched-editor", "presentation-url=/matched-frontend", "route-host=routed.test", "capture=steps", "wait-timeout=5s"] }, + }) + const output = JSON.parse(result.output) as { summary: { editorPresentation: { matchedRendering: { status: string } } } } + assert.equal(output.summary.editorPresentation.matchedRendering.status, "passed") + }) + + await withTempDir("wp-codebox-editor-onboarding-preference-", async (artifactRoot) => { + const result = await runEditorOpenCommand({ + artifactRoot, + runPlaygroundCommand, + runtimeSpec, + server, + spec: { command: "wordpress.editor-open", args: ["url=http://routed.test/onboarding-editor", "route-host=routed.test", "capture=steps", "wait-timeout=5s"] }, + }) + const output = JSON.parse(result.output) as { summary: { editorPresentation: { idleCanvas: { onboardingModalCount: number } } } } + assert.equal(output.summary.editorPresentation.idleCanvas.onboardingModalCount, 0) + }) + await withTempDir("wp-codebox-real-editor-parent-canvas-security-", async (artifactRoot) => { const result = await runEditorOpenCommand({ artifactRoot, From c41eaaffd70286a7ffed2f4d9d406abcbed2300b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 17:29:59 -0400 Subject: [PATCH 08/16] fix(editor): avoid retoggling welcome guide --- packages/runtime-playground/src/editor-command-runners.ts | 7 ++++--- tests/browser-routed-command-security.test.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 12543776..0c94bb7b 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -1095,9 +1095,10 @@ export async function dismissWordPressOnboardingDialogs(page: import("playwright } } }).wp const preferences = wp?.data?.dispatch?.("core/preferences") - preferences?.set?.("core/edit-post", "welcomeGuide", false) - preferences?.set?.("core/edit-post", "welcomeGuideTemplate", false) - if (wp?.data?.select?.("core/edit-post")?.isFeatureActive?.("welcomeGuide")) { + if (preferences?.set) { + preferences.set("core/edit-post", "welcomeGuide", false) + preferences.set("core/edit-post", "welcomeGuideTemplate", false) + } else if (wp?.data?.select?.("core/edit-post")?.isFeatureActive?.("welcomeGuide")) { wp.data.dispatch?.("core/edit-post")?.toggleFeature?.("welcomeGuide") } const selectors = [ diff --git a/tests/browser-routed-command-security.test.ts b/tests/browser-routed-command-security.test.ts index a8f89647..9ff64ade 100644 --- a/tests/browser-routed-command-security.test.ts +++ b/tests/browser-routed-command-security.test.ts @@ -40,7 +40,7 @@ window.wp = { }
Editor fixture
` const editorHtml = `${editorShell}` -const onboardingEditorHtml = `${editorShell}
Undismissed guide without controls
` From 232493a87cb78444978f821367934ce4f6b7fe72 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 17:38:49 -0400 Subject: [PATCH 09/16] fix(editor): dismiss late welcome guide --- packages/runtime-playground/src/editor-command-runners.ts | 1 + tests/browser-routed-command-security.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 0c94bb7b..a4b1cedf 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -651,6 +651,7 @@ export async function runEditorOpenCommand({ editorPresentation = await captureEditorPresentation(page, waitTimeoutMs) if (editorPresentation) { const expected = await captureExpectedEditorPresentationIdentities(target, runPlaygroundCommand, runtimeSpec, server) + await dismissWordPressOnboardingDialogs(page) const idleCanvas = await captureEditorIdleCanvas(page) editorPresentation = { ...editorPresentation, diff --git a/tests/browser-routed-command-security.test.ts b/tests/browser-routed-command-security.test.ts index 9ff64ade..071e21ea 100644 --- a/tests/browser-routed-command-security.test.ts +++ b/tests/browser-routed-command-security.test.ts @@ -40,7 +40,7 @@ window.wp = { }
Editor fixture
` const editorHtml = `${editorShell}` -const onboardingEditorHtml = `${editorShell}
Undismissed guide without controls
` From dafce01c0b9813dcfbf6031de95aab9071750b67 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 18:13:20 -0400 Subject: [PATCH 10/16] feat(editor): diagnose idle canvas modals --- .../src/browser-artifacts.ts | 14 +++++++ .../src/editor-command-runners.ts | 41 ++++++++++++++++--- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/runtime-playground/src/browser-artifacts.ts b/packages/runtime-playground/src/browser-artifacts.ts index c6013794..9d6a7783 100644 --- a/packages/runtime-playground/src/browser-artifacts.ts +++ b/packages/runtime-playground/src/browser-artifacts.ts @@ -294,6 +294,20 @@ export interface BrowserEditorIdleCanvasSummary { schema: "wp-codebox/editor-idle-canvas/v1" status: "captured" | "unavailable" onboardingModalCount?: number + onboardingModals?: Array<{ + selectors: string[] + tag: string + className: string + role?: string + ariaLabel?: string + text: string + controls: Array<{ tag: string; className: string; ariaLabel?: string; text: string; disabled: boolean }> + }> + preferences?: { + welcomeGuide?: boolean + welcomeGuideTemplate?: boolean + editPostWelcomeGuideActive?: boolean + } } export interface BrowserEditorPresentationMatchSummary { diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index a4b1cedf..e95ce76a 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -964,17 +964,48 @@ echo wp_json_encode( array( 'identities' => $identities, 'complete' => true ) ); } export async function captureEditorIdleCanvas(page: import("playwright").Page): Promise { - const onboardingModalCount = await page.evaluate(() => { + const idleCanvas = await page.evaluate(() => { const selectors = [".components-guide", ".welcome-panel", ".components-modal__frame"] const elements = new Set(selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector)))) - return Array.from(elements).filter((element) => { + const visibleElements = Array.from(elements).filter((element) => { const style = getComputedStyle(element) return style.display !== "none" && style.visibility !== "hidden" && Number(style.opacity || "1") > 0 - }).length + }) + const onboardingModals = visibleElements.slice(0, 4).map((element) => ({ + selectors: selectors.filter((selector) => element.matches(selector)), + tag: element.tagName.toLowerCase(), + className: element.className.slice(0, 256), + ...(element.getAttribute("role") ? { role: element.getAttribute("role")! } : {}), + ...(element.getAttribute("aria-label") ? { ariaLabel: element.getAttribute("aria-label")! } : {}), + text: (element.innerText || "").trim().replace(/\s+/g, " ").slice(0, 256), + controls: Array.from(element.querySelectorAll("button,[role='button']")).slice(0, 8).map((control) => ({ + tag: control.tagName.toLowerCase(), + className: control.className.slice(0, 256), + ...(control.getAttribute("aria-label") ? { ariaLabel: control.getAttribute("aria-label")! } : {}), + text: (control.innerText || "").trim().replace(/\s+/g, " ").slice(0, 128), + disabled: control.hasAttribute("disabled"), + })), + })) + const wp = (globalThis as typeof globalThis & { wp?: { data?: { select?: (store: string) => { get?: (scope: string, name: string) => unknown; isFeatureActive?: (feature: string) => boolean } } } }).wp + const preferenceStore = wp?.data?.select?.("core/preferences") + const editPostStore = wp?.data?.select?.("core/edit-post") + const welcomeGuide = preferenceStore?.get?.("core/edit-post", "welcomeGuide") + const welcomeGuideTemplate = preferenceStore?.get?.("core/edit-post", "welcomeGuideTemplate") + const editPostWelcomeGuideActive = editPostStore?.isFeatureActive?.("welcomeGuide") + const preferences = { + ...(typeof welcomeGuide === "boolean" ? { welcomeGuide } : {}), + ...(typeof welcomeGuideTemplate === "boolean" ? { welcomeGuideTemplate } : {}), + ...(typeof editPostWelcomeGuideActive === "boolean" ? { editPostWelcomeGuideActive } : {}), + } + return { + onboardingModalCount: visibleElements.length, + ...(onboardingModals.length > 0 ? { onboardingModals } : {}), + ...(Object.keys(preferences).length > 0 ? { preferences } : {}), + } }).catch(() => undefined) - return onboardingModalCount === undefined + return idleCanvas === undefined ? { schema: "wp-codebox/editor-idle-canvas/v1", status: "unavailable" } - : { schema: "wp-codebox/editor-idle-canvas/v1", status: "captured", onboardingModalCount } + : { schema: "wp-codebox/editor-idle-canvas/v1", status: "captured", ...idleCanvas } } async function captureEditorPresentationMatch(input: { From c92a7715f1aad2257f78f4a110cb6249d6408bf7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 18:19:13 -0400 Subject: [PATCH 11/16] fix(editor): isolate presentation canvas snapshot --- .../src/editor-command-runners.ts | 39 +++++++------------ tests/browser-routed-command-security.test.ts | 2 +- tests/editor-actions.test.ts | 10 ++++- 3 files changed, 24 insertions(+), 27 deletions(-) diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index e95ce76a..5fae791a 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -1028,8 +1028,8 @@ async function captureEditorPresentationMatch(input: { try { const frame = await resolveEditorCanvasFrame(input.page, EDITOR_CANVAS_DEFAULT_IFRAME_SELECTOR) const canvas = frame ?? input.page - const editor = canvas.locator(editorSelector).first() - const editorBox = await editor.boundingBox() + const liveEditor = canvas.locator(editorSelector).first() + const editorBox = await liveEditor.boundingBox() if (!editorBox || editorBox.width <= 0 || editorBox.height <= 0) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: "editor presentation surface has no visible bounds" } const browser = input.page.context().browser() if (!browser) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: "browser context is not available for frontend capture" } @@ -1038,13 +1038,23 @@ async function captureEditorPresentationMatch(input: { viewport: { width: Math.round(editorBox.width), height: Math.max(1, Math.round(editorBox.height)) }, }) const frontend = await frontendContext.newPage() - let restoreEditorCanvasViewport = async () => {} + let editorSnapshot: import("playwright").Page | undefined try { - if (frame) restoreEditorCanvasViewport = await expandEditorCanvasViewport(frame, editorBox.height) + if (frame) { + editorSnapshot = await frontendContext.newPage() + const baseUrl = frame.url().startsWith("http") ? frame.url() : input.page.url() + const html = await frame.content() + const base = `` + await editorSnapshot.setContent(html.replace(/]*)?>/i, (head) => `${head}${base}`), { waitUntil: "networkidle", timeout: input.waitTimeoutMs }) + await editorSnapshot.evaluate(() => document.fonts?.ready) + } await frontend.goto(input.topology.resolveUrl(presentationUrl), { waitUntil: "networkidle", timeout: input.waitTimeoutMs }) const source = frontend.locator(frontendSelector).first() if (!await source.isVisible()) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: `frontend presentation selector is not visible: ${frontendSelector}` } - const [frontendEvidence, editorEvidence] = await Promise.all([presentationSurfaceEvidence(frontend, frontendSelector), presentationSurfaceEvidence(canvas, editorSelector)]) + const editorSurface = editorSnapshot ?? canvas + const editor = editorSurface.locator(editorSelector).first() + if (!await editor.isVisible()) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: `editor presentation selector is not visible in the isolated canvas: ${editorSelector}` } + const [frontendEvidence, editorEvidence] = await Promise.all([presentationSurfaceEvidence(frontend, frontendSelector), presentationSurfaceEvidence(editorSurface, editorSelector)]) await input.artifactSession.writeGenerated("screenshot", "presentation-frontend.png", async (path) => { await source.screenshot({ path, timeout: input.waitTimeoutMs }); }) await input.artifactSession.writeGenerated("screenshot", "presentation-editor.png", async (path) => { await editor.screenshot({ path, timeout: input.waitTimeoutMs }); }) let comparison: Awaited> | undefined @@ -1062,7 +1072,6 @@ async function captureEditorPresentationMatch(input: { const passed = equivalentCanvasWidths && !majorGeometryDrift && !unreadableContent && !hiddenContent && unresolvedAssetCount === 0 return { schema: "wp-codebox/editor-presentation-match/v1", status: passed ? "passed" : "failed", equivalentCanvasWidths, majorGeometryDrift, unreadableContent, hiddenContent, unresolvedAssetCount, frontendScreenshot: frontendRef, editorScreenshot: editorRef, diffScreenshot: diffRef } } finally { - await restoreEditorCanvasViewport() await frontendContext.close() } } catch (error) { @@ -1070,24 +1079,6 @@ async function captureEditorPresentationMatch(input: { } } -async function expandEditorCanvasViewport(frame: import("playwright").Frame, contentHeight: number): Promise<() => Promise> { - const frameElement = await frame.frameElement() - const previousStyle = await frameElement.evaluate((element) => (element as HTMLElement).getAttribute("style")) - await frameElement.evaluate((element, height) => { - const iframe = element as HTMLElement - iframe.style.setProperty("height", `${Math.ceil(height)}px`, "important") - iframe.style.setProperty("min-height", `${Math.ceil(height)}px`, "important") - iframe.style.setProperty("max-height", "none", "important") - }, contentHeight) - return async () => { - await frameElement.evaluate((element, style) => { - const iframe = element as HTMLElement - if (style === null) iframe.removeAttribute("style") - else iframe.setAttribute("style", style) - }, previousStyle).catch(() => undefined) - } -} - function boundedPresentationSelector(args: string[], name: string, fallback: string): string { const selector = argValue(args, name)?.trim() || fallback if (selector.length > 512) throw new Error(`wordpress.editor-open ${name} must be at most 512 characters`) diff --git a/tests/browser-routed-command-security.test.ts b/tests/browser-routed-command-security.test.ts index 071e21ea..33c74632 100644 --- a/tests/browser-routed-command-security.test.ts +++ b/tests/browser-routed-command-security.test.ts @@ -133,7 +133,7 @@ test("real browser commands sanitize console, artifacts, stdout, and failure std assert.deepEqual(output.summary.editorPresentation.matchedRendering, { schema: "wp-codebox/editor-presentation-match/v1", status: "failed", - equivalentCanvasWidths: false, + equivalentCanvasWidths: true, majorGeometryDrift: true, unreadableContent: false, hiddenContent: false, diff --git a/tests/editor-actions.test.ts b/tests/editor-actions.test.ts index ce6f49c4..7dff3996 100644 --- a/tests/editor-actions.test.ts +++ b/tests/editor-actions.test.ts @@ -101,12 +101,18 @@ const idleCanvas = await captureEditorIdleCanvas({ const globals = globalThis as typeof globalThis & { document?: unknown; getComputedStyle?: unknown } const document = globals.document const getComputedStyle = globals.getComputedStyle - globals.document = { querySelectorAll: (selector: string) => selector === ".components-guide" ? [{ id: "guide" }] : [] } + const guide = { matches: (selector: string) => selector === ".components-guide", tagName: "DIV", className: "components-guide", getAttribute: () => null, innerText: "Welcome", querySelectorAll: () => [] } + globals.document = { querySelectorAll: (selector: string) => selector === ".components-guide" ? [guide] : [] } globals.getComputedStyle = () => ({ display: "block", visibility: "visible", opacity: "1" }) try { return callback() } finally { globals.document = document; globals.getComputedStyle = getComputedStyle } }, } as never) -assert.deepEqual(idleCanvas, { schema: "wp-codebox/editor-idle-canvas/v1", status: "captured", onboardingModalCount: 1 }) +assert.deepEqual(idleCanvas, { + schema: "wp-codebox/editor-idle-canvas/v1", + status: "captured", + onboardingModalCount: 1, + onboardingModals: [{ selectors: [".components-guide"], tag: "div", className: "components-guide", text: "Welcome", controls: [] }], +}) const unavailableIdleCanvas = await captureEditorIdleCanvas({ evaluate: async () => { throw new Error("page detached") }, From 36149c5877a593d4aaec1851a9e81bfd8c6d489e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 18:41:43 -0400 Subject: [PATCH 12/16] feat(editor): capture presentation geometry diagnostics --- .../src/browser-artifacts.ts | 14 ++++++ .../src/editor-command-runners.ts | 45 ++++++++++++++++++- tests/browser-routed-command-security.test.ts | 6 ++- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/runtime-playground/src/browser-artifacts.ts b/packages/runtime-playground/src/browser-artifacts.ts index 9d6a7783..987974ed 100644 --- a/packages/runtime-playground/src/browser-artifacts.ts +++ b/packages/runtime-playground/src/browser-artifacts.ts @@ -322,6 +322,20 @@ export interface BrowserEditorPresentationMatchSummary { frontendScreenshot?: string editorScreenshot?: string diffScreenshot?: string + geometry?: { + frontend: BrowserEditorPresentationSurfaceGeometry + liveEditor: BrowserEditorPresentationSurfaceGeometry + isolatedEditor: BrowserEditorPresentationSurfaceGeometry + } +} + +export interface BrowserEditorPresentationSurfaceGeometry { + width: number + height: number + childCount: number + presentationResetPresent: boolean + style: { display: string; marginTop: string; marginBottom: string; maxWidth: string; minHeight: string; paddingTop: string; paddingBottom: string } + children: Array<{ tag: string; className: string; blockType?: string; top: number; width: number; height: number; marginTop: string; marginBottom: string; maxWidth: string; minHeight: string; paddingTop: string; paddingBottom: string }> } export interface BrowserEditorPresentationSummary { diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 5fae791a..a5f4e885 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -1054,7 +1054,13 @@ async function captureEditorPresentationMatch(input: { const editorSurface = editorSnapshot ?? canvas const editor = editorSurface.locator(editorSelector).first() if (!await editor.isVisible()) return { schema: "wp-codebox/editor-presentation-match/v1", status: "unavailable", diagnostic: `editor presentation selector is not visible in the isolated canvas: ${editorSelector}` } - const [frontendEvidence, editorEvidence] = await Promise.all([presentationSurfaceEvidence(frontend, frontendSelector), presentationSurfaceEvidence(editorSurface, editorSelector)]) + const [frontendEvidence, editorEvidence, frontendGeometry, liveEditorGeometry, isolatedEditorGeometry] = await Promise.all([ + presentationSurfaceEvidence(frontend, frontendSelector), + presentationSurfaceEvidence(editorSurface, editorSelector), + presentationSurfaceGeometry(frontend, frontendSelector), + presentationSurfaceGeometry(canvas, editorSelector), + presentationSurfaceGeometry(editorSurface, editorSelector), + ]) await input.artifactSession.writeGenerated("screenshot", "presentation-frontend.png", async (path) => { await source.screenshot({ path, timeout: input.waitTimeoutMs }); }) await input.artifactSession.writeGenerated("screenshot", "presentation-editor.png", async (path) => { await editor.screenshot({ path, timeout: input.waitTimeoutMs }); }) let comparison: Awaited> | undefined @@ -1070,7 +1076,7 @@ async function captureEditorPresentationMatch(input: { const hiddenContent = !frontendEvidence.visible || !editorEvidence.visible const unresolvedAssetCount = frontendEvidence.unresolvedAssets + editorEvidence.unresolvedAssets const passed = equivalentCanvasWidths && !majorGeometryDrift && !unreadableContent && !hiddenContent && unresolvedAssetCount === 0 - return { schema: "wp-codebox/editor-presentation-match/v1", status: passed ? "passed" : "failed", equivalentCanvasWidths, majorGeometryDrift, unreadableContent, hiddenContent, unresolvedAssetCount, frontendScreenshot: frontendRef, editorScreenshot: editorRef, diffScreenshot: diffRef } + return { schema: "wp-codebox/editor-presentation-match/v1", status: passed ? "passed" : "failed", equivalentCanvasWidths, majorGeometryDrift, unreadableContent, hiddenContent, unresolvedAssetCount, frontendScreenshot: frontendRef, editorScreenshot: editorRef, diffScreenshot: diffRef, geometry: { frontend: frontendGeometry, liveEditor: liveEditorGeometry, isolatedEditor: isolatedEditorGeometry } } } finally { await frontendContext.close() } @@ -1079,6 +1085,41 @@ async function captureEditorPresentationMatch(input: { } } +async function presentationSurfaceGeometry(surface: import("playwright").Page | import("playwright").Frame, selector: string): Promise { + return surface.evaluate((selector) => { + const element = document.querySelector(selector) + if (!element) throw new Error(`presentation geometry selector is unavailable: ${selector}`) + const rect = element.getBoundingClientRect() + const style = getComputedStyle(element) + return { + width: Math.round(rect.width * 100) / 100, + height: Math.round(rect.height * 100) / 100, + childCount: element.children.length, + presentationResetPresent: Array.from(document.querySelectorAll("style")).some((node) => (node.textContent || "").includes(".block-editor-block-list__layout.is-root-container > .wp-block")), + style: { display: style.display, marginTop: style.marginTop, marginBottom: style.marginBottom, maxWidth: style.maxWidth, minHeight: style.minHeight, paddingTop: style.paddingTop, paddingBottom: style.paddingBottom }, + children: Array.from(element.children).slice(0, 16).map((child) => { + const childElement = child as HTMLElement + const childRect = childElement.getBoundingClientRect() + const childStyle = getComputedStyle(childElement) + return { + tag: childElement.tagName.toLowerCase(), + className: childElement.className.slice(0, 256), + ...(childElement.dataset.type ? { blockType: childElement.dataset.type } : {}), + top: Math.round((childRect.top - rect.top) * 100) / 100, + width: Math.round(childRect.width * 100) / 100, + height: Math.round(childRect.height * 100) / 100, + marginTop: childStyle.marginTop, + marginBottom: childStyle.marginBottom, + maxWidth: childStyle.maxWidth, + minHeight: childStyle.minHeight, + paddingTop: childStyle.paddingTop, + paddingBottom: childStyle.paddingBottom, + } + }), + } + }, selector) +} + function boundedPresentationSelector(args: string[], name: string, fallback: string): string { const selector = argValue(args, name)?.trim() || fallback if (selector.length > 512) throw new Error(`wordpress.editor-open ${name} must be at most 512 characters`) diff --git a/tests/browser-routed-command-security.test.ts b/tests/browser-routed-command-security.test.ts index 33c74632..8ab8e871 100644 --- a/tests/browser-routed-command-security.test.ts +++ b/tests/browser-routed-command-security.test.ts @@ -130,7 +130,8 @@ test("real browser commands sanitize console, artifacts, stdout, and failure std spec: { command: "wordpress.editor-open", args: [`url=${PUBLIC_URL}`, "presentation-url=/presentation", "route-host=routed.test", "capture=steps", "wait-timeout=5s"] }, }) const output = JSON.parse(result.output) as { summary: { editorPresentation: { matchedRendering: { status: string; frontendScreenshot: string; editorScreenshot: string; diffScreenshot: string; equivalentCanvasWidths: boolean; majorGeometryDrift: boolean; unreadableContent: boolean; hiddenContent: boolean; unresolvedAssetCount: number } } } } - assert.deepEqual(output.summary.editorPresentation.matchedRendering, { + const { geometry, ...matchedRendering } = output.summary.editorPresentation.matchedRendering as typeof output.summary.editorPresentation.matchedRendering & { geometry: { frontend: { childCount: number }; liveEditor: { childCount: number }; isolatedEditor: { childCount: number } } } + assert.deepEqual(matchedRendering, { schema: "wp-codebox/editor-presentation-match/v1", status: "failed", equivalentCanvasWidths: true, @@ -142,6 +143,9 @@ test("real browser commands sanitize console, artifacts, stdout, and failure std editorScreenshot: "files/browser/presentation-editor.png", diffScreenshot: "files/browser/presentation-diff.png", }) + assert.equal(geometry.frontend.childCount, 1) + assert.equal(geometry.liveEditor.childCount, 1) + assert.equal(geometry.isolatedEditor.childCount, 1) await assertCommandSurfacesSafe(result, artifactRoot, ["files/browser/presentation-frontend.png", "files/browser/presentation-editor.png", "files/browser/presentation-diff.png"]) }) From ea08e5106bdd59ae97c6c3c79ef51e064e8af865 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 18:46:29 -0400 Subject: [PATCH 13/16] feat(editor): capture descendant presentation geometry --- .../src/browser-artifacts.ts | 1 + .../src/editor-command-runners.ts | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/runtime-playground/src/browser-artifacts.ts b/packages/runtime-playground/src/browser-artifacts.ts index 987974ed..f6118ccb 100644 --- a/packages/runtime-playground/src/browser-artifacts.ts +++ b/packages/runtime-playground/src/browser-artifacts.ts @@ -336,6 +336,7 @@ export interface BrowserEditorPresentationSurfaceGeometry { presentationResetPresent: boolean style: { display: string; marginTop: string; marginBottom: string; maxWidth: string; minHeight: string; paddingTop: string; paddingBottom: string } children: Array<{ tag: string; className: string; blockType?: string; top: number; width: number; height: number; marginTop: string; marginBottom: string; maxWidth: string; minHeight: string; paddingTop: string; paddingBottom: string }> + descendants: Array<{ path: string; tag: string; className: string; text: string; top: number; left: number; width: number; height: number; display: string; position: string; marginTop: string; marginBottom: string; maxWidth: string; minHeight: string; paddingTop: string; paddingBottom: string; gap: string; gridTemplateColumns: string; fontSize: string; lineHeight: string }> } export interface BrowserEditorPresentationSummary { diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index a5f4e885..4c3ba336 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -1116,6 +1116,38 @@ async function presentationSurfaceGeometry(surface: import("playwright").Page | paddingBottom: childStyle.paddingBottom, } }), + descendants: Array.from(element.querySelectorAll("*")).slice(0, 128).map((descendant) => { + const descendantRect = descendant.getBoundingClientRect() + const descendantStyle = getComputedStyle(descendant) + const path: number[] = [] + let cursor: HTMLElement | null = descendant + while (cursor && cursor !== element) { + path.unshift(Array.prototype.indexOf.call(cursor.parentElement?.children || [], cursor)) + cursor = cursor.parentElement + } + return { + path: path.join("."), + tag: descendant.tagName.toLowerCase(), + className: descendant.className.slice(0, 256), + text: (descendant.innerText || "").trim().replace(/\s+/g, " ").slice(0, 96), + top: Math.round((descendantRect.top - rect.top) * 100) / 100, + left: Math.round((descendantRect.left - rect.left) * 100) / 100, + width: Math.round(descendantRect.width * 100) / 100, + height: Math.round(descendantRect.height * 100) / 100, + display: descendantStyle.display, + position: descendantStyle.position, + marginTop: descendantStyle.marginTop, + marginBottom: descendantStyle.marginBottom, + maxWidth: descendantStyle.maxWidth, + minHeight: descendantStyle.minHeight, + paddingTop: descendantStyle.paddingTop, + paddingBottom: descendantStyle.paddingBottom, + gap: descendantStyle.gap, + gridTemplateColumns: descendantStyle.gridTemplateColumns, + fontSize: descendantStyle.fontSize, + lineHeight: descendantStyle.lineHeight, + } + }), } }, selector) } From 68cc0a7110d643bc532f9eee53c3a852a0e0d24a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 18:49:52 -0400 Subject: [PATCH 14/16] fix(editor): normalize SVG geometry class names --- packages/runtime-playground/src/editor-command-runners.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 4c3ba336..08d6e790 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -1128,7 +1128,7 @@ async function presentationSurfaceGeometry(surface: import("playwright").Page | return { path: path.join("."), tag: descendant.tagName.toLowerCase(), - className: descendant.className.slice(0, 256), + className: (typeof descendant.className === "string" ? descendant.className : descendant.getAttribute("class") || "").slice(0, 256), text: (descendant.innerText || "").trim().replace(/\s+/g, " ").slice(0, 96), top: Math.round((descendantRect.top - rect.top) * 100) / 100, left: Math.round((descendantRect.left - rect.left) * 100) / 100, From eb66336c7ddc12a0637d9bfa080f409bf19cc456 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 18:55:19 -0400 Subject: [PATCH 15/16] fix(editor): detect nested presentation reset --- packages/runtime-playground/src/editor-command-runners.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 08d6e790..2a0fd9d4 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -1095,7 +1095,7 @@ async function presentationSurfaceGeometry(surface: import("playwright").Page | width: Math.round(rect.width * 100) / 100, height: Math.round(rect.height * 100) / 100, childCount: element.children.length, - presentationResetPresent: Array.from(document.querySelectorAll("style")).some((node) => (node.textContent || "").includes(".block-editor-block-list__layout.is-root-container > .wp-block")), + presentationResetPresent: Array.from(document.querySelectorAll("style")).some((node) => (node.textContent || "").includes(".block-editor-block-list__layout > .wp-block")), style: { display: style.display, marginTop: style.marginTop, marginBottom: style.marginBottom, maxWidth: style.maxWidth, minHeight: style.minHeight, paddingTop: style.paddingTop, paddingBottom: style.paddingBottom }, children: Array.from(element.children).slice(0, 16).map((child) => { const childElement = child as HTMLElement From e4941ca33da3272af2ca73aa22dc72a044d5783b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 26 Aug 2026 19:58:21 -0400 Subject: [PATCH 16/16] feat(editor): trace matched margin rules --- .../src/browser-artifacts.ts | 1 + .../src/editor-command-runners.ts | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/packages/runtime-playground/src/browser-artifacts.ts b/packages/runtime-playground/src/browser-artifacts.ts index f6118ccb..2e7a3f60 100644 --- a/packages/runtime-playground/src/browser-artifacts.ts +++ b/packages/runtime-playground/src/browser-artifacts.ts @@ -334,6 +334,7 @@ export interface BrowserEditorPresentationSurfaceGeometry { height: number childCount: number presentationResetPresent: boolean + marginRules: Array<{ selector: string; declaration: string; origin: string }> style: { display: string; marginTop: string; marginBottom: string; maxWidth: string; minHeight: string; paddingTop: string; paddingBottom: string } children: Array<{ tag: string; className: string; blockType?: string; top: number; width: number; height: number; marginTop: string; marginBottom: string; maxWidth: string; minHeight: string; paddingTop: string; paddingBottom: string }> descendants: Array<{ path: string; tag: string; className: string; text: string; top: number; left: number; width: number; height: number; display: string; position: string; marginTop: string; marginBottom: string; maxWidth: string; minHeight: string; paddingTop: string; paddingBottom: string; gap: string; gridTemplateColumns: string; fontSize: string; lineHeight: string }> diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 2a0fd9d4..844671d3 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -1096,6 +1096,54 @@ async function presentationSurfaceGeometry(surface: import("playwright").Page | height: Math.round(rect.height * 100) / 100, childCount: element.children.length, presentationResetPresent: Array.from(document.querySelectorAll("style")).some((node) => (node.textContent || "").includes(".block-editor-block-list__layout > .wp-block")), + marginRules: (() => { + const rows: Array<{ selector: string; declaration: string; origin: string }> = [] + const descendants = Array.from(element.querySelectorAll("*")).slice(0, 128) + const targets = [element, ...descendants] + for (const sheet of Array.from(document.styleSheets)) { + const origin = (sheet.href || "inline").slice(0, 120) + const pending: CSSRule[] = [] + try { + pending.push(...Array.from(sheet.cssRules || [])) + } catch { + rows.push({ selector: "(unreadable stylesheet)", declaration: "", origin }) + continue + } + while (pending.length > 0 && rows.length < 60) { + const rule = pending.shift() as CSSRule + const styleRule = rule as CSSStyleRule + const grouping = rule as CSSRule & { cssRules?: CSSRuleList } + if (!styleRule.selectorText && grouping.cssRules) { + pending.unshift(...Array.from(grouping.cssRules)) + continue + } + if (!styleRule.selectorText || !styleRule.style) continue + const declaration = ["margin-top", "margin-block-start", "margin-bottom", "margin-block-end", "margin"] + .map((property) => { + const value = styleRule.style.getPropertyValue(property) + return value ? property + ":" + value : "" + }) + .filter(Boolean) + .join(";") + if (!declaration) continue + let matched = false + for (const target of targets) { + try { + if (target.matches(styleRule.selectorText)) { + matched = true + break + } + } catch { + matched = false + } + } + if (!matched) continue + rows.push({ selector: styleRule.selectorText.slice(0, 180), declaration: declaration.slice(0, 120), origin }) + } + if (rows.length >= 60) break + } + return rows + })(), style: { display: style.display, marginTop: style.marginTop, marginBottom: style.marginBottom, maxWidth: style.maxWidth, minHeight: style.minHeight, paddingTop: style.paddingTop, paddingBottom: style.paddingBottom }, children: Array.from(element.children).slice(0, 16).map((child) => { const childElement = child as HTMLElement