Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/runtime-core/src/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,26 @@ export const commandRegistry = [
recipe: true,
handler: { kind: "playground", method: "runExportReplayPackage" },
},
{
id: "wordpress.browser-export-replay-package",
description: "Export a nested browser Playground WordPress site as a replay package after browser-side materialization.",
acceptedArgs: [
{ name: "label", description: "Optional human-readable export label recorded in the command output and package source metadata.", format: "string" },
{ name: "url", description: "Outer browser page URL to load before resolving the nested Playground client; defaults to /.", format: "path or URL" },
{ name: "client-expression", description: "Browser JavaScript expression that resolves to the nested Playground client; defaults to window.studioWebPreviewClient.", format: "JavaScript expression" },
{ name: "helper-expression", description: "Browser JavaScript expression that resolves to the WP Codebox browser helper; defaults to window.wpCodeboxBrowser.", format: "JavaScript expression" },
{ name: "prepare-expression", description: "Optional async browser JavaScript expression that prepares the outer page before resolving the nested Playground client.", format: "JavaScript expression" },
{ name: "reload-after-prepare", description: "Reload the outer browser page after prepare-expression completes.", format: "boolean" },
{ name: "output-dir", description: "Optional package directory relative to the runtime artifact root; defaults to files/replay-package.", format: "relative path" },
{ name: "landing-page", description: "Optional replay landing page recorded in blueprint.after.json.", format: "path" },
{ name: "import-ms", description: "Optional importer duration supplied by the caller so replay export metrics can include the preceding import phase.", format: "non-negative integer" },
...snapshotScopingAcceptedArgs,
],
outputShape: "wp-codebox/wordpress-replay-export/v1 JSON with import/browser-export/snapshot/export metrics and manifest, blueprint.after.json, blueprint.after-notes.json, blueprint.zip, and files/runtime-snapshot.json artifact paths.",
policyRequirement: "Runtime policy commands must include wordpress.browser-export-replay-package.",
recipe: true,
handler: { kind: "playground", method: "runBrowserExportReplayPackage" },
},
{
id: "wordpress.ability",
description: "Execute a registered WordPress Ability in the sandbox.",
Expand Down
6 changes: 5 additions & 1 deletion packages/runtime-core/src/runtime-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,12 +364,16 @@ function runtimeEpisodeObservationDigestPayload(observation: ObservationResult):
}

