From 3e967502afe23bc328e24788fd870f984dc2ca56 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 13 Aug 2026 18:38:14 -0400 Subject: [PATCH 1/6] fix: capture editor presentation evidence --- packages/runtime-core/src/command-registry.ts | 2 +- .../src/browser-artifacts.ts | 10 +++ .../src/editor-command-runners.ts | 72 ++++++++++++++++++- tests/editor-actions.test.ts | 27 ++++++- 4 files changed, 108 insertions(+), 3 deletions(-) diff --git a/packages/runtime-core/src/command-registry.ts b/packages/runtime-core/src/command-registry.ts index d0859334b..399ed9dba 100644 --- a/packages/runtime-core/src/command-registry.ts +++ b/packages/runtime-core/src/command-registry.ts @@ -1303,7 +1303,7 @@ export const commandRegistry = [ { 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 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 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.", 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 f8836ed49..7d22ec973 100644 --- a/packages/runtime-playground/src/browser-artifacts.ts +++ b/packages/runtime-playground/src/browser-artifacts.ts @@ -141,6 +141,7 @@ export interface BrowserArtifactSummary { editorValidity?: BrowserEditorValiditySummary editorValidateBlocks?: BrowserEditorValidateBlocksSummary editorReadiness?: BrowserEditorReadinessSummary + editorPresentation?: BrowserEditorPresentationSummary editorSave?: BrowserEditorSaveSummary editorCanvas?: BrowserEditorCanvasProbeSummary editorCapabilities?: { clipboard: "unsupported" } @@ -287,6 +288,15 @@ export interface BrowserEditorReadinessSummary { postType?: string } +export interface BrowserEditorPresentationSummary { + schema: "wp-codebox/editor-presentation/v1" + iframeCount: number + iframeStylesheetUrlCount: number + iframeStylesheetUrls: string[] + generatedPresentationIdentityCount: number + generatedPresentationIdentities: string[] +} + export interface BrowserEditorSaveSummary { schema: "wp-codebox/editor-save/v1" status: "saved" diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 8a24bdf25..23c7f15d5 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -3,7 +3,7 @@ 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, BrowserEditorReadinessSummary, BrowserEditorSaveSummary, BrowserEditorValidateBlocksSummary, BrowserEditorValiditySummary, BrowserProbeAuthSummary, BrowserProbeErrorRecord, BrowserProbeViewport, BrowserStepRecord } from "./browser-artifacts.js" +import type { BrowserArtifact, BrowserArtifactFiles, BrowserArtifactSummary, BrowserEditorCanvasProbeDiagnostic, BrowserEditorCanvasProbeSummary, BrowserEditorCanvasSelectorGroupSummary, BrowserEditorCanvasSelectorSummary, 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" @@ -580,6 +580,7 @@ export async function runEditorOpenCommand({ let editorValidity: EditorValidityArtifact | undefined let editorCanvasReadiness: BrowserEditorCanvasProbeSummary | undefined let editorReadiness: BrowserEditorReadinessSummary | undefined + let editorPresentation: BrowserEditorPresentationSummary | undefined let authSummary: BrowserProbeAuthSummary | undefined let pendingError: Error | undefined let artifact: BrowserArtifact | undefined @@ -639,6 +640,10 @@ export async function runEditorOpenCommand({ } } + if (editorReadiness) { + editorPresentation = await captureEditorPresentation(page) + } + if (capture.has("editor-state")) { editorState = await captureEditorState(page, target) await artifactSession.writeJson("editorState", "editor-state.json", editorState) @@ -653,6 +658,7 @@ export async function runEditorOpenCommand({ htmlSha256 = sha256(Buffer.from(html, "utf8")) } if (capture.has("screenshot")) { + await dismissWordPressOnboardingDialogs(page) await artifactSession.writeGenerated("screenshot", "editor-screenshot.png", async (path) => { if (editorCanvasReadiness?.ready && target.waitSelector) { const frame = await resolveEditorCanvasFrame(page, target.waitSelector) @@ -708,6 +714,7 @@ export async function runEditorOpenCommand({ ...(editorSummary ? { editor: editorSummary } : {}), ...(editorValidity ? { editorValidity: editorValidity.summary } : {}), ...(editorReadiness ? { editorReadiness } : {}), + ...(editorPresentation ? { editorPresentation } : {}), ...(editorCanvasReadiness ? { editorCanvas: editorCanvasReadiness } : {}), viewport, }, @@ -805,6 +812,69 @@ export async function waitForEditorOpenReadiness(page: import("playwright").Page return { editorReadiness, editorCanvasReadiness: probe.summary } } +interface EditorPresentationCapture { + iframeCount: number + stylesheetUrls: string[] + inlineStyleContents: string[] +} + +export function summarizeEditorPresentation(capture: EditorPresentationCapture): BrowserEditorPresentationSummary { + const iframeStylesheetUrls = [...new Set(capture.stylesheetUrls.map((url) => url.trim()).filter(Boolean))].sort() + const generatedPresentationIdentities = [...new Set( + capture.inlineStyleContents.flatMap((content) => [...content.matchAll(/blocks-engine-presentation:([a-f0-9]{64})/gi)].map((match) => match[1]!.toLowerCase())), + )].sort() + return { + schema: "wp-codebox/editor-presentation/v1", + iframeCount: capture.iframeCount, + iframeStylesheetUrlCount: iframeStylesheetUrls.length, + iframeStylesheetUrls, + generatedPresentationIdentityCount: generatedPresentationIdentities.length, + generatedPresentationIdentities, + } +} + +export async function captureEditorPresentation(page: import("playwright").Page): Promise { + const capture = await page.evaluate(() => { + let iframeCount = 0 + const stylesheetUrls: string[] = [] + const inlineStyleContents: string[] = [] + const iframes = Array.from(document.querySelectorAll("iframe")) + for (const iframe of iframes) { + const iframeDocument = iframe.contentDocument + if (!iframeDocument) continue + iframeCount += 1 + for (const stylesheet of Array.from(iframeDocument.querySelectorAll('link[rel~="stylesheet"]'))) { + if (stylesheet.href) stylesheetUrls.push(stylesheet.href) + } + for (const style of Array.from(iframeDocument.querySelectorAll("style"))) { + inlineStyleContents.push(style.textContent ?? "") + } + } + return { iframeCount, stylesheetUrls, inlineStyleContents } + }) as EditorPresentationCapture + return summarizeEditorPresentation(capture) +} + +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"]', + ".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() + } + } + } + }) +} + export function editorOpenArtifactError(stepCount: number, error: Error, artifact: BrowserArtifact): BrowserCommandArtifactError { return new BrowserCommandArtifactError(`wordpress.editor-open failed after ${stepCount} step(s): ${error.message}`, artifact) } diff --git a/tests/editor-actions.test.ts b/tests/editor-actions.test.ts index 1d2c726e6..edd26b3db 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, type EditorStateSnapshot, waitForEditorOpenReadiness } from "../packages/runtime-playground/src/editor-command-runners.js" +import { assertEditorMutationPostcondition, 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" @@ -68,6 +68,31 @@ const unavailableEditorState = await captureEditorState({ } as never, target) assert.equal(unavailableEditorState.storesAvailable, false) +const styledPresentation = summarizeEditorPresentation({ + iframeCount: 2, + stylesheetUrls: ["https://example.test/styles/editor.css", "https://example.test/styles/editor.css", "https://example.test/styles/theme.css"], + inlineStyleContents: [ + "/* blocks-engine-presentation:ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789 */", + "/* blocks-engine-presentation:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 */", + ], +}) +assert.deepEqual(styledPresentation, { + schema: "wp-codebox/editor-presentation/v1", + iframeCount: 2, + iframeStylesheetUrlCount: 2, + iframeStylesheetUrls: ["https://example.test/styles/editor.css", "https://example.test/styles/theme.css"], + generatedPresentationIdentityCount: 1, + generatedPresentationIdentities: ["abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"], +}) +assert.deepEqual(summarizeEditorPresentation({ iframeCount: 1, stylesheetUrls: [], inlineStyleContents: [".editor { color: black; }"] }), { + schema: "wp-codebox/editor-presentation/v1", + iframeCount: 1, + iframeStylesheetUrlCount: 0, + iframeStylesheetUrls: [], + generatedPresentationIdentityCount: 0, + generatedPresentationIdentities: [], +}) + // 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 0b33cbc388cfee6884079125fc06dd132d979cbc Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 14 Aug 2026 03:41:02 -0400 Subject: [PATCH 2/6] fix: harden editor presentation capture --- .../src/editor-command-runners.ts | 85 ++++++++++++++----- tests/browser-routed-command-security.test.ts | 68 ++++++++++++++- 2 files changed, 127 insertions(+), 26 deletions(-) diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 23c7f15d5..528115266 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -22,6 +22,11 @@ const EDITOR_CANVAS_DEFAULT_IFRAME_SELECTOR = 'iframe[name="editor-canvas"]' const EDITOR_CANVAS_DEFAULT_LAYOUT_SELECTOR = ".block-editor-block-list__layout" const EDITOR_CANVAS_DEFAULT_BLOCK_SELECTOR = ".block-editor-block-list__block, [data-block]" const EDITOR_CANVAS_DEFAULT_TIMEOUT_MS = 30_000 +const EDITOR_PRESENTATION_SETTLE_MS = 250 +const EDITOR_PRESENTATION_MIN_OBSERVATION_MS = 1_000 +const EDITOR_PRESENTATION_POLL_MS = 50 +const EDITOR_PRESENTATION_IFRAME_DISCOVERY_MS = 1_000 +const EDITOR_PRESENTATION_MAX_CAPTURE_MS = 3_000 const EDITOR_VALIDITY_WARNING_SELECTORS = [ ".block-editor-warning", ".block-editor-block-list__block.is-invalid", @@ -350,11 +355,7 @@ async function waitForEditorCanvasProbe(page: import("playwright").Page, options } async function resolveEditorCanvasFrame(page: import("playwright").Page, iframeSelector: string): Promise { - const namedFrame = page.frame({ name: "editor-canvas" }) - if (namedFrame) { - return namedFrame - } - const handle = await page.locator(iframeSelector).elementHandle().catch(() => null) + const handle = await page.locator(iframeSelector).first().elementHandle().catch(() => null) return handle ? await handle.contentFrame() : null } @@ -641,7 +642,7 @@ export async function runEditorOpenCommand({ } if (editorReadiness) { - editorPresentation = await captureEditorPresentation(page) + editorPresentation = await captureEditorPresentation(page, waitTimeoutMs) } if (capture.has("editor-state")) { @@ -833,26 +834,64 @@ export function summarizeEditorPresentation(capture: EditorPresentationCapture): } } -export async function captureEditorPresentation(page: import("playwright").Page): Promise { - const capture = await page.evaluate(() => { - let iframeCount = 0 - const stylesheetUrls: string[] = [] - const inlineStyleContents: string[] = [] - const iframes = Array.from(document.querySelectorAll("iframe")) - for (const iframe of iframes) { - const iframeDocument = iframe.contentDocument - if (!iframeDocument) continue - iframeCount += 1 - for (const stylesheet of Array.from(iframeDocument.querySelectorAll('link[rel~="stylesheet"]'))) { - if (stylesheet.href) stylesheetUrls.push(stylesheet.href) +export async function captureEditorPresentation(page: import("playwright").Page, timeoutMs: number): Promise { + const startedAtMs = Date.now() + const deadlineMs = startedAtMs + Math.min(timeoutMs, EDITOR_PRESENTATION_MAX_CAPTURE_MS) + let previousFingerprint: string | undefined + let stableSinceMs: number | undefined + let sawCanvas = false + + while (Date.now() <= deadlineMs) { + const frame = await resolveEditorCanvasFrame(page, EDITOR_CANVAS_DEFAULT_IFRAME_SELECTOR) + if (!frame) { + // Canvas presentation is additive evidence. A visible parent-document + // canvas proves this editor does not use the iframe presentation path; + // otherwise keep waiting because stores can become ready before the + // editor-canvas iframe is inserted. + const hasParentDocumentCanvas = await page.locator(EDITOR_CANVAS_DEFAULT_LAYOUT_SELECTOR).first().isVisible().catch(() => false) + if (!sawCanvas && hasParentDocumentCanvas && Date.now() - startedAtMs >= EDITOR_PRESENTATION_IFRAME_DISCOVERY_MS) return undefined + previousFingerprint = undefined + stableSinceMs = undefined + await page.waitForTimeout(EDITOR_PRESENTATION_POLL_MS) + continue + } + + sawCanvas = true + const capture = await frame.evaluate(() => { + if (document.readyState !== "complete" || document.fonts?.status === "loading") return null + const stylesheets = Array.from(document.querySelectorAll('link[rel~="stylesheet"]')) + if (stylesheets.some((stylesheet) => !stylesheet.disabled && !stylesheet.sheet)) return null + return { + documentIdentity: `${location.href}\n${performance.timeOrigin}`, + documentAgeMs: performance.now(), + iframeCount: 1, + stylesheetUrls: stylesheets.flatMap((stylesheet) => stylesheet.href ? [stylesheet.href] : []), + inlineStyleContents: Array.from(document.querySelectorAll("style"), (style) => style.textContent ?? ""), } - for (const style of Array.from(iframeDocument.querySelectorAll("style"))) { - inlineStyleContents.push(style.textContent ?? "") + }).catch(() => null) as (EditorPresentationCapture & { documentIdentity: string; documentAgeMs: number }) | null + if (capture) { + const summary = summarizeEditorPresentation(capture) + const fingerprint = `${capture.documentIdentity}\n${JSON.stringify(summary)}` + const observedAtMs = Date.now() + if (fingerprint === previousFingerprint) { + const currentDocumentIdentity = await resolveEditorCanvasFrame(page, EDITOR_CANVAS_DEFAULT_IFRAME_SELECTOR) + .then(async (currentFrame) => currentFrame === frame ? await currentFrame.evaluate(() => `${location.href}\n${performance.timeOrigin}`) : undefined) + .catch(() => undefined) + if (stableSinceMs !== undefined + && observedAtMs - stableSinceMs >= EDITOR_PRESENTATION_SETTLE_MS + && capture.documentAgeMs >= EDITOR_PRESENTATION_MIN_OBSERVATION_MS + && currentDocumentIdentity === capture.documentIdentity) { + return summary + } + } else { + previousFingerprint = fingerprint + stableSinceMs = observedAtMs } } - return { iframeCount, stylesheetUrls, inlineStyleContents } - }) as EditorPresentationCapture - return summarizeEditorPresentation(capture) + await page.waitForTimeout(EDITOR_PRESENTATION_POLL_MS) + } + + return undefined } export async function dismissWordPressOnboardingDialogs(page: import("playwright").Page): Promise { diff --git a/tests/browser-routed-command-security.test.ts b/tests/browser-routed-command-security.test.ts index c74117cf3..549ca5c98 100644 --- a/tests/browser-routed-command-security.test.ts +++ b/tests/browser-routed-command-security.test.ts @@ -15,7 +15,12 @@ import { withTempDir } from "../scripts/test-kit.js" const TOKEN = "SENTINEL_ROUTED_COMMAND_TOKEN_2094" const PUBLIC_URL = `http://routed.test/editor?token=${TOKEN}` -const editorHtml = `
Editor fixture
` +
Editor fixture
` +const editorHtml = `${editorShell}` +const noCanvasEditorHtml = `${editorShell}
Block
` +const replacingCanvasEditorHtml = `${editorShell}` +const delayedCanvasEditorHtml = `${editorShell}
Transition
` test("real browser commands sanitize console, artifacts, stdout, and failure stderr", async () => { const httpServer = createServer((request, response) => { response.setHeader("content-type", "text/html") - response.end(request.url?.startsWith("/broken") ? "
Broken editor fixture
" : editorHtml) + response.end(request.url?.startsWith("/broken") + ? "
Broken editor fixture
" + : request.url?.startsWith("/no-canvas") + ? noCanvasEditorHtml + : request.url?.startsWith("/replacing-canvas") + ? replacingCanvasEditorHtml + : request.url?.startsWith("/delayed-canvas") + ? delayedCanvasEditorHtml + : editorHtml) }) const serverUrl = await listenLocalHttpServer(httpServer) const server = { serverUrl, playground: {} } as PlaygroundCliServer @@ -74,6 +91,51 @@ test("real browser commands sanitize console, artifacts, stdout, and failure std spec: { command: "wordpress.editor-open", args: [`url=${PUBLIC_URL}`, "route-host=routed.test", "capture=steps,errors,console", "wait-timeout=5s"] }, }) await assertCommandSurfacesSafe(result, artifactRoot, ["files/browser/editor-summary.json", "files/browser/editor-steps.jsonl", "files/browser/editor-console.jsonl"]) + const output = JSON.parse(result.output) as { summary: { editorPresentation: { iframeCount: number; generatedPresentationIdentities: string[] } } } + assert.deepEqual(output.summary.editorPresentation, { + schema: "wp-codebox/editor-presentation/v1", + iframeCount: 1, + iframeStylesheetUrlCount: 0, + iframeStylesheetUrls: [], + generatedPresentationIdentityCount: 1, + generatedPresentationIdentities: [CANVAS_PRESENTATION_IDENTITY], + }) + }) + + await withTempDir("wp-codebox-real-editor-no-canvas-security-", async (artifactRoot) => { + const result = await runEditorOpenCommand({ + artifactRoot, + runPlaygroundCommand, + runtimeSpec, + server, + spec: { command: "wordpress.editor-open", args: [`url=http://routed.test/no-canvas?token=${TOKEN}`, "route-host=routed.test", "capture=steps", "wait-timeout=5s"] }, + }) + const output = JSON.parse(result.output) as { summary: { editorPresentation?: unknown } } + assert.equal(output.summary.editorPresentation, undefined) + }) + + await withTempDir("wp-codebox-real-editor-replacing-canvas-security-", async (artifactRoot) => { + const result = await runEditorOpenCommand({ + artifactRoot, + runPlaygroundCommand, + runtimeSpec, + server, + spec: { command: "wordpress.editor-open", args: [`url=http://routed.test/replacing-canvas?token=${TOKEN}`, "route-host=routed.test", "capture=steps", "wait-timeout=5s"] }, + }) + const output = JSON.parse(result.output) as { summary: { editorPresentation: { generatedPresentationIdentities: string[] } } } + assert.deepEqual(output.summary.editorPresentation.generatedPresentationIdentities, [REPLACED_CANVAS_PRESENTATION_IDENTITY]) + }) + + await withTempDir("wp-codebox-real-editor-delayed-canvas-security-", async (artifactRoot) => { + const result = await runEditorOpenCommand({ + artifactRoot, + runPlaygroundCommand, + runtimeSpec, + server, + spec: { command: "wordpress.editor-open", args: [`url=http://routed.test/delayed-canvas?token=${TOKEN}`, "route-host=routed.test", "capture=steps", "wait-timeout=5s"] }, + }) + const output = JSON.parse(result.output) as { summary: { editorPresentation: { generatedPresentationIdentities: string[] } } } + assert.deepEqual(output.summary.editorPresentation.generatedPresentationIdentities, [DELAYED_CANVAS_PRESENTATION_IDENTITY]) }) await withTempDir("wp-codebox-real-editor-canvas-security-", async (artifactRoot) => { From 1eb5bb0460c4321a7613cebe581f1d37fd8e640f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 14 Aug 2026 04:41:11 -0400 Subject: [PATCH 3/6] fix: preserve loaded editor validation provenance --- .../src/editor-command-runners.ts | 13 +++++++++++-- tests/editor-validate-blocks.test.ts | 6 ++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 528115266..3e624055e 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -1922,6 +1922,8 @@ export interface EditorValidateBlocksResult { invalid_blocks: number validation_method: "wp.blocks.validateBlock" validation_provider: string + content_source: "argument" | "edited-post-content" + block_types_registered: number results: BlockValidationResult[] } @@ -1956,7 +1958,7 @@ export function flattenBlockValidationNodes(nodes: BlockValidationNode[]): Block return results } -export function summarizeBlockValidation(input: { nodes: BlockValidationNode[]; validationProvider: string }): EditorValidateBlocksResult { +export function summarizeBlockValidation(input: { nodes: BlockValidationNode[]; validationProvider: string; contentSource: "argument" | "edited-post-content"; blockTypesRegistered: number }): EditorValidateBlocksResult { const results = flattenBlockValidationNodes(input.nodes) const validBlocks = results.filter((result) => result.isValid).length return { @@ -1965,6 +1967,8 @@ export function summarizeBlockValidation(input: { nodes: BlockValidationNode[]; invalid_blocks: results.length - validBlocks, validation_method: "wp.blocks.validateBlock", validation_provider: input.validationProvider, + content_source: input.contentSource, + block_types_registered: input.blockTypesRegistered, results, } } @@ -1972,7 +1976,12 @@ export function summarizeBlockValidation(input: { nodes: BlockValidationNode[]; export async function validateEditorBlocks(page: import("playwright").Page, options: { content?: string; provider: string }): Promise { const evaluation = await evaluateEditorBlockValidation(page, options) return { - result: summarizeBlockValidation({ nodes: evaluation.nodes, validationProvider: evaluation.validationProvider }), + result: summarizeBlockValidation({ + nodes: evaluation.nodes, + validationProvider: evaluation.validationProvider, + contentSource: evaluation.contentSource, + blockTypesRegistered: evaluation.blockTypesRegistered, + }), contentSource: evaluation.contentSource, blockTypesRegistered: evaluation.blockTypesRegistered, } diff --git a/tests/editor-validate-blocks.test.ts b/tests/editor-validate-blocks.test.ts index ef24fb5b0..e1357438e 100644 --- a/tests/editor-validate-blocks.test.ts +++ b/tests/editor-validate-blocks.test.ts @@ -28,6 +28,8 @@ assert.deepEqual(flattened.map((entry) => entry.name), ["core/columns", "core/co // A post with valid blocks: every block isValid, zero invalid. const validResult = summarizeBlockValidation({ validationProvider: "wordpress-block-editor", + contentSource: "edited-post-content", + blockTypesRegistered: 42, nodes: [ { name: "core/heading", isValid: true, issues: [] }, { name: "core/paragraph", isValid: true, issues: [], innerBlocks: [] }, @@ -43,6 +45,8 @@ assert.ok(validResult.results.every((entry) => entry.isValid === true)) // A post with a deliberately corrupted (nested) block: counted invalid, keeps name + issues. const corruptedResult = summarizeBlockValidation({ validationProvider: "wordpress-block-editor", + contentSource: "edited-post-content", + blockTypesRegistered: 42, nodes: [ { name: "core/group", isValid: true, issues: [], innerBlocks: [ { name: "core/paragraph", isValid: false, issues: ["Block validation failed: expected

but found

"] }, @@ -75,6 +79,8 @@ assert.equal(evaluated.result.validation_method, "wp.blocks.validateBlock") assert.equal(evaluated.result.total_blocks, 2) assert.equal(evaluated.result.valid_blocks, 1) assert.equal(evaluated.result.invalid_blocks, 1) +assert.equal(evaluated.result.content_source, "argument") +assert.equal(evaluated.result.block_types_registered, 42) assert.equal(evaluated.contentSource, "argument") assert.equal(evaluated.blockTypesRegistered, 42) From 6f12c49111344b54ef62b9dbc1abdfe32a26103f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 14 Aug 2026 07:37:16 -0400 Subject: [PATCH 4/6] fix: capture non-iframed editor presentation --- .../src/browser-artifacts.ts | 1 + .../src/editor-command-runners.ts | 45 ++++++++++++------- tests/browser-routed-command-security.test.ts | 18 +++++--- tests/editor-actions.test.ts | 7 ++- 4 files changed, 45 insertions(+), 26 deletions(-) diff --git a/packages/runtime-playground/src/browser-artifacts.ts b/packages/runtime-playground/src/browser-artifacts.ts index 7d22ec973..da68b2354 100644 --- a/packages/runtime-playground/src/browser-artifacts.ts +++ b/packages/runtime-playground/src/browser-artifacts.ts @@ -290,6 +290,7 @@ export interface BrowserEditorReadinessSummary { export interface BrowserEditorPresentationSummary { schema: "wp-codebox/editor-presentation/v1" + canvasDocumentType: "iframe" | "parent" iframeCount: number iframeStylesheetUrlCount: number iframeStylesheetUrls: string[] diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 3e624055e..5f0226444 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -355,7 +355,9 @@ async function waitForEditorCanvasProbe(page: import("playwright").Page, options } async function resolveEditorCanvasFrame(page: import("playwright").Page, iframeSelector: string): Promise { - const handle = await page.locator(iframeSelector).first().elementHandle().catch(() => null) + const locator = page.locator(iframeSelector).first() + if (await locator.count() === 0) return null + const handle = await locator.elementHandle().catch(() => null) return handle ? await handle.contentFrame() : null } @@ -814,6 +816,7 @@ export async function waitForEditorOpenReadiness(page: import("playwright").Page } interface EditorPresentationCapture { + canvasDocumentType: "iframe" | "parent" iframeCount: number stylesheetUrls: string[] inlineStyleContents: string[] @@ -826,6 +829,7 @@ export function summarizeEditorPresentation(capture: EditorPresentationCapture): )].sort() return { schema: "wp-codebox/editor-presentation/v1", + canvasDocumentType: capture.canvasDocumentType, iframeCount: capture.iframeCount, iframeStylesheetUrlCount: iframeStylesheetUrls.length, iframeStylesheetUrls, @@ -843,40 +847,47 @@ export async function captureEditorPresentation(page: import("playwright").Page, while (Date.now() <= deadlineMs) { const frame = await resolveEditorCanvasFrame(page, EDITOR_CANVAS_DEFAULT_IFRAME_SELECTOR) + let canvas: import("playwright").Page | import("playwright").Frame | undefined = frame ?? undefined + let canvasDocumentType: "iframe" | "parent" = "iframe" if (!frame) { - // Canvas presentation is additive evidence. A visible parent-document - // canvas proves this editor does not use the iframe presentation path; - // otherwise keep waiting because stores can become ready before the - // editor-canvas iframe is inserted. const hasParentDocumentCanvas = await page.locator(EDITOR_CANVAS_DEFAULT_LAYOUT_SELECTOR).first().isVisible().catch(() => false) - if (!sawCanvas && hasParentDocumentCanvas && Date.now() - startedAtMs >= EDITOR_PRESENTATION_IFRAME_DISCOVERY_MS) return undefined - previousFingerprint = undefined - stableSinceMs = undefined - await page.waitForTimeout(EDITOR_PRESENTATION_POLL_MS) - continue + if (sawCanvas || !hasParentDocumentCanvas || Date.now() - startedAtMs < EDITOR_PRESENTATION_IFRAME_DISCOVERY_MS) { + previousFingerprint = undefined + stableSinceMs = undefined + await page.waitForTimeout(EDITOR_PRESENTATION_POLL_MS) + continue + } + canvas = page + canvasDocumentType = "parent" } - sawCanvas = true - const capture = await frame.evaluate(() => { + if (!canvas) continue + if (frame) sawCanvas = true + const capture = await canvas.evaluate(({ canvasDocumentType, iframeCount }) => { if (document.readyState !== "complete" || document.fonts?.status === "loading") return null const stylesheets = Array.from(document.querySelectorAll('link[rel~="stylesheet"]')) if (stylesheets.some((stylesheet) => !stylesheet.disabled && !stylesheet.sheet)) return null return { documentIdentity: `${location.href}\n${performance.timeOrigin}`, documentAgeMs: performance.now(), - iframeCount: 1, + canvasDocumentType, + iframeCount, stylesheetUrls: stylesheets.flatMap((stylesheet) => stylesheet.href ? [stylesheet.href] : []), inlineStyleContents: Array.from(document.querySelectorAll("style"), (style) => style.textContent ?? ""), } - }).catch(() => null) as (EditorPresentationCapture & { documentIdentity: string; documentAgeMs: number }) | null + }, { canvasDocumentType, iframeCount: canvasDocumentType === "iframe" ? 1 : 0 }).catch(() => null) as (EditorPresentationCapture & { documentIdentity: string; documentAgeMs: number }) | null if (capture) { const summary = summarizeEditorPresentation(capture) const fingerprint = `${capture.documentIdentity}\n${JSON.stringify(summary)}` const observedAtMs = Date.now() if (fingerprint === previousFingerprint) { - const currentDocumentIdentity = await resolveEditorCanvasFrame(page, EDITOR_CANVAS_DEFAULT_IFRAME_SELECTOR) - .then(async (currentFrame) => currentFrame === frame ? await currentFrame.evaluate(() => `${location.href}\n${performance.timeOrigin}`) : undefined) - .catch(() => undefined) + const currentDocumentIdentity = capture.canvasDocumentType === "iframe" + ? await resolveEditorCanvasFrame(page, EDITOR_CANVAS_DEFAULT_IFRAME_SELECTOR) + .then(async (currentFrame) => currentFrame && currentFrame === frame ? await currentFrame.evaluate(() => `${location.href}\n${performance.timeOrigin}`) : undefined) + .catch(() => undefined) + : await resolveEditorCanvasFrame(page, EDITOR_CANVAS_DEFAULT_IFRAME_SELECTOR) + .then(async (currentFrame) => currentFrame ? undefined : await page.evaluate(() => `${location.href}\n${performance.timeOrigin}`)) + .catch(() => undefined) if (stableSinceMs !== undefined && observedAtMs - stableSinceMs >= EDITOR_PRESENTATION_SETTLE_MS && capture.documentAgeMs >= EDITOR_PRESENTATION_MIN_OBSERVATION_MS diff --git a/tests/browser-routed-command-security.test.ts b/tests/browser-routed-command-security.test.ts index 549ca5c98..6dbabad99 100644 --- a/tests/browser-routed-command-security.test.ts +++ b/tests/browser-routed-command-security.test.ts @@ -20,6 +20,7 @@ const UNRELATED_PRESENTATION_IDENTITY = "b".repeat(64) const INITIAL_CANVAS_PRESENTATION_IDENTITY = "c".repeat(64) 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 editorShell = `
Editor fixture
` const editorHtml = `${editorShell}` -const noCanvasEditorHtml = `${editorShell}
Block
` +const parentCanvasEditorHtml = `${editorShell}
Block
` const replacingCanvasEditorHtml = `${editorShell}` const delayedCanvasEditorHtml = `${editorShell}
Transition
` @@ -46,8 +47,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("/no-canvas") - ? noCanvasEditorHtml + : request.url?.startsWith("/parent-canvas") + ? parentCanvasEditorHtml : request.url?.startsWith("/replacing-canvas") ? replacingCanvasEditorHtml : request.url?.startsWith("/delayed-canvas") @@ -94,6 +95,7 @@ test("real browser commands sanitize console, artifacts, stdout, and failure std const output = JSON.parse(result.output) as { summary: { editorPresentation: { iframeCount: number; generatedPresentationIdentities: string[] } } } assert.deepEqual(output.summary.editorPresentation, { schema: "wp-codebox/editor-presentation/v1", + canvasDocumentType: "iframe", iframeCount: 1, iframeStylesheetUrlCount: 0, iframeStylesheetUrls: [], @@ -102,16 +104,18 @@ test("real browser commands sanitize console, artifacts, stdout, and failure std }) }) - await withTempDir("wp-codebox-real-editor-no-canvas-security-", async (artifactRoot) => { + await withTempDir("wp-codebox-real-editor-parent-canvas-security-", async (artifactRoot) => { const result = await runEditorOpenCommand({ artifactRoot, runPlaygroundCommand, runtimeSpec, server, - spec: { command: "wordpress.editor-open", args: [`url=http://routed.test/no-canvas?token=${TOKEN}`, "route-host=routed.test", "capture=steps", "wait-timeout=5s"] }, + spec: { command: "wordpress.editor-open", args: [`url=http://routed.test/parent-canvas?token=${TOKEN}`, "route-host=routed.test", "capture=steps", "wait-timeout=5s"] }, }) - const output = JSON.parse(result.output) as { summary: { editorPresentation?: unknown } } - assert.equal(output.summary.editorPresentation, undefined) + const output = JSON.parse(result.output) as { summary: { editorPresentation: { canvasDocumentType: string; iframeCount: number; generatedPresentationIdentities: string[] } } } + assert.equal(output.summary.editorPresentation.canvasDocumentType, "parent") + assert.equal(output.summary.editorPresentation.iframeCount, 0) + assert.deepEqual(output.summary.editorPresentation.generatedPresentationIdentities, [PARENT_CANVAS_PRESENTATION_IDENTITY]) }) await withTempDir("wp-codebox-real-editor-replacing-canvas-security-", async (artifactRoot) => { diff --git a/tests/editor-actions.test.ts b/tests/editor-actions.test.ts index edd26b3db..446c038db 100644 --- a/tests/editor-actions.test.ts +++ b/tests/editor-actions.test.ts @@ -69,6 +69,7 @@ const unavailableEditorState = await captureEditorState({ assert.equal(unavailableEditorState.storesAvailable, false) const styledPresentation = summarizeEditorPresentation({ + canvasDocumentType: "iframe", iframeCount: 2, stylesheetUrls: ["https://example.test/styles/editor.css", "https://example.test/styles/editor.css", "https://example.test/styles/theme.css"], inlineStyleContents: [ @@ -78,15 +79,17 @@ const styledPresentation = summarizeEditorPresentation({ }) assert.deepEqual(styledPresentation, { schema: "wp-codebox/editor-presentation/v1", + canvasDocumentType: "iframe", iframeCount: 2, iframeStylesheetUrlCount: 2, iframeStylesheetUrls: ["https://example.test/styles/editor.css", "https://example.test/styles/theme.css"], generatedPresentationIdentityCount: 1, generatedPresentationIdentities: ["abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"], }) -assert.deepEqual(summarizeEditorPresentation({ iframeCount: 1, stylesheetUrls: [], inlineStyleContents: [".editor { color: black; }"] }), { +assert.deepEqual(summarizeEditorPresentation({ canvasDocumentType: "parent", iframeCount: 0, stylesheetUrls: [], inlineStyleContents: [".editor { color: black; }"] }), { schema: "wp-codebox/editor-presentation/v1", - iframeCount: 1, + canvasDocumentType: "parent", + iframeCount: 0, iframeStylesheetUrlCount: 0, iframeStylesheetUrls: [], generatedPresentationIdentityCount: 0, From c2d8a10e4fcb11c2dc5efe028650683692523eed Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 14 Aug 2026 07:55:14 -0400 Subject: [PATCH 5/6] fix: collect artifacts from canonical mount paths --- .github/workflows/agent-task-contracts.yml | 1 + package.json | 1 + .../src/commands/recipe-declared-artifacts.ts | 16 ++++--- packages/cli/src/commands/recipe-run.ts | 6 +-- tests/recipe-declared-artifacts.test.ts | 45 +++++++++++++++++++ 5 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 tests/recipe-declared-artifacts.test.ts diff --git a/.github/workflows/agent-task-contracts.yml b/.github/workflows/agent-task-contracts.yml index 145534af1..1883a5b44 100644 --- a/.github/workflows/agent-task-contracts.yml +++ b/.github/workflows/agent-task-contracts.yml @@ -138,6 +138,7 @@ jobs: - run: npm run test:native-agent-task-interruption - run: npm run test:trusted-apply-artifact-channel - run: npm run test:runtime-command-artifact-bounds + - run: npm run test:recipe-declared-artifacts - run: npm run test:redaction - run: npm run test:browser-preview-routing - run: npm run test:browser-routed-command-security diff --git a/package.json b/package.json index 5af12b675..67096ed77 100644 --- a/package.json +++ b/package.json @@ -270,6 +270,7 @@ "test:recipe-secret-env": "tsx tests/recipe-secret-env.test.ts", "test:preview-options": "tsx tests/preview-options.test.ts", "test:evidence-bundle-digest-and-recipe-artifact": "tsx tests/evidence-bundle-digest-and-recipe-artifact.test.ts", + "test:recipe-declared-artifacts": "tsx tests/recipe-declared-artifacts.test.ts", "test:runtime-overlay-descriptors": "tsx tests/runtime-overlay-descriptors.test.ts", "test:composer-package-overlay-revision": "tsx scripts/composer-backed-source-hydration-smoke.ts", "test:composer-package-overlay-autoload-layout": "tsx scripts/composer-package-overlay-autoload-layout-smoke.ts", diff --git a/packages/cli/src/commands/recipe-declared-artifacts.ts b/packages/cli/src/commands/recipe-declared-artifacts.ts index a9d73e865..f9e5ae4e1 100644 --- a/packages/cli/src/commands/recipe-declared-artifacts.ts +++ b/packages/cli/src/commands/recipe-declared-artifacts.ts @@ -2,28 +2,30 @@ import { Buffer } from "node:buffer" import { DEFAULT_CAPTURED_ARTIFACT_MAX_BYTES, STRUCTURED_ARTIFACT_SCHEMA, TYPED_ARTIFACT_INDEX_SCHEMA, materializeStructuredArtifactFiles, redactJsonValue, workspaceRecipeRuntimeCollectedArtifacts, type ArtifactBundle, type Runtime, type StructuredArtifactPayload, type TypedArtifactRef, type WorkspaceRecipe, type WorkspaceRecipeDeclaredArtifact, type WorkspaceRecipeTypedArtifact } from "@automattic/wp-codebox-core" import { stripUndefined } from "@automattic/wp-codebox-core/internals" import { appendRecipeRuntimeEvidenceFiles } from "../recipe-evidence.js" +import { rewriteInputMountPath, type InputMountPathMapping } from "../input-mount-paths.js" import { serializeRecipeRunError, RecipeDeclaredArtifactFailureError, RecipeProbeFailureError } from "./recipe-run-output.js" import type { RecipeRunDeclaredArtifact, RecipeRunDistributionSetupArtifact, RecipeRunDistributionStartupProbe, RecipeRunFixtureDatabase, RecipeRunProbe } from "./recipe-run-types.js" const DECLARED_ARTIFACT_CAPTURE_MAX_BYTES = DEFAULT_CAPTURED_ARTIFACT_MAX_BYTES const declaredArtifactContents = new WeakMap() -export async function collectRecipeDeclaredArtifacts(recipe: WorkspaceRecipe, runtime: Runtime): Promise { +export async function collectRecipeDeclaredArtifacts(recipe: WorkspaceRecipe, runtime: Runtime, inputMountPathMap: readonly InputMountPathMapping[] = []): Promise { const results: RecipeRunDeclaredArtifact[] = [] for (const { kind, index, artifact } of workspaceRecipeRuntimeCollectedArtifacts(recipe)) { + const effectivePath = rewriteInputMountPath(artifact.path, inputMountPathMap) results.push(kind === "typed" - ? await collectRecipeTypedArtifact(runtime, artifact, index) - : await collectRecipeDeclaredArtifact(runtime, artifact, index)) + ? await collectRecipeTypedArtifact(runtime, artifact, index, effectivePath) + : await collectRecipeDeclaredArtifact(runtime, artifact, index, effectivePath)) } return results } -async function collectRecipeDeclaredArtifact(runtime: Runtime, artifact: WorkspaceRecipeDeclaredArtifact, index: number): Promise { +async function collectRecipeDeclaredArtifact(runtime: Runtime, artifact: WorkspaceRecipeDeclaredArtifact, index: number, effectivePath: string): Promise { const required = artifact.required !== false try { const execution = await runtime.execute({ command: "wordpress.run-php", - args: [`code=${declaredArtifactReadCode(artifact.path, artifact.parseJson === true, false)}`], + args: [`code=${declaredArtifactReadCode(effectivePath, artifact.parseJson === true, false)}`], }) const collected = JSON.parse(execution.stdout.trim() || "{}") as Record const exists = collected.exists === true @@ -57,11 +59,11 @@ async function collectRecipeDeclaredArtifact(runtime: Runtime, artifact: Workspa } } -async function collectRecipeTypedArtifact(runtime: Runtime, artifact: WorkspaceRecipeTypedArtifact, index: number): Promise { +async function collectRecipeTypedArtifact(runtime: Runtime, artifact: WorkspaceRecipeTypedArtifact, index: number, effectivePath: string): Promise { try { const execution = await runtime.execute({ command: "wordpress.run-php", - args: [`code=${declaredArtifactReadCode(artifact.path, artifact.parseJson === true, true)}`], + args: [`code=${declaredArtifactReadCode(effectivePath, artifact.parseJson === true, true)}`], }) const collected = JSON.parse(execution.stdout.trim() || "{}") as Record const exists = collected.exists === true diff --git a/packages/cli/src/commands/recipe-run.ts b/packages/cli/src/commands/recipe-run.ts index a2d414f54..6f9283c30 100644 --- a/packages/cli/src/commands/recipe-run.ts +++ b/packages/cli/src/commands/recipe-run.ts @@ -359,7 +359,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe } let evidence = await phaseTracker.run("collect_artifacts", { includeLogs: true, includeObservations: true }, async () => { - declaredArtifacts = await awaitRecipe("recipe-artifacts.collect", collectRecipeDeclaredArtifacts(recipe, runtime!)) + declaredArtifacts = await awaitRecipe("recipe-artifacts.collect", collectRecipeDeclaredArtifacts(recipe, runtime!, inputMountPathMap)) const declaredArtifactFailure = recipeDeclaredArtifactFailure(declaredArtifacts) await awaitRecipe("runtime.observe:runtime-info", runtime!.observe({ type: "runtime-info" })) await awaitRecipe("runtime.observe:mounts", runtime!.observe({ type: "mounts" })) @@ -391,7 +391,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe artifacts = await awaitRecipe("runtime.collect-artifacts.preview-hold", collectRecipeRuntimeArtifacts(runtime, { includeLogs: true, includeObservations: true, previewHoldSeconds: options.previewHoldSeconds }, { snapshotTimeoutMs: SUCCESSFUL_RECIPE_RUNTIME_SNAPSHOT_TIMEOUT_MS, activeExecution: executions.at(-1) })) browserEvidence = await recipeBrowserEvidence(artifacts, executions, recipe) await artifactPointer.update({ runtime: await runtime.info(), artifacts, phases: phaseTracker.list(), browserEvidence }) - declaredArtifacts = await collectRecipeDeclaredArtifacts(recipe, runtime) + declaredArtifacts = await collectRecipeDeclaredArtifacts(recipe, runtime, inputMountPathMap) await materializeTypedRecipeDeclaredArtifacts(artifacts, declaredArtifacts) await appendRecipeRuntimeEvidence(artifacts, recipeRuntimeEvidenceFiles(fixtureDatabases, distributionSetupArtifacts, distributionStartupProbes, probes, declaredArtifacts)) evidence = await finalizeRecipeArtifactEvidence(artifacts, recipe, workspaceMounts, stagedFiles, effectivePolicy, secretEnvSummary) @@ -520,7 +520,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe await artifactPointer.update({ runtime: await activeRuntime.info(), artifacts, phases: phaseTracker.list(), browserEvidence }) try { if (declaredArtifacts.length === 0) { - declaredArtifacts = await collectRecipeDeclaredArtifacts(recipe, activeRuntime) + declaredArtifacts = await collectRecipeDeclaredArtifacts(recipe, activeRuntime, inputMountPathMap) } await materializeTypedRecipeDeclaredArtifacts(artifacts, declaredArtifacts) const evidenceFiles = await appendRecipeRuntimeEvidence(artifacts, [ diff --git a/tests/recipe-declared-artifacts.test.ts b/tests/recipe-declared-artifacts.test.ts new file mode 100644 index 000000000..8aae07b98 --- /dev/null +++ b/tests/recipe-declared-artifacts.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict" + +import type { Runtime, WorkspaceRecipe } from "../packages/runtime-core/src/index.js" +import { collectRecipeDeclaredArtifacts } from "../packages/cli/src/commands/recipe-declared-artifacts.js" +import { recipeInputMountPathMap } from "../packages/cli/src/input-mount-paths.js" + +const declaredPath = "/home/wpcom/public_html/evidence/receipt.json" +const payload = { schema: "example/receipt/v1", status: "completed" } +const recipe: WorkspaceRecipe = { + schema: "wp-codebox/workspace-recipe/v1", + inputs: { + mounts: [{ source: "site", target: "/home/wpcom/public_html", mode: "readwrite" }], + }, + workflow: { steps: [] }, + artifacts: { + typed: [{ name: "receipt", type: "example/receipt", path: declaredPath, parseJson: true }], + }, +} +const mappings = recipeInputMountPathMap(recipe) +const effectivePath = `${mappings[0]!.canonicalTarget}/evidence/receipt.json` +let executedCode = "" +const runtime = { + execute: async (spec: { args?: string[] }) => { + executedCode = spec.args?.[0] ?? "" + return { + stdout: JSON.stringify({ + exists: true, + type: "file", + size: JSON.stringify(payload).length, + sha256: "a".repeat(64), + parsedJson: payload, + contentBase64: Buffer.from(JSON.stringify(payload)).toString("base64"), + }), + stderr: "", + exitCode: 0, + } + }, +} as unknown as Runtime + +const [artifact] = await collectRecipeDeclaredArtifacts(recipe, runtime, mappings) +assert.match(executedCode, new RegExp(effectivePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))) +assert.equal(executedCode.includes(declaredPath), false) +assert.equal(artifact?.path, declaredPath) +assert.equal(artifact?.status, "collected") +assert.deepEqual(artifact?.parsedJson, payload) From 831d30576984077ab4f68b5da599d4580de48165 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 14 Aug 2026 08:34:06 -0400 Subject: [PATCH 6/6] fix: await late editor presentation styles --- .../src/editor-command-runners.ts | 4 ++-- tests/browser-routed-command-security.test.ts | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 5f0226444..e614e8e71 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -23,10 +23,10 @@ const EDITOR_CANVAS_DEFAULT_LAYOUT_SELECTOR = ".block-editor-block-list__layout" const EDITOR_CANVAS_DEFAULT_BLOCK_SELECTOR = ".block-editor-block-list__block, [data-block]" const EDITOR_CANVAS_DEFAULT_TIMEOUT_MS = 30_000 const EDITOR_PRESENTATION_SETTLE_MS = 250 -const EDITOR_PRESENTATION_MIN_OBSERVATION_MS = 1_000 +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 = 3_000 +const EDITOR_PRESENTATION_MAX_CAPTURE_MS = 10_000 const EDITOR_VALIDITY_WARNING_SELECTORS = [ ".block-editor-warning", ".block-editor-block-list__block.is-invalid", diff --git a/tests/browser-routed-command-security.test.ts b/tests/browser-routed-command-security.test.ts index 6dbabad99..1ed53c55f 100644 --- a/tests/browser-routed-command-security.test.ts +++ b/tests/browser-routed-command-security.test.ts @@ -21,6 +21,7 @@ const INITIAL_CANVAS_PRESENTATION_IDENTITY = "c".repeat(64) 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 editorShell = `` const delayedCanvasEditorHtml = `${editorShell}
Transition
` +const slowPresentationEditorHtml = `${editorShell}` test("real browser commands sanitize console, artifacts, stdout, and failure stderr", async () => { const httpServer = createServer((request, response) => { @@ -53,7 +55,9 @@ test("real browser commands sanitize console, artifacts, stdout, and failure std ? replacingCanvasEditorHtml : request.url?.startsWith("/delayed-canvas") ? delayedCanvasEditorHtml - : editorHtml) + : request.url?.startsWith("/slow-presentation") + ? slowPresentationEditorHtml + : editorHtml) }) const serverUrl = await listenLocalHttpServer(httpServer) const server = { serverUrl, playground: {} } as PlaygroundCliServer @@ -142,6 +146,18 @@ test("real browser commands sanitize console, artifacts, stdout, and failure std assert.deepEqual(output.summary.editorPresentation.generatedPresentationIdentities, [DELAYED_CANVAS_PRESENTATION_IDENTITY]) }) + await withTempDir("wp-codebox-real-editor-slow-presentation-security-", async (artifactRoot) => { + const result = await runEditorOpenCommand({ + artifactRoot, + runPlaygroundCommand, + runtimeSpec, + server, + spec: { command: "wordpress.editor-open", args: [`url=http://routed.test/slow-presentation?token=${TOKEN}`, "route-host=routed.test", "capture=steps", "wait-timeout=6s"] }, + }) + const output = JSON.parse(result.output) as { summary: { editorPresentation: { generatedPresentationIdentities: string[] } } } + assert.deepEqual(output.summary.editorPresentation.generatedPresentationIdentities, [SLOW_PRESENTATION_IDENTITY]) + }) + await withTempDir("wp-codebox-real-editor-canvas-security-", async (artifactRoot) => { const result = await runEditorCanvasProbeCommand({ artifactRoot,