diff --git a/.github/workflows/agent-task-contracts.yml b/.github/workflows/agent-task-contracts.yml index 145534af..1883a5b4 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 5af12b67..67096ed7 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 a9d73e86..f9e5ae4e 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 a2d414f5..6f9283c3 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/packages/runtime-core/src/command-registry.ts b/packages/runtime-core/src/command-registry.ts index d0859334..399ed9db 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 f8836ed4..da68b235 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,16 @@ export interface BrowserEditorReadinessSummary { postType?: string } +export interface BrowserEditorPresentationSummary { + schema: "wp-codebox/editor-presentation/v1" + canvasDocumentType: "iframe" | "parent" + 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 8a24bdf2..e614e8e7 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" @@ -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 = 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_VALIDITY_WARNING_SELECTORS = [ ".block-editor-warning", ".block-editor-block-list__block.is-invalid", @@ -350,11 +355,9 @@ 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 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 } @@ -580,6 +583,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 +643,10 @@ export async function runEditorOpenCommand({ } } + if (editorReadiness) { + editorPresentation = await captureEditorPresentation(page, waitTimeoutMs) + } + if (capture.has("editor-state")) { editorState = await captureEditorState(page, target) await artifactSession.writeJson("editorState", "editor-state.json", editorState) @@ -653,6 +661,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 +717,7 @@ export async function runEditorOpenCommand({ ...(editorSummary ? { editor: editorSummary } : {}), ...(editorValidity ? { editorValidity: editorValidity.summary } : {}), ...(editorReadiness ? { editorReadiness } : {}), + ...(editorPresentation ? { editorPresentation } : {}), ...(editorCanvasReadiness ? { editorCanvas: editorCanvasReadiness } : {}), viewport, }, @@ -805,6 +815,116 @@ export async function waitForEditorOpenReadiness(page: import("playwright").Page return { editorReadiness, editorCanvasReadiness: probe.summary } } +interface EditorPresentationCapture { + canvasDocumentType: "iframe" | "parent" + 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", + canvasDocumentType: capture.canvasDocumentType, + iframeCount: capture.iframeCount, + iframeStylesheetUrlCount: iframeStylesheetUrls.length, + iframeStylesheetUrls, + generatedPresentationIdentityCount: generatedPresentationIdentities.length, + generatedPresentationIdentities, + } +} + +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) + 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) + 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" + } + + 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(), + canvasDocumentType, + iframeCount, + stylesheetUrls: stylesheets.flatMap((stylesheet) => stylesheet.href ? [stylesheet.href] : []), + inlineStyleContents: Array.from(document.querySelectorAll("style"), (style) => style.textContent ?? ""), + } + }, { canvasDocumentType, iframeCount: canvasDocumentType === "iframe" ? 1 : 0 }).catch(() => null) as (EditorPresentationCapture & { documentIdentity: string; documentAgeMs: number }) | null + if (capture) { + const summary = summarizeEditorPresentation(capture) + const fingerprint = `${capture.documentIdentity}\n${JSON.stringify(summary)}` + const observedAtMs = Date.now() + if (fingerprint === previousFingerprint) { + 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 + && currentDocumentIdentity === capture.documentIdentity) { + return summary + } + } else { + previousFingerprint = fingerprint + stableSinceMs = observedAtMs + } + } + await page.waitForTimeout(EDITOR_PRESENTATION_POLL_MS) + } + + return undefined +} + +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) } @@ -1813,6 +1933,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[] } @@ -1847,7 +1969,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 { @@ -1856,6 +1978,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, } } @@ -1863,7 +1987,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/browser-routed-command-security.test.ts b/tests/browser-routed-command-security.test.ts index c74117cf..1ed53c55 100644 --- a/tests/browser-routed-command-security.test.ts +++ b/tests/browser-routed-command-security.test.ts @@ -15,7 +15,14 @@ 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 parentCanvasEditorHtml = `${editorShell}
Block
` +const replacingCanvasEditorHtml = `${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) => { 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("/parent-canvas") + ? parentCanvasEditorHtml + : request.url?.startsWith("/replacing-canvas") + ? replacingCanvasEditorHtml + : request.url?.startsWith("/delayed-canvas") + ? delayedCanvasEditorHtml + : request.url?.startsWith("/slow-presentation") + ? slowPresentationEditorHtml + : editorHtml) }) const serverUrl = await listenLocalHttpServer(httpServer) const server = { serverUrl, playground: {} } as PlaygroundCliServer @@ -74,6 +96,66 @@ 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", + canvasDocumentType: "iframe", + iframeCount: 1, + iframeStylesheetUrlCount: 0, + iframeStylesheetUrls: [], + generatedPresentationIdentityCount: 1, + generatedPresentationIdentities: [CANVAS_PRESENTATION_IDENTITY], + }) + }) + + 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/parent-canvas?token=${TOKEN}`, "route-host=routed.test", "capture=steps", "wait-timeout=5s"] }, + }) + 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) => { + 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-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) => { diff --git a/tests/editor-actions.test.ts b/tests/editor-actions.test.ts index 1d2c726e..446c038d 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,34 @@ const unavailableEditorState = await captureEditorState({ } as never, target) 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: [ + "/* blocks-engine-presentation:ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789 */", + "/* blocks-engine-presentation:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 */", + ], +}) +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({ canvasDocumentType: "parent", iframeCount: 0, stylesheetUrls: [], inlineStyleContents: [".editor { color: black; }"] }), { + schema: "wp-codebox/editor-presentation/v1", + canvasDocumentType: "parent", + iframeCount: 0, + 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[] }> = [] diff --git a/tests/editor-validate-blocks.test.ts b/tests/editor-validate-blocks.test.ts index ef24fb5b..e1357438 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) diff --git a/tests/recipe-declared-artifacts.test.ts b/tests/recipe-declared-artifacts.test.ts new file mode 100644 index 00000000..8aae07b9 --- /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)