Skip to content

Commit 7677f6d

Browse files
committed
Fix editor saves on internal runtime origin
1 parent 4366692 commit 7677f6d

6 files changed

Lines changed: 162 additions & 60 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@
160160
"test:browser-runner-template": "tsx tests/browser-runner-template.test.ts",
161161
"test:browser-observation-assertions": "tsx tests/browser-observation-assertions.test.ts",
162162
"test:editor-actions": "tsx tests/editor-actions.test.ts",
163+
"test:editor-actions-save-integration": "node --import tsx --test tests/editor-actions-save.integration.test.ts",
163164
"test:editor-validate-blocks": "tsx tests/editor-validate-blocks.test.ts",
164165
"test:artifact-result-envelope": "tsx tests/artifact-result-envelope.test.ts",
165166
"test:async-agent-task-contracts": "tsx tests/async-agent-task-contracts.test.ts",

packages/runtime-playground/src/editor-command-runners.ts

Lines changed: 78 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ const EDITOR_VALIDITY_WARNING_SELECTORS = [
2929
".components-notice",
3030
]
3131

32+
export function editorCommandWordPressUrl(server: PlaygroundCliServer): string {
33+
return server.wordpressUrl ?? server.serverUrl
34+
}
35+
36+
function editorCommandPreviewTopology(args: string[], runtimeSpec: RuntimeCreateSpec, server: PlaygroundCliServer) {
37+
return browserPreviewTopology(["preview-mode=local", ...args], runtimeSpec, editorCommandWordPressUrl(server))
38+
}
39+
3240
export async function runEditorCanvasProbeCommand({
3341
artifactRoot,
3442
runtimeSpec,
@@ -551,7 +559,7 @@ export async function runEditorOpenCommand({
551559
}
552560

553561
const waitTimeoutMs = durationArg(args, "wait-timeout", BROWSER_STEP_DEFAULT_TIMEOUT_MS)
554-
const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin)
562+
const topology = editorCommandPreviewTopology(args, runtimeSpec, server)
555563
const { preview, networkPolicy } = topology
556564
const routeTracker = createBrowserPreviewRouteTracker()
557565
const targetUrl = topology.resolveUrl(target.url)
@@ -842,7 +850,7 @@ export async function runEditorActionsCommand({
842850
const waitTimeoutMs = durationArg(args, "wait-timeout", BROWSER_STEP_DEFAULT_TIMEOUT_MS)
843851
const stepTimeoutMs = durationArg(args, "step-timeout", BROWSER_STEP_DEFAULT_TIMEOUT_MS)
844852
const totalTimeoutMs = durationArg(args, "timeout", BROWSER_SCRIPT_DEFAULT_TIMEOUT_MS)
845-
const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin)
853+
const topology = editorCommandPreviewTopology(args, runtimeSpec, server)
846854
const { preview, networkPolicy } = topology
847855
const routeTracker = createBrowserPreviewRouteTracker()
848856
const targetUrl = topology.resolveUrl(target.url)
@@ -1218,11 +1226,14 @@ async function executeEditorBlockMutation(page: import("playwright").Page, step:
12181226
let siblings = blocks
12191227
let block: Block | undefined
12201228
if (typeof input.clientId === "string") {
1221-
const locate = (items: Block[], parent?: string): boolean => items.some((item) => {
1222-
if (item.clientId === input.clientId) { block = item; parentClientId = parent; siblings = items; return true }
1223-
return locate(item.innerBlocks ?? [], item.clientId)
1224-
})
1225-
locate(blocks)
1229+
const pending: Array<{ items: Block[]; parent?: string }> = [{ items: blocks }]
1230+
while (!block && pending.length > 0) {
1231+
const current = pending.pop()!
1232+
for (const item of current.items) {
1233+
if (item.clientId === input.clientId) { block = item; parentClientId = current.parent; siblings = current.items; break }
1234+
if (item.innerBlocks?.length) pending.push({ items: item.innerBlocks, parent: item.clientId })
1235+
}
1236+
}
12261237
} else {
12271238
const path = input.path ?? [input.index ?? -1]
12281239
for (const position of path) {
@@ -1239,22 +1250,33 @@ async function executeEditorBlockMutation(page: import("playwright").Page, step:
12391250
}
12401251
}
12411252
if (!block?.clientId) throw new Error("wp-codebox-editor-target-not-found: clientId, index, or path did not resolve a block")
1242-
const requireAction = (name: string): ((...args: unknown[]) => unknown) => {
1243-
const action = actions[name]
1244-
if (typeof action !== "function") throw new Error(`wp-codebox-editor-${input.kind}-unsupported: core/block-editor.${name} is unavailable`)
1245-
return action as (...args: unknown[]) => unknown
1246-
}
1247-
const create = (spec: { name: string; attributes?: Record<string, unknown>; innerBlocks?: unknown[] }): unknown => {
1248-
if (typeof wp?.blocks?.createBlock !== "function") throw new Error("wp-codebox-editor-create-block-unsupported: wp.blocks.createBlock is unavailable")
1249-
return wp.blocks.createBlock(spec.name, spec.attributes ?? {}, (spec.innerBlocks ?? []).map((child) => create(child as typeof spec)))
1250-
}
1251-
if (input.kind === "selectBlock") return void requireAction("selectBlock")(block.clientId)
1252-
if (input.kind === "updateBlockAttributes") return void requireAction("updateBlockAttributes")(block.clientId, input.attributes)
1253-
if (input.kind === "removeBlock") return void requireAction("removeBlocks")([block.clientId])
1254-
if (input.kind === "duplicateBlock") return void requireAction("duplicateBlocks")([block.clientId])
1255-
if (input.kind === "moveBlock") return void requireAction("moveBlocksToPosition")([block.clientId], parentClientId, parentClientId, input.position)
1256-
if (input.kind === "replaceBlock") return void requireAction("replaceBlock")(block.clientId, create(input.block))
1257-
if (input.kind === "replaceInnerBlocks") return void requireAction("replaceInnerBlocks")(block.clientId, input.blocks.map(create))
1253+
let actionName: string = input.kind
1254+
let actionArgs: unknown[] = [block.clientId]
1255+
if (input.kind === "updateBlockAttributes") actionArgs.push(input.attributes)
1256+
if (input.kind === "removeBlock") { actionName = "removeBlocks"; actionArgs = [[block.clientId]] }
1257+
if (input.kind === "duplicateBlock") { actionName = "duplicateBlocks"; actionArgs = [[block.clientId]] }
1258+
if (input.kind === "moveBlock") { actionName = "moveBlocksToPosition"; actionArgs = [[block.clientId], parentClientId, parentClientId, input.position] }
1259+
if (input.kind === "replaceBlock" || input.kind === "replaceInnerBlocks") {
1260+
const createBlock = wp?.blocks?.createBlock
1261+
if (typeof createBlock !== "function") throw new Error("wp-codebox-editor-create-block-unsupported: wp.blocks.createBlock is unavailable")
1262+
const specs = input.kind === "replaceBlock" ? [input.block] : input.blocks
1263+
const created = new Map<object, unknown>()
1264+
const pending = specs.map((spec) => ({ spec, visited: false }))
1265+
while (pending.length > 0) {
1266+
const current = pending.pop()!
1267+
if (!current.visited) {
1268+
pending.push({ spec: current.spec, visited: true })
1269+
for (const child of current.spec.innerBlocks ?? []) pending.push({ spec: child, visited: false })
1270+
continue
1271+
}
1272+
created.set(current.spec, createBlock(current.spec.name, current.spec.attributes ?? {}, (current.spec.innerBlocks ?? []).map((child) => created.get(child))))
1273+
}
1274+
actionName = input.kind
1275+
actionArgs = input.kind === "replaceBlock" ? [block.clientId, created.get(input.block)] : [block.clientId, input.blocks.map((spec) => created.get(spec))]
1276+
}
1277+
const action = actions[actionName]
1278+
if (typeof action !== "function") throw new Error(`wp-codebox-editor-${input.kind}-unsupported: core/block-editor.${actionName} is unavailable`)
1279+
action(...actionArgs)
12581280
}, step)
12591281
}
12601282

@@ -1333,7 +1355,7 @@ async function waitForEditorSemanticReadiness(page: import("playwright").Page, t
13331355
}
13341356

13351357
async function saveEditorPost(page: import("playwright").Page, step: Extract<EditorActionStep, { kind: "savePost" }>, timeoutMs: number): Promise<BrowserEditorSaveSummary> {
1336-
const save = await page.evaluate(async (input) => {
1358+
await page.evaluate(async (input) => {
13371359
const win = window as unknown as {
13381360
wp?: {
13391361
blocks?: { createBlock?: (name: string, attributes?: Record<string, unknown>) => unknown }
@@ -1363,31 +1385,19 @@ async function saveEditorPost(page: import("playwright").Page, step: Extract<Edi
13631385
blockEditor.insertBlocks([createBlock("core/paragraph", { content: input.content ?? input.marker })])
13641386
}
13651387
await Promise.resolve(editorDispatch.savePost())
1366-
const deadline = Date.now() + input.timeoutMs
1367-
await new Promise<void>((resolve, reject) => {
1368-
const done = () => {
1369-
const isSaving = typeof editor.isSavingPost === "function" ? Boolean(editor.isSavingPost()) : false
1370-
const didSucceed = typeof editor.didPostSaveRequestSucceed === "function" ? Boolean(editor.didPostSaveRequestSucceed()) : undefined
1371-
const didFail = typeof editor.didPostSaveRequestFail === "function" ? Boolean(editor.didPostSaveRequestFail()) : false
1372-
if (!isSaving && didSucceed !== false) {
1373-
cleanup()
1374-
resolve()
1375-
} else if (didFail) {
1376-
cleanup()
1377-
reject(new Error("wp-codebox-editor-save-failed: core/editor savePost reported a failed request"))
1378-
} else if (Date.now() > deadline) {
1379-
cleanup()
1380-
reject(new Error("wp-codebox-editor-save-timeout: timed out waiting for core/editor savePost to settle"))
1381-
}
1382-
}
1383-
const unsubscribe = typeof wpData?.subscribe === "function" ? wpData.subscribe(done) : undefined
1384-
const interval = window.setInterval(done, 250)
1385-
const cleanup = () => {
1386-
window.clearInterval(interval)
1387-
if (unsubscribe) unsubscribe()
1388-
}
1389-
done()
1390-
})
1388+
}, { marker: step.marker, content: step.content })
1389+
const settled = await page.waitForFunction(() => {
1390+
const editor = (window as unknown as { wp?: { data?: { select?: (store: string) => Record<string, unknown> } } }).wp?.data?.select?.("core/editor")
1391+
if (!editor) return false
1392+
const saving = typeof editor.isSavingPost === "function" ? Boolean(editor.isSavingPost()) : false
1393+
const failed = typeof editor.didPostSaveRequestFail === "function" ? Boolean(editor.didPostSaveRequestFail()) : false
1394+
const succeeded = typeof editor.didPostSaveRequestSucceed === "function" ? Boolean(editor.didPostSaveRequestSucceed()) : undefined
1395+
return !saving && (failed || succeeded !== false) ? { failed } : false
1396+
}, undefined, { timeout: timeoutMs }).then((handle) => handle.jsonValue() as Promise<{ failed: boolean }>)
1397+
if (settled.failed) throw new Error("wp-codebox-editor-save-failed: core/editor savePost reported a failed request")
1398+
const save = await page.evaluate((input) => {
1399+
const editor = (window as unknown as { wp?: { data?: { select?: (store: string) => Record<string, unknown> } } }).wp?.data?.select?.("core/editor")
1400+
if (!editor) throw new Error("wp-codebox-editor-readiness-unavailable: core/editor store is unavailable")
13911401
const editedContent = typeof editor.getEditedPostContent === "function" ? String(editor.getEditedPostContent() ?? "") : ""
13921402
return {
13931403
schema: "wp-codebox/editor-save/v1",
@@ -1398,7 +1408,7 @@ async function saveEditorPost(page: import("playwright").Page, step: Extract<Edi
13981408
markerPresent: input.marker ? editedContent.includes(input.marker) : undefined,
13991409
content: editedContent,
14001410
}
1401-
}, { marker: step.marker, content: step.content, timeoutMs })
1411+
}, { marker: step.marker })
14021412

14031413
const { content, ...summary } = save as BrowserEditorSaveSummary & { content?: string }
14041414
return {
@@ -1493,13 +1503,22 @@ export async function captureEditorState(page: import("playwright").Page, target
14931503
const currentPost = typeof editor.getCurrentPost === "function" ? editor.getCurrentPost() as Record<string, unknown> | null : null
14941504
const blocks = typeof blockEditor.getBlocks === "function" ? blockEditor.getBlocks() as Array<Record<string, unknown>> : []
14951505
const serialize = (window as unknown as { wp?: { blocks?: { serialize?: (items: unknown[]) => string } } }).wp?.blocks?.serialize
1496-
const toTree = (items: Array<Record<string, unknown>>): NonNullable<EditorStateSnapshot["blocks"]> => items.map((block) => ({
1497-
name: typeof block.name === "string" ? block.name : "",
1498-
clientId: typeof block.clientId === "string" ? block.clientId : undefined,
1499-
attributes: typeof block.attributes === "object" && block.attributes ? block.attributes as Record<string, unknown> : undefined,
1500-
isValid: typeof block.isValid === "boolean" ? block.isValid : undefined,
1501-
innerBlocks: Array.isArray(block.innerBlocks) ? toTree(block.innerBlocks as Array<Record<string, unknown>>) : undefined,
1502-
}))
1506+
const tree: NonNullable<EditorStateSnapshot["blocks"]> = []
1507+
const pending: Array<{ items: Array<Record<string, unknown>>; output: NonNullable<EditorStateSnapshot["blocks"]> }> = [{ items: blocks, output: tree }]
1508+
while (pending.length > 0) {
1509+
const current = pending.pop()!
1510+
for (const block of current.items) {
1511+
const innerBlocks: NonNullable<EditorStateSnapshot["blocks"]> = []
1512+
current.output.push({
1513+
name: typeof block.name === "string" ? block.name : "",
1514+
clientId: typeof block.clientId === "string" ? block.clientId : undefined,
1515+
attributes: typeof block.attributes === "object" && block.attributes ? block.attributes as Record<string, unknown> : undefined,
1516+
isValid: typeof block.isValid === "boolean" ? block.isValid : undefined,
1517+
innerBlocks,
1518+
})
1519+
if (Array.isArray(block.innerBlocks)) pending.push({ items: block.innerBlocks as Array<Record<string, unknown>>, output: innerBlocks })
1520+
}
1521+
}
15031522
const savedContent = typeof currentPost?.content === "object" && currentPost.content
15041523
? stringValue((currentPost.content as Record<string, unknown>).raw ?? (currentPost.content as Record<string, unknown>).rendered)
15051524
: undefined
@@ -1511,7 +1530,7 @@ export async function captureEditorState(page: import("playwright").Page, target
15111530
status: typeof currentPost?.status === "string" ? currentPost.status : undefined,
15121531
title: typeof currentPost?.title === "object" && currentPost.title ? (currentPost.title as Record<string, unknown>).raw ?? (currentPost.title as Record<string, unknown>).rendered : undefined,
15131532
},
1514-
blocks: toTree(blocks),
1533+
blocks: tree,
15151534
serializedContent: typeof serialize === "function" ? serialize(blocks) : typeof editor.getEditedPostContent === "function" ? String(editor.getEditedPostContent() ?? "") : undefined,
15161535
dirty: typeof editor.isEditedPostDirty === "function" ? Boolean(editor.isEditedPostDirty()) : undefined,
15171536
saving: typeof editor.isSavingPost === "function" ? Boolean(editor.isSavingPost()) : undefined,
@@ -1957,7 +1976,7 @@ export async function runEditorValidateBlocksCommand({
19571976
const content = await editorValidateContentFromArgs(args)
19581977
const provider = editorValidateProviderFromArgs(args)
19591978
const waitTimeoutMs = durationArg(args, "wait-timeout", EDITOR_VALIDATE_BLOCKS_READY_TIMEOUT_MS)
1960-
const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin)
1979+
const topology = editorCommandPreviewTopology(args, runtimeSpec, server)
19611980
const { preview, networkPolicy } = topology
19621981
const routeTracker = createBrowserPreviewRouteTracker()
19631982
const targetUrl = topology.resolveUrl(target.url)

packages/runtime-playground/src/preview-server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface PlaygroundCliServer {
1717
writeFile?(path: string, contents: string): Promise<void>
1818
}
1919
serverUrl: string
20+
wordpressUrl?: string
2021
requestWorkerEndpoint?: { route: string; token: string; payloadDirectory: string }
2122
previewLease?: PreviewLease
2223
previewRoutes?: PlaygroundPreviewRouteRegistry
@@ -69,6 +70,7 @@ export async function withPreviewProxy(server: PlaygroundCliServer, port: number
6970
return {
7071
...server,
7172
serverUrl: proxy.serverUrl,
73+
wordpressUrl: server.wordpressUrl ?? server.serverUrl,
7274
previewRoutes: proxy.previewRoutes,
7375
previewProxyDiagnostics: proxy.diagnostics,
7476
async [Symbol.asyncDispose]() {

tests/browser-callback-materialization-contracts.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ const proxied = await withPreviewProxy({
206206
},
207207
} satisfies PlaygroundCliServer, 0)
208208
try {
209+
assert.equal(proxied.wordpressUrl, targetServerUrl)
210+
assert.notEqual(proxied.serverUrl, proxied.wordpressUrl)
209211
assert.deepEqual(proxied.previewProxyDiagnostics, {
210212
schema: "wp-codebox/preview-proxy-diagnostics/v1",
211213
upstreamConcurrency: "serialized",
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import assert from "node:assert/strict"
2+
import { createHash } from "node:crypto"
3+
import { readFile, rm } from "node:fs/promises"
4+
import { tmpdir } from "node:os"
5+
import { join } from "node:path"
6+
import test from "node:test"
7+
8+
import { createWordPressRuntime } from "../packages/runtime-playground/src/public.js"
9+
10+
test("editor actions save persists after reload", { timeout: 180_000 }, async () => {
11+
const artifactsDirectory = join(tmpdir(), `wp-codebox-editor-save-${process.pid}-${Date.now()}`)
12+
const runtime = await createWordPressRuntime({
13+
environment: { version: "6.5", phpVersion: "8.0", blueprint: { steps: [] }, workers: 6 },
14+
policy: {
15+
network: "deny",
16+
filesystem: "sandbox",
17+
commands: ["wordpress.run-php", "wordpress.editor-actions"],
18+
secrets: "none",
19+
approvals: "never",
20+
},
21+
artifactsDirectory,
22+
})
23+
24+
try {
25+
const created = await runtime.execute({
26+
command: "wordpress.run-php",
27+
args: ["code=echo wp_insert_post(array('post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Editor save integration', 'post_content' => '<!-- wp:paragraph --><p>Before</p><!-- /wp:paragraph -->'));"],
28+
timeoutMs: 60_000,
29+
})
30+
assert.equal(created.exitCode, 0, created.stderr)
31+
const postId = Number.parseInt(created.stdout.trim(), 10)
32+
assert.ok(Number.isInteger(postId) && postId > 0, `expected a created page id, received ${created.stdout}`)
33+
34+
const marker = "Persisted editor action marker"
35+
const action = await runtime.execute({
36+
command: "wordpress.editor-actions",
37+
args: [
38+
`post-id=${postId}`,
39+
"post-type=page",
40+
"capture=steps,errors,editor-state",
41+
"wait-timeout=45s",
42+
"step-timeout=45s",
43+
"timeout=120s",
44+
`steps-json=${JSON.stringify([
45+
{ kind: "updateBlockAttributes", index: 0, attributes: { content: marker } },
46+
{ kind: "savePost" },
47+
{ kind: "reload" },
48+
{ kind: "inspectState" },
49+
])}`,
50+
],
51+
timeoutMs: 150_000,
52+
})
53+
assert.equal(action.exitCode, 0, action.stderr || action.stdout)
54+
55+
const runtimeInfo = await runtime.info()
56+
const state = JSON.parse(await readFile(join(artifactsDirectory, runtimeInfo.id, "files/browser/editor-action-state.json"), "utf8")) as {
57+
dirty?: boolean
58+
serializedContent?: string
59+
serializedContentSha256?: string
60+
}
61+
assert.equal(state.dirty, false)
62+
assert.match(state.serializedContent ?? "", new RegExp(marker))
63+
assert.ok(state.serializedContentSha256)
64+
const persisted = await runtime.execute({
65+
command: "wordpress.run-php",
66+
args: [`code=echo get_post_field('post_content', ${postId});`],
67+
timeoutMs: 30_000,
68+
})
69+
assert.equal(persisted.exitCode, 0, persisted.stderr)
70+
assert.match(persisted.stdout, new RegExp(marker))
71+
assert.equal(state.serializedContentSha256, createHash("sha256").update(persisted.stdout).digest("hex"), "post-reload editor content must match the persisted WordPress content")
72+
} finally {
73+
await runtime.destroy()
74+
await rm(artifactsDirectory, { recursive: true, force: true })
75+
}
76+
})

0 commit comments

Comments
 (0)