function runtimeEpisodeSnapshotDigestPayload(snapshot: Snapshot): Record<string, unknown> {
const metadata = snapshot.metadata && typeof snapshot.metadata === "object" && !Array.isArray(snapshot.metadata)
? Object.fromEntries(Object.entries(snapshot.metadata).filter(([key]) => key !== "payload" && key !== "artifact"))
: snapshot.metadata

return {
schema: "wp-codebox/runtime-episode-snapshot/v1",
id: snapshot.id,
createdAt: snapshot.createdAt,
semantics: snapshot.semantics,
metadata: snapshot.metadata,
metadata,
artifactRefs: snapshot.artifactRefs ?? [],
}
}
Expand Down
15 changes: 9 additions & 6 deletions packages/runtime-playground/src/artifact-bundle-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,17 +132,19 @@ export class ArtifactBundleBuilder {
probes: source.browserArtifacts(),
redactor,
})
const replaySnapshot = portableSiteReplaySnapshot(
await firstRuntimeStateSnapshotPayload(source.snapshots),
source.spec.environment.blueprint,
)
const replayExportPackageFiles = replayExportPackageManifestFiles(source.artifactRoot, source.commands)
const replaySnapshot = replayExportPackageFiles.length > 0
? undefined
: portableSiteReplaySnapshot(
await firstRuntimeStateSnapshotPayload(source.snapshots),
source.spec.environment.blueprint,
)
const runtimeSnapshots = spec.includeRuntimeSnapshotBundles ? source.snapshots : []
const runtimeSnapshotFiles = runtimeSnapshots.flatMap((snapshot) =>
(snapshot.artifactRefs ?? [])
.filter((ref): ref is typeof ref & { path: string } => typeof ref.path === "string" && ref.path.length > 0)
.map((ref) => artifactManifestFile(join(source.artifactRoot, ref.path), "runtime-snapshot", "application/json")),
)
const replayExportPackageFiles = replayExportPackageManifestFiles(source.artifactRoot, source.commands)
const capturedMounts = await source.captureMountedFiles(filesDirectory, redactor)
const { mountDiffs, changedFiles, patch, diagnostics: mountDiffDiagnostics } = await source.captureMountDiffs(filesDirectory, redactor)
const changedFilesJson = redactor.redact("files/changed-files.json", `${JSON.stringify(changedFiles, null, 2)}\n`)
Expand Down Expand Up @@ -1091,7 +1093,7 @@ function artifactPreviewContentType(path: string): string {

function replayExportPackageManifestFiles(artifactRoot: string, commands: ExecutionResult[]): ArtifactManifestFile[] {
return commands.flatMap((command) => {
if (command.command !== "wordpress.export-replay-package" || command.exitCode !== 0) {
if (!["wordpress.export-replay-package", "wordpress.browser-export-replay-package"].includes(command.command) || command.exitCode !== 0) {
return []
}

Expand All @@ -1112,6 +1114,7 @@ function replayExportPackageManifestFiles(artifactRoot: string, commands: Execut
const refs: Array<{ key: string; kind: string; contentType: string }> = [
{ key: "manifest", kind: "replay-package-manifest", contentType: "application/json" },
{ key: "blueprint", kind: "blueprint-after", contentType: "application/json" },
{ key: "playgroundBundle", kind: "playground-blueprint-bundle", contentType: "application/zip" },
{ key: "snapshot", kind: "runtime-snapshot", contentType: "application/json" },
{ key: "notes", kind: "blueprint-after-notes", contentType: "application/json" },
]
Expand Down
268 changes: 267 additions & 1 deletion packages/runtime-playground/src/browser-command-runners.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto"
import { access, mkdir, readFile, writeFile } from "node:fs/promises"
import { dirname, join, relative } from "node:path"
import { assertRuntimeCommandAllowed, browserInteractionScriptUsesEvaluate, validateBrowserInteractionScript, type BrowserInteractionStep, type ExecutionSpec, type RuntimeCreateSpec } from "@automattic/wp-codebox-core"
import { assertRuntimeCommandAllowed, browserInteractionScriptUsesEvaluate, validateBrowserInteractionScript, type BrowserInteractionStep, type ExecutionSpec, type RuntimeCreateSpec, type RuntimeInfo } from "@automattic/wp-codebox-core"
import pixelmatch from "pixelmatch"
import { PNG } from "pngjs"
import { browserInteractionStepsFromArgs, browserStepTimeoutMs, durationStringMs, sanitizeScreenshotName } from "./browser-actions.js"
Expand All @@ -18,6 +18,8 @@ import { editorActionStepsFromArgs, editorOpenTargetFromArgs, type EditorActionS
import { bootstrapPhpCode } from "./php-bootstrap.js"
import { assertPlaygroundResponseOk, type PlaygroundRunResponse } from "./playground-command-errors.js"
import type { PlaygroundCliServer } from "./preview-server.js"
import { writeReplayExportPackage } from "./replayable-wordpress-site-bundle.js"
import { contentDigest, runtimeSnapshotExportPhp, type RuntimeSnapshotArtifact, type RuntimeSnapshotExportOptions } from "./runtime-snapshot.js"
import type { Page } from "playwright"

const BROWSER_STEP_DEFAULT_TIMEOUT_MS = 15_000
Expand Down Expand Up @@ -2219,6 +2221,175 @@ function editorCanvasTimeoutMs(args: string[]): number {
return durationArg(args, "timeout", EDITOR_CANVAS_DEFAULT_TIMEOUT_MS)
}

interface BrowserRuntimeSnapshotExportResult {
schema: "wp-codebox/wordpress-runtime-snapshot-export-manifest/v1"
compatibility: RuntimeSnapshotArtifact["compatibility"]
metadata: Omit<RuntimeSnapshotArtifact["metadata"], "runtime" | "mounts" | "mountedInputs">
database: {
tables: Array<{
name: string
createSql: string
rowCount: number
chunks: string[]
}>
}
files: {
ndjsonPath: string
}
}

async function exportNestedBrowserRuntimeSnapshot(page: Page, options: { clientExpression: string; helperExpression: string; phpCode: string; runtimeInfo: RuntimeInfo }): Promise<RuntimeSnapshotArtifact> {
const createdAt = now()
const payload = await page.evaluate(async ({ clientExpression, helperExpression, phpCode }) => {
const resolveExpression = (source: string) => Function(`return (${source})`)()
const responseText = async (response: unknown): Promise<string> => {
if (typeof response === "string") return response
if (response && typeof response === "object") {
const record = response as Record<string, unknown>
if (typeof record.text === "string") return record.text
if (typeof record.text === "function") return await (record.text as () => Promise<string>)()
for (const key of ["stdout", "output", "body"] as const) {
if (typeof record[key] === "string") return record[key] as string
}
if (ArrayBuffer.isView(record.bytes) || Array.isArray(record.bytes)) return new TextDecoder().decode(new Uint8Array(record.bytes as ArrayLike<number>))
}
return ""
}
const readFileAsText = async (client: Record<string, unknown>, path: string): Promise<string> => {
const attempts = [
() => (client.readFileAsText as (path: string) => Promise<string>)(path),
() => (client.readFileAsText as (input: { path: string }) => Promise<string>)({ path }),
() => (client.readFile as (path: string) => Promise<unknown>)(path),
() => (client.readFile as (input: { path: string }) => Promise<unknown>)({ path }),
]
let lastError: unknown
for (const attempt of attempts) {
try {
const value = await attempt()
if (typeof value === "string") return value
if (value instanceof Uint8Array) return new TextDecoder().decode(value)
if (Array.isArray(value)) return new TextDecoder().decode(new Uint8Array(value))
if (value && typeof value === "object") {
const record = value as Record<string, unknown>
if (typeof record.text === "string") return record.text
if (typeof record.content === "string") return record.content
if (ArrayBuffer.isView(record.bytes) || Array.isArray(record.bytes)) return new TextDecoder().decode(new Uint8Array(record.bytes as ArrayLike<number>))
}
} catch (error) {
lastError = error
}
}
throw new Error(`Nested Playground file read failed for ${path}: ${lastError instanceof Error ? lastError.message : String(lastError)}`)
}

const client = resolveExpression(clientExpression) as Record<string, unknown>
const helper = resolveExpression(helperExpression) as { runPhpRequest?: (client: unknown, options: Record<string, unknown>) => Promise<unknown> }
if (!client) throw new Error("Nested Playground client expression did not resolve to a client.")
if (typeof helper?.runPhpRequest !== "function") throw new Error("WP Codebox browser helper does not expose runPhpRequest().")
const manifestResponse = await helper.runPhpRequest(client, {
name: "wp-codebox-browser-replay-export",
expectJson: true,
code: phpCode,
})
const manifest = (manifestResponse && typeof manifestResponse === "object" && "schema" in manifestResponse)
? manifestResponse as BrowserRuntimeSnapshotExportResult
: (manifestResponse as { data?: unknown })?.data as BrowserRuntimeSnapshotExportResult
if (!manifest || manifest.schema !== "wp-codebox/wordpress-runtime-snapshot-export-manifest/v1") {
throw new Error("Nested Playground runtime snapshot export did not return a supported manifest.")
}
const tables = []
for (const table of manifest.database.tables) {
const rows = []
for (const chunkPath of table.chunks) {
const chunkRows = JSON.parse(await readFileAsText(client, chunkPath))
if (!Array.isArray(chunkRows)) throw new Error(`Nested Playground snapshot table chunk is not an array: ${chunkPath}`)
rows.push(...chunkRows)
}
tables.push({ name: table.name, createSql: table.createSql, rowCount: table.rowCount, rows })
}
const files = []
for (const line of (await readFileAsText(client, manifest.files.ndjsonPath)).split("\n")) {
if (line.trim()) files.push(JSON.parse(line))
}
return {
compatibility: manifest.compatibility,
metadata: manifest.metadata,
database: { tables },
files,
}
}, options)

const snapshot: RuntimeSnapshotArtifact = {
schema: "wp-codebox/wordpress-runtime-snapshot/v1",
version: 1,
id: `runtime-snapshot-${contentDigest({ createdAt, compatibility: payload.compatibility, metadata: payload.metadata, database: payload.database, files: payload.files }).value}`,
createdAt,
compatibility: payload.compatibility,
metadata: {
...payload.metadata,
runtime: options.runtimeInfo,
mounts: [],
mountedInputs: [],
},
database: payload.database,
files: payload.files,
hashes: {
database: contentDigest(payload.database),
files: contentDigest(payload.files),
},
}
return snapshot
}

function browserRuntimeInfo(runtimeSpec: RuntimeCreateSpec): RuntimeInfo {
return {
id: `browser-runtime-export-${Date.now().toString(36)}`,
backend: "wordpress-playground",
status: "destroyed",
createdAt: now(),
environment: runtimeSpec.environment,
}
}

function browserReplayExportOutputDirectory(artifactRoot: string, requested: string | undefined): string {
const relativePath = requested?.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "") || "files/replay-package"
if (relativePath.length === 0 || relativePath.includes("..")) {
throw new Error("wordpress.browser-export-replay-package output-dir must be a relative path inside the runtime artifact root")
}

return join(artifactRoot, relativePath)
}

function nonNegativeIntegerArg(args: string[], name: string): number | undefined {
const raw = argValue(args, name)
if (!raw) return undefined
const parsed = Number.parseInt(raw, 10)
return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined
}

function browserSnapshotOptionsFromArgs(args: string[]): RuntimeSnapshotExportOptions {
const options: RuntimeSnapshotExportOptions = {}
const includedWpContentPaths = commaListArg(args, "snapshot-include-wp-content")
const excludedWpContentPaths = commaListArg(args, "snapshot-exclude-wp-content")
const includedDatabaseTables = commaListArg(args, "snapshot-database-tables")
const excludedDatabaseTables = commaListArg(args, "snapshot-exclude-database-tables")
const includedOptionNames = commaListArg(args, "snapshot-option-names")
const includedPostTypes = commaListArg(args, "snapshot-post-types")

if (includedWpContentPaths) options.includedWpContentPaths = includedWpContentPaths
if (excludedWpContentPaths) options.excludedWpContentPaths = excludedWpContentPaths
if (includedDatabaseTables) options.includedDatabaseTables = includedDatabaseTables
if (excludedDatabaseTables) options.excludedDatabaseTables = excludedDatabaseTables
if (includedOptionNames) options.includedOptionNames = includedOptionNames
if (includedPostTypes) options.includedPostTypes = includedPostTypes

return options
}

function hasBrowserSnapshotOptions(options: RuntimeSnapshotExportOptions): boolean {
return Object.keys(options).length > 0
}

export async function runBrowserActionsCommand({
artifactRoot,
plan,
Expand Down Expand Up @@ -2567,6 +2738,101 @@ export async function runBrowserActionsCommand({
}
}

export async function runBrowserExportReplayPackageCommand({
artifactRoot,
runtimeSpec,
server,
spec,
}: {
artifactRoot: string
runtimeSpec: RuntimeCreateSpec
server: PlaygroundCliServer
spec: ExecutionSpec
}): Promise<string> {
const args = spec.args ?? []
const label = argValue(args, "label")
const landingPage = argValue(args, "landing-page")
const outputDirectory = browserReplayExportOutputDirectory(artifactRoot, argValue(args, "output-dir"))
const importMs = nonNegativeIntegerArg(args, "import-ms") ?? 0
const clientExpression = argValue(args, "client-expression") || "window.studioWebPreviewClient"
const helperExpression = argValue(args, "helper-expression") || "window.wpCodeboxBrowser"
const prepareExpression = argValue(args, "prepare-expression")
const reloadAfterPrepare = strictBooleanArg(args, "reload-after-prepare", false)
const snapshotOptions = browserSnapshotOptionsFromArgs(args)
const snapshotOptionsMetadata = hasBrowserSnapshotOptions(snapshotOptions) ? { snapshotOptions } : {}
const preview = browserPreviewRouting(args, runtimeSpec, server.serverUrl)
const targetUrl = resolveBrowserPreviewUrl(argValue(args, "url") || "/", preview.effectiveOrigin)
const browser = await launchChromiumBrowser()
const startedAtMs = Date.now()

try {
const page = await browser.newPage()
await page.goto(targetUrl, { waitUntil: "domcontentloaded" })
if (prepareExpression) {
await page.evaluate(async (source) => {
const run = new Function(`return (async () => {\n${source}\n})()`)
return run()
}, prepareExpression)
if (reloadAfterPrepare) {
await page.reload({ waitUntil: "domcontentloaded" })
}
}
await page.waitForFunction(({ clientExpression: clientSource, helperExpression: helperSource }) => {
const resolveExpression = (source: string) => Function(`return (${source})`)()
const client = resolveExpression(clientSource)
const helper = resolveExpression(helperSource)
return Boolean(client && helper && typeof helper.runPhpRequest === "function")
}, { clientExpression, helperExpression }, { timeout: 60_000 })

const snapshotStartedAtMs = Date.now()
const snapshot = await exportNestedBrowserRuntimeSnapshot(page, {
clientExpression,
helperExpression,
phpCode: `<?php require_once '/wordpress/wp-load.php'; ${runtimeSnapshotExportPhp(snapshotOptions)}`,
runtimeInfo: browserRuntimeInfo(runtimeSpec),
})
const snapshotMs = Date.now() - snapshotStartedAtMs
const exportStartedAtMs = Date.now()
const replayPackage = await writeReplayExportPackage(snapshot, {
directory: outputDirectory,
landingPage,
importMs,
materializeMs: 0,
snapshotMs,
source: {
...(label ? { label } : {}),
command: "wordpress.browser-export-replay-package",
outerRuntimeServerUrl: server.serverUrl,
targetUrl,
clientExpression,
...snapshotOptionsMetadata,
},
})
replayPackage.metrics.exportMs = Date.now() - exportStartedAtMs

return `${JSON.stringify({
schema: "wp-codebox/wordpress-replay-export/v1",
status: replayPackage.status,
...(label ? { label } : {}),
replayStatus: "replayable-runtime-state",
directory: replayPackage.directory,
metrics: {
...replayPackage.metrics,
browserMs: Date.now() - startedAtMs,
},
artifacts: replayPackage.artifacts,
manifest: {
id: replayPackage.manifest.id,
contentDigest: replayPackage.manifest.contentDigest,
createdAt: replayPackage.manifest.createdAt,
},
...snapshotOptionsMetadata,
}, null, 2)}\n`
} finally {
await browser.close()
}
}

async function browserActionsRunPlanFromArgs(args: string[]): Promise<BrowserActionsRunPlan> {
const capture = new Set(commaListArg(args, "capture"))
if (capture.size === 0) {
Expand Down
Loading