Skip to content

Commit 36930da

Browse files
authored
Add stateful block editor mutation steps (#1859)
* Add stateful editor mutations (#1855) * Verify editor mutation postconditions * Classify rejected editor inserts
1 parent 6dfeea1 commit 36930da

6 files changed

Lines changed: 613 additions & 62 deletions

File tree

packages/runtime-core/src/command-registry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,14 +1282,14 @@ export const commandRegistry = [
12821282
{ name: "post-id", description: "Existing post ID to open in the post editor.", format: "positive integer" },
12831283
{ name: "post-type", description: "Post type for post-new or post-id targets; defaults to post.", format: "post type slug" },
12841284
{ name: "url", description: "Explicit editor path or absolute URL to open instead of resolving a target.", format: "path or URL" },
1285-
{ name: "steps-json", description: "Ordered editor action script: open, waitForReady, insertBlock, selectBlock, savePost, and inspectState. savePost uses Gutenberg core/editor.savePost and reports typed editorSave diagnostics.", required: true, format: "JSON array (inline or @<path>)" },
1285+
{ name: "steps-json", description: "Ordered typed editor action script. Block targets resolve by exactly one clientId, root index, or numeric path. Supports insert/select, attribute update, remove/move/duplicate/replace, inner-block replacement, undo/redo, reload/reopen, save, and inspect. Clipboard actions are explicitly unsupported because this runtime has no deterministic clipboard contract.", required: true, format: "JSON array (inline or @<path>)" },
12861286
{ name: "wait-selector", description: "Selector that marks the editor as ready; defaults to the block editor shell.", format: "CSS selector" },
12871287
{ name: "wait-timeout", description: "Timeout for navigation and editor-ready waits.", format: "duration, e.g. 15s or 500ms" },
12881288
{ name: "step-timeout", description: "Per-action timeout applied to each editor action step.", format: "duration, e.g. 15s or 500ms" },
12891289
{ name: "timeout", description: "Total-script timeout bounding the whole editor action run.", format: "duration, e.g. 30s or 1500ms" },
12901290
{ name: "capture", description: "Comma-separated artifacts to capture after actions.", format: "steps,console,errors,html,screenshot,editor-state,editor-validity" },
12911291
],
1292-
outputShape: "JSON summary plus files/browser/editor-action-steps.jsonl, editor-action-summary.json, editor-action-state.json, optional editor-action-validity.json, and optional console/errors/html/screenshot artifacts.",
1292+
outputShape: "JSON summary plus per-step before/after mutation summaries, files/browser/editor-action-steps.jsonl, editor-action-summary.json, editor-action-state.json (typed block tree, attributes, validity, serialized/saved content identities, dirty/saving state), editor-action-validity.json, and optional console/errors/html/screenshot artifacts.",
12931293
policyRequirement: "Runtime policy commands must include wordpress.editor-actions.",
12941294
validation: editorActionsValidation,
12951295
recipe: true,

packages/runtime-playground/src/browser-artifacts.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export interface BrowserArtifactSummary {
137137
editorReadiness?: BrowserEditorReadinessSummary
138138
editorSave?: BrowserEditorSaveSummary
139139
editorCanvas?: BrowserEditorCanvasProbeSummary
140+
editorCapabilities?: { clipboard: "unsupported" }
140141
steps?: number
141142
assertions?: BrowserAssertionsSummary
142143
consoleMessages: number
@@ -805,6 +806,22 @@ export interface BrowserStepRecord {
805806
verifierResult?: string
806807
finalUrl?: string
807808
error?: BrowserProbeErrorRecord
809+
editorMutation?: BrowserEditorMutationSummary
810+
}
811+
812+
export interface BrowserEditorMutationSummary {
813+
status: "applied" | "no-op" | "failed"
814+
before: BrowserEditorStateSummary
815+
after?: BrowserEditorStateSummary
816+
failure?: string
817+
}
818+
819+
export interface BrowserEditorStateSummary {
820+
blockCount?: number
821+
contentSha256?: string
822+
dirty?: boolean
823+
saving?: boolean
824+
savedContentSha256?: string
808825
}
809826

810827
export interface BrowserStepScreenshotFallback {

packages/runtime-playground/src/browser-interactions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { BrowserInteractionStep } from "@automattic/wp-codebox-core"
22
import { now } from "@automattic/wp-codebox-core/internals"
33
import type { Frame, Page } from "playwright"
44
import { browserActionLoadState, browserDeepEqual, browserStepTimeoutMs, durationStringMs, sanitizeScreenshotName } from "./browser-actions.js"
5-
import type { BrowserProbeErrorRecord, BrowserStepAssertion, BrowserStepReadiness, BrowserStepRecord } from "./browser-artifacts.js"
5+
import type { BrowserEditorMutationSummary, BrowserProbeErrorRecord, BrowserStepAssertion, BrowserStepReadiness, BrowserStepRecord } from "./browser-artifacts.js"
66
import { browserCommandLivenessPolicy, withBrowserCommandLiveness } from "./browser-liveness.js"
77

88
export interface BrowserStepOutcome {
@@ -14,6 +14,7 @@ export interface BrowserStepOutcome {
1414
screenshotFallback?: { reason: string; mode: "page-screenshot" }
1515
verifierResult?: string
1616
error?: BrowserProbeErrorRecord
17+
editorMutation?: BrowserEditorMutationSummary
1718
}
1819

1920
export interface BrowserStepScreenshotWriterResult {
@@ -386,6 +387,7 @@ export function browserStepRecord(
386387
...(outcome.verifierResult ? { verifierResult: outcome.verifierResult } : {}),
387388
finalUrl,
388389
...(outcome.error ? { error: outcome.error } : {}),
390+
...(outcome.editorMutation ? { editorMutation: outcome.editorMutation } : {}),
389391
}
390392
}
391393

packages/runtime-playground/src/editor-actions.ts

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,32 @@ export type EditorActionStep =
2929
| { kind: "open"; timeout?: string }
3030
| { kind: "waitForReady"; timeout?: string }
3131
| { kind: "insertBlock"; name?: string; attributes?: Record<string, unknown>; content?: string; select?: boolean; timeout?: string }
32-
| { kind: "selectBlock"; clientId?: string; index?: number; timeout?: string }
32+
| ({ kind: "selectBlock"; timeout?: string } & EditorBlockTarget)
33+
| ({ kind: "updateBlockAttributes"; attributes: Record<string, unknown>; timeout?: string } & EditorBlockTarget)
34+
| ({ kind: "removeBlock"; timeout?: string } & EditorBlockTarget)
35+
| ({ kind: "moveBlock"; position: number; timeout?: string } & EditorBlockTarget)
36+
| ({ kind: "duplicateBlock"; timeout?: string } & EditorBlockTarget)
37+
| ({ kind: "replaceBlock"; block: EditorBlockSpec; timeout?: string } & EditorBlockTarget)
38+
| ({ kind: "replaceInnerBlocks"; blocks: EditorBlockSpec[]; timeout?: string } & EditorBlockTarget)
39+
| { kind: "undo"; timeout?: string }
40+
| { kind: "redo"; timeout?: string }
41+
| { kind: "reload"; timeout?: string }
42+
| { kind: "reopen"; timeout?: string }
3343
| { kind: "savePost"; marker?: string; content?: string; timeout?: string }
3444
| { kind: "inspectState"; timeout?: string }
3545

46+
export interface EditorBlockTarget {
47+
clientId?: string
48+
index?: number
49+
path?: number[]
50+
}
51+
52+
export interface EditorBlockSpec {
53+
name: string
54+
attributes?: Record<string, unknown>
55+
innerBlocks?: EditorBlockSpec[]
56+
}
57+
3658
export function editorOpenTargetFromArgs(args: string[]): EditorOpenTarget {
3759
const explicitUrl = argValue(args, "url")?.trim()
3860
const waitSelector = argValue(args, "wait-selector")?.trim() || undefined
@@ -228,18 +250,31 @@ function normalizeEditorActionStep(step: unknown, index: number): EditorActionSt
228250
return { kind: "waitForReady", ...(typeof input.timeout === "string" ? { timeout: input.timeout } : {}) }
229251
}
230252
if (input.kind === "selectBlock") {
231-
if (input.clientId !== undefined && typeof input.clientId !== "string") {
232-
throw new Error(`wordpress.editor-actions steps-json[${index}].clientId must be a string`)
233-
}
234-
if (input.index !== undefined && (typeof input.index !== "number" || !Number.isInteger(input.index) || input.index < 0)) {
235-
throw new Error(`wordpress.editor-actions steps-json[${index}].index must be a non-negative integer`)
253+
return { kind: "selectBlock", ...normalizeEditorBlockTarget(input, index), ...editorActionTimeout(input) }
254+
}
255+
if (input.kind === "updateBlockAttributes") {
256+
return { kind: "updateBlockAttributes", ...normalizeEditorBlockTarget(input, index), attributes: normalizeAttributes(input.attributes, index), ...editorActionTimeout(input) }
257+
}
258+
if (input.kind === "removeBlock" || input.kind === "duplicateBlock") {
259+
return { kind: input.kind, ...normalizeEditorBlockTarget(input, index), ...editorActionTimeout(input) }
260+
}
261+
if (input.kind === "moveBlock") {
262+
if (typeof input.position !== "number" || !Number.isInteger(input.position) || input.position < 0) {
263+
throw new Error(`wordpress.editor-actions steps-json[${index}].position must be a non-negative integer`)
236264
}
237-
return {
238-
kind: "selectBlock",
239-
...(typeof input.clientId === "string" ? { clientId: input.clientId } : {}),
240-
...(Number.isInteger(input.index) && (input.index as number) >= 0 ? { index: input.index as number } : {}),
241-
...(typeof input.timeout === "string" ? { timeout: input.timeout } : {}),
265+
return { kind: "moveBlock", ...normalizeEditorBlockTarget(input, index), position: input.position, ...editorActionTimeout(input) }
266+
}
267+
if (input.kind === "replaceBlock") {
268+
return { kind: "replaceBlock", ...normalizeEditorBlockTarget(input, index), block: normalizeEditorBlockSpec(input.block, index, "block"), ...editorActionTimeout(input) }
269+
}
270+
if (input.kind === "replaceInnerBlocks") {
271+
if (!Array.isArray(input.blocks)) {
272+
throw new Error(`wordpress.editor-actions steps-json[${index}].blocks must be a JSON array`)
242273
}
274+
return { kind: "replaceInnerBlocks", ...normalizeEditorBlockTarget(input, index), blocks: input.blocks.map((block, blockIndex) => normalizeEditorBlockSpec(block, index, `blocks[${blockIndex}]`)), ...editorActionTimeout(input) }
275+
}
276+
if (input.kind === "undo" || input.kind === "redo" || input.kind === "reload" || input.kind === "reopen") {
277+
return { kind: input.kind, ...editorActionTimeout(input) }
243278
}
244279
if (input.kind === "savePost") {
245280
if (input.marker !== undefined && typeof input.marker !== "string") {
@@ -261,3 +296,38 @@ function normalizeEditorActionStep(step: unknown, index: number): EditorActionSt
261296

262297
throw new Error(`wordpress.editor-actions step kind is not supported: ${String(input.kind)}`)
263298
}
299+
300+
function normalizeEditorBlockTarget(input: Record<string, unknown>, index: number): EditorBlockTarget {
301+
const targetCount = Number(typeof input.clientId === "string") + Number(input.index !== undefined) + Number(input.path !== undefined)
302+
if (targetCount !== 1) {
303+
throw new Error(`wordpress.editor-actions steps-json[${index}] requires exactly one target: clientId, index, or path`)
304+
}
305+
if (typeof input.clientId === "string" && input.clientId.length > 0) return { clientId: input.clientId }
306+
if (typeof input.index === "number" && Number.isInteger(input.index) && input.index >= 0) return { index: input.index }
307+
if (Array.isArray(input.path) && input.path.length > 0 && input.path.every((part) => typeof part === "number" && Number.isInteger(part) && part >= 0)) return { path: input.path as number[] }
308+
throw new Error(`wordpress.editor-actions steps-json[${index}] target must be a non-empty clientId, non-negative index, or non-empty path of non-negative indexes`)
309+
}
310+
311+
function normalizeAttributes(value: unknown, index: number): Record<string, unknown> {
312+
if (!value || typeof value !== "object" || Array.isArray(value)) {
313+
throw new Error(`wordpress.editor-actions steps-json[${index}].attributes must be a JSON object`)
314+
}
315+
return value as Record<string, unknown>
316+
}
317+
318+
function normalizeEditorBlockSpec(value: unknown, index: number, field: string): EditorBlockSpec {
319+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`wordpress.editor-actions steps-json[${index}].${field} must be an object`)
320+
const input = value as Record<string, unknown>
321+
if (typeof input.name !== "string" || input.name.length === 0) throw new Error(`wordpress.editor-actions steps-json[${index}].${field}.name must be a block name string`)
322+
if (input.attributes !== undefined) normalizeAttributes(input.attributes, index)
323+
if (input.innerBlocks !== undefined && !Array.isArray(input.innerBlocks)) throw new Error(`wordpress.editor-actions steps-json[${index}].${field}.innerBlocks must be a JSON array`)
324+
return {
325+
name: input.name,
326+
...(input.attributes !== undefined ? { attributes: input.attributes as Record<string, unknown> } : {}),
327+
...(Array.isArray(input.innerBlocks) ? { innerBlocks: input.innerBlocks.map((block, childIndex) => normalizeEditorBlockSpec(block, index, `${field}.innerBlocks[${childIndex}]`)) } : {}),
328+
}
329+
}
330+
331+
function editorActionTimeout(input: Record<string, unknown>): { timeout?: string } {
332+
return typeof input.timeout === "string" ? { timeout: input.timeout } : {}
333+
}

0 commit comments

Comments
 (0)