From b5543528e07b24aa2b8fd285ecaa9ebeccba3b65 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 16 Jun 2026 10:40:41 -0400 Subject: [PATCH] Add browser replay package export --- packages/runtime-core/src/command-registry.ts | 20 ++ .../runtime-core/src/runtime-reference.ts | 6 +- .../src/artifact-bundle-builder.ts | 15 +- .../src/browser-command-runners.ts | 268 +++++++++++++++++- .../runtime-playground/src/command-router.ts | 2 + .../src/playground-runtime.ts | 12 +- 6 files changed, 314 insertions(+), 9 deletions(-) diff --git a/packages/runtime-core/src/command-registry.ts b/packages/runtime-core/src/command-registry.ts index 8856e36b..9233772d 100644 --- a/packages/runtime-core/src/command-registry.ts +++ b/packages/runtime-core/src/command-registry.ts @@ -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.", diff --git a/packages/runtime-core/src/runtime-reference.ts b/packages/runtime-core/src/runtime-reference.ts index d00ba249..88885c23 100644 --- a/packages/runtime-core/src/runtime-reference.ts +++ b/packages/runtime-core/src/runtime-reference.ts @@ -364,12 +364,16 @@ function runtimeEpisodeObservationDigestPayload(observation: ObservationResult): } function runtimeEpisodeSnapshotDigestPayload(snapshot: Snapshot): Record { + 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 ?? [], } } diff --git a/packages/runtime-playground/src/artifact-bundle-builder.ts b/packages/runtime-playground/src/artifact-bundle-builder.ts index 7a5a47db..54c96b5e 100644 --- a/packages/runtime-playground/src/artifact-bundle-builder.ts +++ b/packages/runtime-playground/src/artifact-bundle-builder.ts @@ -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`) @@ -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 [] } @@ -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" }, ] diff --git a/packages/runtime-playground/src/browser-command-runners.ts b/packages/runtime-playground/src/browser-command-runners.ts index f056d721..f3805d70 100644 --- a/packages/runtime-playground/src/browser-command-runners.ts +++ b/packages/runtime-playground/src/browser-command-runners.ts @@ -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" @@ -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 @@ -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 + 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 { + 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 => { + if (typeof response === "string") return response + if (response && typeof response === "object") { + const record = response as Record + if (typeof record.text === "string") return record.text + if (typeof record.text === "function") return await (record.text as () => Promise)() + 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)) + } + return "" + } + const readFileAsText = async (client: Record, path: string): Promise => { + const attempts = [ + () => (client.readFileAsText as (path: string) => Promise)(path), + () => (client.readFileAsText as (input: { path: string }) => Promise)({ path }), + () => (client.readFile as (path: string) => Promise)(path), + () => (client.readFile as (input: { path: string }) => Promise)({ 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 + 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)) + } + } 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 + const helper = resolveExpression(helperExpression) as { runPhpRequest?: (client: unknown, options: Record) => Promise } + 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, @@ -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 { + 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: ` { const capture = new Set(commaListArg(args, "capture")) if (capture.size === 0) { diff --git a/packages/runtime-playground/src/command-router.ts b/packages/runtime-playground/src/command-router.ts index da502287..fb735681 100644 --- a/packages/runtime-playground/src/command-router.ts +++ b/packages/runtime-playground/src/command-router.ts @@ -9,6 +9,7 @@ interface PlaygroundCommandRuntime { runWpCli(spec: ExecutionSpec): Promise runCaptureStateBundle(spec: ExecutionSpec): Promise runExportReplayPackage(spec: ExecutionSpec): Promise + runBrowserExportReplayPackage(spec: ExecutionSpec): Promise runRestRequest(spec: ExecutionSpec): Promise runAbility(spec: ExecutionSpec): Promise runBench(spec: ExecutionSpec): Promise @@ -32,6 +33,7 @@ const playgroundCommandHandlers = { "wordpress.wp-cli": (runtime, spec) => runtime.runWpCli(spec), "wordpress.capture-state-bundle": (runtime, spec) => runtime.runCaptureStateBundle(spec), "wordpress.export-replay-package": (runtime, spec) => runtime.runExportReplayPackage(spec), + "wordpress.browser-export-replay-package": (runtime, spec) => runtime.runBrowserExportReplayPackage(spec), "wordpress.rest-request": (runtime, spec) => runtime.runRestRequest(spec), "wordpress.ability": (runtime, spec) => runtime.runAbility(spec), "wordpress.bench": (runtime, spec) => runtime.runBench(spec), diff --git a/packages/runtime-playground/src/playground-runtime.ts b/packages/runtime-playground/src/playground-runtime.ts index 39734015..6ddba98b 100644 --- a/packages/runtime-playground/src/playground-runtime.ts +++ b/packages/runtime-playground/src/playground-runtime.ts @@ -4,7 +4,7 @@ import type { IncomingMessage, ServerResponse } from "node:http" import { dirname, join, resolve } from "node:path" import { HostToolRegistry, RUNTIME_EPISODE_OBSERVATION_SCHEMA, RUNTIME_EPISODE_SNAPSHOT_SCHEMA, assertRuntimeCommandAllowed, createHostToolRegistry, runtimeEpisodeDigest } from "@automattic/wp-codebox-core" import { browserReviewSummary as browserArtifactReviewSummary, type BrowserArtifact } from "./browser-artifacts.js" -import { isBrowserCommandArtifactError, runBrowserActionsCommand, runBrowserProbeCommand, runBrowserScenarioCommand, runEditorActionsCommand, runEditorCanvasProbeCommand, runEditorOpenCommand, runHtmlCaptureCommand, runVisualCompareCommand, wordpressAdminAuthCookiePhpCode } from "./browser-command-runners.js" +import { isBrowserCommandArtifactError, runBrowserActionsCommand, runBrowserExportReplayPackageCommand, runBrowserProbeCommand, runBrowserScenarioCommand, runEditorActionsCommand, runEditorCanvasProbeCommand, runEditorOpenCommand, runHtmlCaptureCommand, runVisualCompareCommand, wordpressAdminAuthCookiePhpCode } from "./browser-command-runners.js" import type { PluginCheckArtifact, ThemeCheckArtifact } from "./check-artifacts.js" import { executePlaygroundCommand } from "./command-router.js" import { cleanWpCliOutput, shellArgv, wpCliCommandFromArgs, wpCliPhpScript } from "./commands.js" @@ -892,6 +892,16 @@ class PlaygroundRuntime implements Runtime { }, null, 2)}\n` } + async runBrowserExportReplayPackage(spec: ExecutionSpec): Promise { + const server = await this.bootPlayground() + return runBrowserExportReplayPackageCommand({ + artifactRoot: this.artifactRoot, + runtimeSpec: this.spec, + server, + spec, + }) + } + async runPluginCheck(spec: ExecutionSpec): Promise { const server = await this.bootPlayground() const result = await runPluginCheckCommand({