diff --git a/package.json b/package.json index f4cd8b4c..1635fd2d 100644 --- a/package.json +++ b/package.json @@ -160,6 +160,7 @@ "test:browser-runner-template": "tsx tests/browser-runner-template.test.ts", "test:browser-observation-assertions": "tsx tests/browser-observation-assertions.test.ts", "test:editor-actions": "tsx tests/editor-actions.test.ts", + "test:editor-actions-save-integration": "node --import tsx --test tests/editor-actions-save.integration.test.ts", "test:editor-validate-blocks": "tsx tests/editor-validate-blocks.test.ts", "test:artifact-result-envelope": "tsx tests/artifact-result-envelope.test.ts", "test:async-agent-task-contracts": "tsx tests/async-agent-task-contracts.test.ts", diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 334e0c58..c1d159fa 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -29,6 +29,14 @@ const EDITOR_VALIDITY_WARNING_SELECTORS = [ ".components-notice", ] +export function editorCommandWordPressUrl(server: PlaygroundCliServer): string { + return server.wordpressUrl ?? server.serverUrl +} + +function editorCommandPreviewTopology(args: string[], runtimeSpec: RuntimeCreateSpec, server: PlaygroundCliServer) { + return browserPreviewTopology(["preview-mode=local", ...args], runtimeSpec, editorCommandWordPressUrl(server)) +} + export async function runEditorCanvasProbeCommand({ artifactRoot, runtimeSpec, @@ -551,7 +559,7 @@ export async function runEditorOpenCommand({ } const waitTimeoutMs = durationArg(args, "wait-timeout", BROWSER_STEP_DEFAULT_TIMEOUT_MS) - const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin) + const topology = editorCommandPreviewTopology(args, runtimeSpec, server) const { preview, networkPolicy } = topology const routeTracker = createBrowserPreviewRouteTracker() const targetUrl = topology.resolveUrl(target.url) @@ -842,7 +850,7 @@ export async function runEditorActionsCommand({ const waitTimeoutMs = durationArg(args, "wait-timeout", BROWSER_STEP_DEFAULT_TIMEOUT_MS) const stepTimeoutMs = durationArg(args, "step-timeout", BROWSER_STEP_DEFAULT_TIMEOUT_MS) const totalTimeoutMs = durationArg(args, "timeout", BROWSER_SCRIPT_DEFAULT_TIMEOUT_MS) - const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin) + const topology = editorCommandPreviewTopology(args, runtimeSpec, server) const { preview, networkPolicy } = topology const routeTracker = createBrowserPreviewRouteTracker() const targetUrl = topology.resolveUrl(target.url) @@ -1218,11 +1226,14 @@ async function executeEditorBlockMutation(page: import("playwright").Page, step: let siblings = blocks let block: Block | undefined if (typeof input.clientId === "string") { - const locate = (items: Block[], parent?: string): boolean => items.some((item) => { - if (item.clientId === input.clientId) { block = item; parentClientId = parent; siblings = items; return true } - return locate(item.innerBlocks ?? [], item.clientId) - }) - locate(blocks) + const pending: Array<{ items: Block[]; parent?: string }> = [{ items: blocks }] + while (!block && pending.length > 0) { + const current = pending.pop()! + for (const item of current.items) { + if (item.clientId === input.clientId) { block = item; parentClientId = current.parent; siblings = current.items; break } + if (item.innerBlocks?.length) pending.push({ items: item.innerBlocks, parent: item.clientId }) + } + } } else { const path = input.path ?? [input.index ?? -1] for (const position of path) { @@ -1239,22 +1250,33 @@ async function executeEditorBlockMutation(page: import("playwright").Page, step: } } if (!block?.clientId) throw new Error("wp-codebox-editor-target-not-found: clientId, index, or path did not resolve a block") - const requireAction = (name: string): ((...args: unknown[]) => unknown) => { - const action = actions[name] - if (typeof action !== "function") throw new Error(`wp-codebox-editor-${input.kind}-unsupported: core/block-editor.${name} is unavailable`) - return action as (...args: unknown[]) => unknown - } - const create = (spec: { name: string; attributes?: Record; innerBlocks?: unknown[] }): unknown => { - if (typeof wp?.blocks?.createBlock !== "function") throw new Error("wp-codebox-editor-create-block-unsupported: wp.blocks.createBlock is unavailable") - return wp.blocks.createBlock(spec.name, spec.attributes ?? {}, (spec.innerBlocks ?? []).map((child) => create(child as typeof spec))) - } - if (input.kind === "selectBlock") return void requireAction("selectBlock")(block.clientId) - if (input.kind === "updateBlockAttributes") return void requireAction("updateBlockAttributes")(block.clientId, input.attributes) - if (input.kind === "removeBlock") return void requireAction("removeBlocks")([block.clientId]) - if (input.kind === "duplicateBlock") return void requireAction("duplicateBlocks")([block.clientId]) - if (input.kind === "moveBlock") return void requireAction("moveBlocksToPosition")([block.clientId], parentClientId, parentClientId, input.position) - if (input.kind === "replaceBlock") return void requireAction("replaceBlock")(block.clientId, create(input.block)) - if (input.kind === "replaceInnerBlocks") return void requireAction("replaceInnerBlocks")(block.clientId, input.blocks.map(create)) + let actionName: string = input.kind + let actionArgs: unknown[] = [block.clientId] + if (input.kind === "updateBlockAttributes") actionArgs.push(input.attributes) + if (input.kind === "removeBlock") { actionName = "removeBlocks"; actionArgs = [[block.clientId]] } + if (input.kind === "duplicateBlock") { actionName = "duplicateBlocks"; actionArgs = [[block.clientId]] } + if (input.kind === "moveBlock") { actionName = "moveBlocksToPosition"; actionArgs = [[block.clientId], parentClientId, parentClientId, input.position] } + if (input.kind === "replaceBlock" || input.kind === "replaceInnerBlocks") { + const createBlock = wp?.blocks?.createBlock + if (typeof createBlock !== "function") throw new Error("wp-codebox-editor-create-block-unsupported: wp.blocks.createBlock is unavailable") + const specs = input.kind === "replaceBlock" ? [input.block] : input.blocks + const created = new Map() + const pending = specs.map((spec) => ({ spec, visited: false })) + while (pending.length > 0) { + const current = pending.pop()! + if (!current.visited) { + pending.push({ spec: current.spec, visited: true }) + for (const child of current.spec.innerBlocks ?? []) pending.push({ spec: child, visited: false }) + continue + } + created.set(current.spec, createBlock(current.spec.name, current.spec.attributes ?? {}, (current.spec.innerBlocks ?? []).map((child) => created.get(child)))) + } + actionName = input.kind + actionArgs = input.kind === "replaceBlock" ? [block.clientId, created.get(input.block)] : [block.clientId, input.blocks.map((spec) => created.get(spec))] + } + const action = actions[actionName] + if (typeof action !== "function") throw new Error(`wp-codebox-editor-${input.kind}-unsupported: core/block-editor.${actionName} is unavailable`) + action(...actionArgs) }, step) } @@ -1333,7 +1355,7 @@ async function waitForEditorSemanticReadiness(page: import("playwright").Page, t } async function saveEditorPost(page: import("playwright").Page, step: Extract, timeoutMs: number): Promise { - const save = await page.evaluate(async (input) => { + await page.evaluate(async (input) => { const win = window as unknown as { wp?: { blocks?: { createBlock?: (name: string, attributes?: Record) => unknown } @@ -1363,31 +1385,19 @@ async function saveEditorPost(page: import("playwright").Page, step: Extract((resolve, reject) => { - const done = () => { - const isSaving = typeof editor.isSavingPost === "function" ? Boolean(editor.isSavingPost()) : false - const didSucceed = typeof editor.didPostSaveRequestSucceed === "function" ? Boolean(editor.didPostSaveRequestSucceed()) : undefined - const didFail = typeof editor.didPostSaveRequestFail === "function" ? Boolean(editor.didPostSaveRequestFail()) : false - if (!isSaving && didSucceed !== false) { - cleanup() - resolve() - } else if (didFail) { - cleanup() - reject(new Error("wp-codebox-editor-save-failed: core/editor savePost reported a failed request")) - } else if (Date.now() > deadline) { - cleanup() - reject(new Error("wp-codebox-editor-save-timeout: timed out waiting for core/editor savePost to settle")) - } - } - const unsubscribe = typeof wpData?.subscribe === "function" ? wpData.subscribe(done) : undefined - const interval = window.setInterval(done, 250) - const cleanup = () => { - window.clearInterval(interval) - if (unsubscribe) unsubscribe() - } - done() - }) + }, { marker: step.marker, content: step.content }) + const settled = await page.waitForFunction(() => { + const editor = (window as unknown as { wp?: { data?: { select?: (store: string) => Record } } }).wp?.data?.select?.("core/editor") + if (!editor) return false + const saving = typeof editor.isSavingPost === "function" ? Boolean(editor.isSavingPost()) : false + const failed = typeof editor.didPostSaveRequestFail === "function" ? Boolean(editor.didPostSaveRequestFail()) : false + const succeeded = typeof editor.didPostSaveRequestSucceed === "function" ? Boolean(editor.didPostSaveRequestSucceed()) : undefined + return !saving && (failed || succeeded !== false) ? { failed } : false + }, undefined, { timeout: timeoutMs }).then((handle) => handle.jsonValue() as Promise<{ failed: boolean }>) + if (settled.failed) throw new Error("wp-codebox-editor-save-failed: core/editor savePost reported a failed request") + const save = await page.evaluate((input) => { + const editor = (window as unknown as { wp?: { data?: { select?: (store: string) => Record } } }).wp?.data?.select?.("core/editor") + if (!editor) throw new Error("wp-codebox-editor-readiness-unavailable: core/editor store is unavailable") const editedContent = typeof editor.getEditedPostContent === "function" ? String(editor.getEditedPostContent() ?? "") : "" return { schema: "wp-codebox/editor-save/v1", @@ -1398,7 +1408,7 @@ async function saveEditorPost(page: import("playwright").Page, step: Extract | null : null const blocks = typeof blockEditor.getBlocks === "function" ? blockEditor.getBlocks() as Array> : [] const serialize = (window as unknown as { wp?: { blocks?: { serialize?: (items: unknown[]) => string } } }).wp?.blocks?.serialize - const toTree = (items: Array>): NonNullable => items.map((block) => ({ - name: typeof block.name === "string" ? block.name : "", - clientId: typeof block.clientId === "string" ? block.clientId : undefined, - attributes: typeof block.attributes === "object" && block.attributes ? block.attributes as Record : undefined, - isValid: typeof block.isValid === "boolean" ? block.isValid : undefined, - innerBlocks: Array.isArray(block.innerBlocks) ? toTree(block.innerBlocks as Array>) : undefined, - })) + const tree: NonNullable = [] + const pending: Array<{ items: Array>; output: NonNullable }> = [{ items: blocks, output: tree }] + while (pending.length > 0) { + const current = pending.pop()! + for (const block of current.items) { + const innerBlocks: NonNullable = [] + current.output.push({ + name: typeof block.name === "string" ? block.name : "", + clientId: typeof block.clientId === "string" ? block.clientId : undefined, + attributes: typeof block.attributes === "object" && block.attributes ? block.attributes as Record : undefined, + isValid: typeof block.isValid === "boolean" ? block.isValid : undefined, + innerBlocks, + }) + if (Array.isArray(block.innerBlocks)) pending.push({ items: block.innerBlocks as Array>, output: innerBlocks }) + } + } const savedContent = typeof currentPost?.content === "object" && currentPost.content ? stringValue((currentPost.content as Record).raw ?? (currentPost.content as Record).rendered) : undefined @@ -1511,7 +1530,7 @@ export async function captureEditorState(page: import("playwright").Page, target status: typeof currentPost?.status === "string" ? currentPost.status : undefined, title: typeof currentPost?.title === "object" && currentPost.title ? (currentPost.title as Record).raw ?? (currentPost.title as Record).rendered : undefined, }, - blocks: toTree(blocks), + blocks: tree, serializedContent: typeof serialize === "function" ? serialize(blocks) : typeof editor.getEditedPostContent === "function" ? String(editor.getEditedPostContent() ?? "") : undefined, dirty: typeof editor.isEditedPostDirty === "function" ? Boolean(editor.isEditedPostDirty()) : undefined, saving: typeof editor.isSavingPost === "function" ? Boolean(editor.isSavingPost()) : undefined, @@ -1957,7 +1976,7 @@ export async function runEditorValidateBlocksCommand({ const content = await editorValidateContentFromArgs(args) const provider = editorValidateProviderFromArgs(args) const waitTimeoutMs = durationArg(args, "wait-timeout", EDITOR_VALIDATE_BLOCKS_READY_TIMEOUT_MS) - const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin) + const topology = editorCommandPreviewTopology(args, runtimeSpec, server) const { preview, networkPolicy } = topology const routeTracker = createBrowserPreviewRouteTracker() const targetUrl = topology.resolveUrl(target.url) diff --git a/packages/runtime-playground/src/preview-server.ts b/packages/runtime-playground/src/preview-server.ts index 668c1055..0df79071 100644 --- a/packages/runtime-playground/src/preview-server.ts +++ b/packages/runtime-playground/src/preview-server.ts @@ -17,6 +17,7 @@ export interface PlaygroundCliServer { writeFile?(path: string, contents: string): Promise } serverUrl: string + wordpressUrl?: string requestWorkerEndpoint?: { route: string; token: string; payloadDirectory: string } previewLease?: PreviewLease previewRoutes?: PlaygroundPreviewRouteRegistry @@ -69,6 +70,7 @@ export async function withPreviewProxy(server: PlaygroundCliServer, port: number return { ...server, serverUrl: proxy.serverUrl, + wordpressUrl: server.wordpressUrl ?? server.serverUrl, previewRoutes: proxy.previewRoutes, previewProxyDiagnostics: proxy.diagnostics, async [Symbol.asyncDispose]() { diff --git a/tests/browser-callback-materialization-contracts.test.ts b/tests/browser-callback-materialization-contracts.test.ts index 0d60a3da..05bb86f3 100644 --- a/tests/browser-callback-materialization-contracts.test.ts +++ b/tests/browser-callback-materialization-contracts.test.ts @@ -206,6 +206,8 @@ const proxied = await withPreviewProxy({ }, } satisfies PlaygroundCliServer, 0) try { + assert.equal(proxied.wordpressUrl, targetServerUrl) + assert.notEqual(proxied.serverUrl, proxied.wordpressUrl) assert.deepEqual(proxied.previewProxyDiagnostics, { schema: "wp-codebox/preview-proxy-diagnostics/v1", upstreamConcurrency: "serialized", diff --git a/tests/editor-actions-save.integration.test.ts b/tests/editor-actions-save.integration.test.ts new file mode 100644 index 00000000..1d0046e6 --- /dev/null +++ b/tests/editor-actions-save.integration.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict" +import { createHash } from "node:crypto" +import { readFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import test from "node:test" + +import { createWordPressRuntime } from "../packages/runtime-playground/src/public.js" + +test("editor actions save persists after reload", { timeout: 180_000 }, async () => { + const artifactsDirectory = join(tmpdir(), `wp-codebox-editor-save-${process.pid}-${Date.now()}`) + const runtime = await createWordPressRuntime({ + environment: { version: "6.5", phpVersion: "8.0", blueprint: { steps: [] }, workers: 6 }, + policy: { + network: "deny", + filesystem: "sandbox", + commands: ["wordpress.run-php", "wordpress.editor-actions"], + secrets: "none", + approvals: "never", + }, + artifactsDirectory, + }) + + try { + const created = await runtime.execute({ + command: "wordpress.run-php", + args: ["code=echo wp_insert_post(array('post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Editor save integration', 'post_content' => '

Before

'));"], + timeoutMs: 60_000, + }) + assert.equal(created.exitCode, 0, created.stderr) + const postId = Number.parseInt(created.stdout.trim(), 10) + assert.ok(Number.isInteger(postId) && postId > 0, `expected a created page id, received ${created.stdout}`) + + const marker = "Persisted editor action marker" + const action = await runtime.execute({ + command: "wordpress.editor-actions", + args: [ + `post-id=${postId}`, + "post-type=page", + "capture=steps,errors,editor-state", + "wait-timeout=45s", + "step-timeout=45s", + "timeout=120s", + `steps-json=${JSON.stringify([ + { kind: "updateBlockAttributes", index: 0, attributes: { content: marker } }, + { kind: "savePost" }, + { kind: "reload" }, + { kind: "inspectState" }, + ])}`, + ], + timeoutMs: 150_000, + }) + assert.equal(action.exitCode, 0, action.stderr || action.stdout) + + const runtimeInfo = await runtime.info() + const state = JSON.parse(await readFile(join(artifactsDirectory, runtimeInfo.id, "files/browser/editor-action-state.json"), "utf8")) as { + dirty?: boolean + serializedContent?: string + serializedContentSha256?: string + } + assert.equal(state.dirty, false) + assert.match(state.serializedContent ?? "", new RegExp(marker)) + assert.ok(state.serializedContentSha256) + const persisted = await runtime.execute({ + command: "wordpress.run-php", + args: [`code=echo get_post_field('post_content', ${postId});`], + timeoutMs: 30_000, + }) + assert.equal(persisted.exitCode, 0, persisted.stderr) + assert.match(persisted.stdout, new RegExp(marker)) + assert.equal(state.serializedContentSha256, createHash("sha256").update(persisted.stdout).digest("hex"), "post-reload editor content must match the persisted WordPress content") + } finally { + await runtime.destroy() + await rm(artifactsDirectory, { recursive: true, force: true }) + } +}) diff --git a/tests/editor-actions.test.ts b/tests/editor-actions.test.ts index 64f7d4bc..1d2c726e 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, 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, 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" @@ -281,6 +281,9 @@ assert.equal(nestedValidity.warnings[0]?.clientId, "nested-invalid") const frontPageTarget = editorOpenTargetFromArgs(["target=front-page"]) assert.equal(frontPageTarget.kind, "front-page") assert.equal(frontPageTarget.url, "") +const editorRuntimeSpec = { wp: "latest", environment: {} } as never +assert.equal(editorCommandWordPressUrl({ serverUrl: "http://preview.test", wordpressUrl: "http://wordpress.test" } as never), "http://wordpress.test") +assert.equal(editorCommandWordPressUrl({ serverUrl: "http://wordpress.test" } as never), "http://wordpress.test") // resolveEditorOpenTarget asks the running WordPress for page_on_front and rewrites // the target to open that exact post in the editor. @@ -291,7 +294,7 @@ const resolved = await resolveEditorOpenTarget(frontPageTarget, { resolveCalls.push(command) return { ok: true, text: "57\n" } as never }, - runtimeSpec: { wp: "latest" } as never, + runtimeSpec: editorRuntimeSpec, server: { serverUrl: "http://localhost" } as never, }) assert.equal(resolved.kind, "post") @@ -314,7 +317,7 @@ const resolvedPostSlug = await resolveEditorOpenTarget(postSlugTarget, { postSlugCalls.push(command) return { ok: true, text: "91\n" } as never }, - runtimeSpec: { wp: "latest" } as never, + runtimeSpec: editorRuntimeSpec, server: { serverUrl: "http://localhost" } as never, }) assert.equal(resolvedPostSlug.kind, "post") @@ -326,7 +329,7 @@ await assert.rejects( resolveEditorOpenTarget(postSlugTarget, { command: "wordpress.editor-open", runPlaygroundCommand: async () => ({ ok: true, text: "0" } as never), - runtimeSpec: { wp: "latest" } as never, + runtimeSpec: editorRuntimeSpec, server: { serverUrl: "http://localhost" } as never, }), /resolved no editable post/, @@ -338,7 +341,7 @@ await assert.rejects( resolveEditorOpenTarget(frontPageTarget, { command: "wordpress.editor-validate-blocks", runPlaygroundCommand: async () => ({ ok: true, text: "0" } as never), - runtimeSpec: { wp: "latest" } as never, + runtimeSpec: editorRuntimeSpec, server: { serverUrl: "http://localhost" } as never, }), /no static front page/, @@ -351,7 +354,7 @@ const passthrough = await resolveEditorOpenTarget(postTarget, { runPlaygroundCommand: async () => { throw new Error("resolveEditorOpenTarget should not run PHP for a concrete target") }, - runtimeSpec: { wp: "latest" } as never, + runtimeSpec: editorRuntimeSpec, server: { serverUrl: "http://localhost" } as never, }) assert.equal(passthrough.url, "/wp-admin/post.php?post=12&action=edit")