Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions packages/runtime-playground/src/editor-command-runners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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, type EditorOpenTarget } from "./editor-actions.js"
import { assertPlaygroundResponseOk, type PlaygroundRunResponse } from "./playground-command-errors.js"
import { assertPlaygroundResponseOk, attachPlaygroundDiagnostics, 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"
Expand All @@ -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_CONTRACT_MARKER = "WP_CODEBOX_EDITOR_PRESENTATION_CONTRACT:"
const EDITOR_VALIDITY_WARNING_SELECTORS = [
".block-editor-warning",
".block-editor-block-list__block.is-invalid",
Expand Down Expand Up @@ -873,7 +874,8 @@ export async function captureEditorPresentation(page: import("playwright").Page,
let canvas: import("playwright").Page | import("playwright").Frame | undefined = frame ?? undefined
let canvasDocumentType: "iframe" | "parent" = "iframe"
if (!frame) {
const hasParentDocumentCanvas = await page.locator(EDITOR_CANVAS_DEFAULT_LAYOUT_SELECTOR).first().isVisible().catch(() => false)
const parentDocumentCanvases = await page.locator(EDITOR_CANVAS_DEFAULT_LAYOUT_SELECTOR).all()
const hasParentDocumentCanvas = (await Promise.all(parentDocumentCanvases.map((candidate) => candidate.isVisible().catch(() => false)))).some(Boolean)
if (sawCanvas || !hasParentDocumentCanvas || Date.now() - startedAtMs < EDITOR_PRESENTATION_IFRAME_DISCOVERY_MS) {
previousFingerprint = undefined
stableSinceMs = undefined
Expand Down Expand Up @@ -945,6 +947,8 @@ async function captureExpectedEditorPresentationIdentities(
code: bootstrapPhpCode(runtimeSpec, `
$post = get_post(${target.postId});
if ( ! $post instanceof WP_Post ) { throw new RuntimeException( 'Editor target post is unavailable.' ); }
wp_styles();
wp_scripts();
$settings = get_block_editor_settings( array(), new WP_Block_Editor_Context( array( 'post' => $post ) ) );
$identities = array();
foreach ( (array) ( $settings['styles'] ?? array() ) as $style ) {
Expand All @@ -955,17 +959,31 @@ foreach ( (array) ( $settings['styles'] ?? array() ) as $style ) {
}
$identities = array_values( array_unique( $identities ) );
sort( $identities, SORT_STRING );
echo wp_json_encode( array( 'identities' => $identities, 'complete' => true ) );
echo PHP_EOL . '${EDITOR_PRESENTATION_CONTRACT_MARKER}' . base64_encode( (string) wp_json_encode( array( 'identities' => $identities, 'complete' => true ) ) ) . PHP_EOL;
`, []),
})
assertPlaygroundResponseOk("wordpress.editor-open.capture-presentation-contract", response)
const value = JSON.parse(cleanWpCliOutput(response.text)) as { identities?: unknown; complete?: unknown }
const value = parseEditorPresentationContract(response.text) as { identities?: unknown; complete?: unknown }
if (!Array.isArray(value.identities) || value.complete !== true || !value.identities.every((identity) => typeof identity === "string" && /^[a-f0-9]{64}$/.test(identity))) {
throw new Error("wordpress.editor-open presentation contract returned an invalid identity set")
}
return { identities: [...new Set(value.identities)].sort(), complete: true }
}

export function parseEditorPresentationContract(output: string): unknown {
const text = cleanWpCliOutput(output)
const payload = text.match(new RegExp(`${EDITOR_PRESENTATION_CONTRACT_MARKER}([A-Za-z0-9+/=]+)`))?.[1]
if (!payload) {
throw attachPlaygroundDiagnostics(new Error("wordpress.editor-open presentation contract did not emit framed JSON"), "PHP output", text)
}

try {
return JSON.parse(Buffer.from(payload, "base64").toString("utf8"))
} catch (error) {
throw attachPlaygroundDiagnostics(new Error(`wordpress.editor-open presentation contract emitted invalid framed JSON: ${error instanceof Error ? error.message : String(error)}`), "PHP output", text)
}
}

export async function captureEditorIdleCanvas(page: import("playwright").Page): Promise<BrowserEditorIdleCanvasSummary> {
const idleCanvas = await page.evaluate(() => {
const selectors = [".components-guide", ".welcome-panel", ".components-modal__frame"]
Expand Down
20 changes: 16 additions & 4 deletions tests/browser-routed-command-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { runBrowserActionsCommand, runBrowserScenarioCommand } from "../packages
import { isBrowserCommandArtifactError } from "../packages/runtime-playground/src/browser-command-artifact-error.js"
import { runBrowserMultiActorScenarioCommand } from "../packages/runtime-playground/src/browser-multi-actor-scenario-runner.js"
import { runBrowserProbeCommand } from "../packages/runtime-playground/src/browser-probe-runner.js"
import { runEditorCanvasProbeCommand, runEditorOpenCommand } from "../packages/runtime-playground/src/editor-command-runners.js"
import { parseEditorPresentationContract, runEditorCanvasProbeCommand, runEditorOpenCommand } from "../packages/runtime-playground/src/editor-command-runners.js"
import { closeHttpServer, listenLocalHttpServer, type PlaygroundCliServer } from "../packages/runtime-playground/src/preview-server.js"
import { withTempDir } from "../scripts/test-kit.js"

Expand All @@ -25,6 +25,7 @@ const SLOW_PRESENTATION_IDENTITY = "1".repeat(64)
const PENDING_STYLES_PRESENTATION_IDENTITY = "2".repeat(64)
const GROWING_PRESENTATION_IDENTITIES = ["3".repeat(64), "4".repeat(64)]
const DELAYED_POST_PRESENTATION_IDENTITY = "5".repeat(64)
const EDITOR_PRESENTATION_CONTRACT_MARKER = "WP_CODEBOX_EDITOR_PRESENTATION_CONTRACT:"
const matchedPresentationMarkup = `<style>html,body{margin:0}.block-editor-block-list__layout{box-sizing:border-box;width:200px;height:400px;background:linear-gradient(#123,#abc);color:white;padding:12px}</style><div class="block-editor-block-list__layout">Matched presentation</div>`
const editorShell = `<!doctype html><script>
globalThis.__name = (value) => value
Expand All @@ -45,14 +46,25 @@ window.wp = {
const editorHtml = `${editorShell}<iframe name="unrelated" srcdoc="<style>/* blocks-engine-presentation:${UNRELATED_PRESENTATION_IDENTITY} */<\/style>"></iframe><iframe name="editor-canvas" srcdoc="<script>globalThis.__name = (value) => value;setTimeout(() => { const style = document.createElement('style'); style.textContent = '/* blocks-engine-presentation:${CANVAS_PRESENTATION_IDENTITY} */'; document.head.append(style) }, 400)<\/script><div class='block-editor-block-list__layout'><div class='block-editor-block-list__block' data-block='fixture'>Block</div></div>"></iframe>`
const onboardingEditorHtml = `${editorShell}<script>setTimeout(() => document.body.insertAdjacentHTML('afterbegin', '<div class=components-guide>Late guide without controls</div>'), 1000); const fixtureSelect = wp.data.select; wp.data.select = (store) => store === 'core/edit-post' ? { isFeatureActive: () => true } : fixtureSelect(store); wp.data.dispatch = (store) => store === 'core/preferences' ? { set: (scope, feature, value) => { if (scope === 'core/edit-post' && feature === 'welcomeGuide' && value === false) document.querySelector('.components-guide')?.remove() } } : store === 'core/edit-post' ? { toggleFeature: () => { document.body.insertAdjacentHTML('afterbegin', '<div class=components-guide>Retoggled guide</div>') } } : ({})<\/script><iframe name="editor-canvas" srcdoc="<div class='block-editor-block-list__layout'><div class='block-editor-block-list__block' data-block='fixture'>Block</div></div>"></iframe>`
const matchedPresentationEditorHtml = `${editorShell}<iframe name="editor-canvas" style="border:0;width:200px;height:80px" srcdoc="${matchedPresentationMarkup.replaceAll('"', '&quot;')}"></iframe>`
const parentCanvasEditorHtml = `${editorShell}<style>/* blocks-engine-presentation:${PARENT_CANVAS_PRESENTATION_IDENTITY} */</style><div class="block-editor-block-list__layout"><div class="block-editor-block-list__block" data-block="fixture">Block</div></div>`
const parentCanvasEditorHtml = `${editorShell}<style>/* blocks-engine-presentation:${PARENT_CANVAS_PRESENTATION_IDENTITY} */</style><div class="block-editor-block-list__layout" hidden>Hidden duplicate</div><div class="block-editor-block-list__layout"><div class="block-editor-block-list__block" data-block="fixture">Block</div></div>`
const replacingCanvasEditorHtml = `${editorShell}<iframe name="editor-canvas" srcdoc="<style>/* blocks-engine-presentation:${INITIAL_CANVAS_PRESENTATION_IDENTITY} */<\/style>"></iframe><script>setTimeout(() => { document.querySelector('iframe[name=editor-canvas]').srcdoc = '<style>/* blocks-engine-presentation:${REPLACED_CANVAS_PRESENTATION_IDENTITY} */<\\/style>' }, 150)</script>`
const delayedCanvasEditorHtml = `${editorShell}<div class="block-editor-block-list__layout"><div class="block-editor-block-list__block" data-block="transition">Transition</div></div><script>setTimeout(() => { const iframe = document.createElement('iframe'); iframe.name = 'editor-canvas'; iframe.srcdoc = '<style>/* blocks-engine-presentation:${DELAYED_CANVAS_PRESENTATION_IDENTITY} */<\\/style>'; document.body.append(iframe) }, 300)</script>`
const slowPresentationEditorHtml = `${editorShell}<iframe name="editor-canvas" srcdoc="<script>setTimeout(() => { const style = document.createElement('style'); style.textContent = '/* blocks-engine-presentation:${SLOW_PRESENTATION_IDENTITY} */'; document.head.append(style) }, 3500)<\/script><div class='block-editor-block-list__layout'><div class='block-editor-block-list__block' data-block='slow'>Slow</div></div>"></iframe>`
const pendingStylesEditorHtml = `${editorShell}<iframe name="editor-canvas" srcdoc="<link rel='stylesheet' href='http://127.0.0.1:9/unavailable.css'><style>/* blocks-engine-presentation:${PENDING_STYLES_PRESENTATION_IDENTITY} */<\/style><div class='block-editor-block-list__layout'><div class='block-editor-block-list__block' data-block='pending'>Pending</div></div>"></iframe>`
const delayedPostEditorHtml = `${editorShell}<script>const fixtureSelect = wp.data.select; let currentPostId = null; wp.data.select = (store) => store === 'core/editor' ? { getCurrentPostId: () => currentPostId, getCurrentPostType: () => currentPostId ? 'page' : null } : fixtureSelect(store); setTimeout(() => { currentPostId = 4; const iframe = document.createElement('iframe'); iframe.name = 'editor-canvas'; iframe.srcdoc = "<style>/* blocks-engine-presentation:${DELAYED_POST_PRESENTATION_IDENTITY} */<\\/style><div class='block-editor-block-list__layout'>Hydrated post</div>"; document.body.append(iframe) }, 1500)<\/script>`
const growingPresentationEditorHtml = `${editorShell}<iframe name="editor-canvas" srcdoc="<style>/* blocks-engine-presentation:${GROWING_PRESENTATION_IDENTITIES[0]} */<\/style><script>let identity = 0; setInterval(() => { const style = document.createElement('style'); style.textContent = '/* blocks-engine-presentation:' + (identity++).toString(16).padStart(64, '9') + ' */'; document.head.append(style) }, 100); setTimeout(() => { const style = document.createElement('style'); style.textContent = '/* blocks-engine-presentation:${GROWING_PRESENTATION_IDENTITIES[1]} */'; document.head.append(style) }, 4100)<\/script><div class='block-editor-block-list__layout'><div class='block-editor-block-list__block' data-block='growing'>Growing</div></div>"></iframe>`

function editorPresentationContractOutput(value: unknown): string {
return `${EDITOR_PRESENTATION_CONTRACT_MARKER}${Buffer.from(JSON.stringify(value)).toString("base64")}\n`
}

test("editor presentation contract isolates framed JSON from PHP diagnostics", () => {
const warning = '<br />\n<b>Warning</b>: Undefined array key "css" in <b>/wordpress/wp-includes/block-editor.php</b> on line <b>123</b><br />\n'
const value = { identities: GROWING_PRESENTATION_IDENTITIES, complete: true }
assert.deepEqual(parseEditorPresentationContract(`${warning}${editorPresentationContractOutput(value)}`), value)
assert.throws(() => parseEditorPresentationContract(warning), /Undefined array key "css"/)
})

test("real browser commands sanitize console, artifacts, stdout, and failure stderr", async () => {
const httpServer = createServer((request, response) => {
response.setHeader("content-type", "text/html")
Expand Down Expand Up @@ -98,7 +110,7 @@ test("real browser commands sanitize console, artifacts, stdout, and failure std
} as RuntimeCreateSpec
const runPlaygroundCommand = async (command: string) => ({
text: command === "wordpress.editor-open.capture-presentation-contract"
? JSON.stringify({ identities: GROWING_PRESENTATION_IDENTITIES, complete: true })
? editorPresentationContractOutput({ identities: GROWING_PRESENTATION_IDENTITIES, complete: true })
: "[]",
exitCode: 0,
})
Expand Down Expand Up @@ -257,7 +269,7 @@ test("real browser commands sanitize console, artifacts, stdout, and failure std
artifactRoot,
runPlaygroundCommand: async (command) => ({
text: command.includes("capture-presentation-contract")
? JSON.stringify({ identities: [DELAYED_POST_PRESENTATION_IDENTITY], complete: true })
? editorPresentationContractOutput({ identities: [DELAYED_POST_PRESENTATION_IDENTITY], complete: true })
: "[]",
exitCode: 0,
}),
Expand Down
Loading