Skip to content

Commit 26ddbd5

Browse files
authored
Merge pull request #999 from Automattic/issue-994-replay-export
Fix replay export restore blueprint
2 parents 2642e84 + 0b254e6 commit 26ddbd5

7 files changed

Lines changed: 173 additions & 13 deletions

File tree

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/runtime-playground/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"dependencies": {
2020
"@automattic/wp-codebox-core": "file:../runtime-core",
2121
"@wp-playground/cli": "^3.1.35",
22+
"@wp-playground/storage": "^3.1.35",
2223
"@wp-playground/wordpress-builds": "^0.9.4",
2324
"@types/pngjs": "^6.0.5",
2425
"pixelmatch": "^7.2.0",

packages/runtime-playground/src/playground-cli-runner.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import { existsSync } from "node:fs"
77
import { createServer as createHttpServer, type Server as HttpServer } from "node:http"
88
import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises"
99
import { homedir } from "node:os"
10-
import { basename, join } from "node:path"
10+
import { basename, dirname, join } from "node:path"
1111
import { createServer as createNetServer } from "node:net"
12+
import * as PlaygroundStorage from "@wp-playground/storage"
1213
import { resolveWordPressRelease } from "@wp-playground/wordpress"
1314

1415
export interface PlaygroundCliModule {
@@ -103,7 +104,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
103104
wp: localAssetServer?.url ?? wordpressStartupAsset?.wp,
104105
php: spec.environment.phpVersion,
105106
"site-url": spec.preview?.siteUrl,
106-
blueprint: playgroundBlueprint(spec.environment.blueprint, spec.policy, spec.preview?.siteUrl),
107+
blueprint: playgroundCliBlueprint(spec),
107108
})
108109
} finally {
109110
await localAssetServer?.close()
@@ -135,6 +136,49 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
135136
}
136137
}
137138

139+
function playgroundCliBlueprint(spec: RuntimeCreateSpec): unknown {
140+
const blueprint = playgroundBlueprint(spec.environment.blueprint, spec.policy, spec.preview?.siteUrl)
141+
if (blueprint !== spec.environment.blueprint) {
142+
return blueprint
143+
}
144+
145+
return localBlueprintPackageFilesystem(spec) ?? blueprint
146+
}
147+
148+
function localBlueprintPackageFilesystem(spec: RuntimeCreateSpec): LocalBlueprintPackageFilesystem | undefined {
149+
const task = spec.metadata?.task
150+
if (!task || typeof task !== "object" || Array.isArray(task)) {
151+
return undefined
152+
}
153+
154+
const blueprintPath = (task as Record<string, unknown>).blueprintPath
155+
if (typeof blueprintPath !== "string" || blueprintPath.length === 0) {
156+
return undefined
157+
}
158+
159+
return new LocalBlueprintPackageFilesystem(blueprintPath)
160+
}
161+
162+
class LocalBlueprintPackageFilesystem {
163+
private readonly filesystem: ReadableBlueprintFilesystem
164+
private readonly blueprintFileName: string
165+
166+
constructor(blueprintPath: string) {
167+
const NodeJsFilesystem = (PlaygroundStorage as unknown as { NodeJsFilesystem: new(root: string) => ReadableBlueprintFilesystem }).NodeJsFilesystem
168+
this.filesystem = new NodeJsFilesystem(dirname(blueprintPath))
169+
this.blueprintFileName = basename(blueprintPath)
170+
}
171+
172+
read(path: string): ReturnType<ReadableBlueprintFilesystem["read"]> {
173+
const normalizedPath = path.replace(/\\/g, "/").replace(/^\/+/, "")
174+
return this.filesystem.read(normalizedPath === "blueprint.json" ? this.blueprintFileName : normalizedPath)
175+
}
176+
}
177+
178+
interface ReadableBlueprintFilesystem {
179+
read(path: string): Promise<unknown>
180+
}
181+
138182
async function startPlaygroundCliWithDynamicPortRetry(callback: (port: number) => Promise<PlaygroundCliServer>, fixedPreviewPort: boolean): Promise<PlaygroundCliServer> {
139183
const attempts = fixedPreviewPort ? 1 : 6
140184
for (let attempt = 1; attempt <= attempts; attempt++) {

packages/runtime-playground/src/replayable-wordpress-site-bundle.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
type ArtifactManifest,
88
type RuntimeInfo,
99
} from "@automattic/wp-codebox-core"
10-
import { runtimeSnapshotRestorePhp, type RuntimeSnapshotArtifact } from "./runtime-snapshot.js"
10+
import { runtimeSnapshotRestorePhp, runtimeSnapshotRestorePhpFromFile, type RuntimeSnapshotArtifact } from "./runtime-snapshot.js"
1111

1212
export interface ReplayableWordPressSiteBundleOptions {
1313
directory: string
@@ -244,22 +244,28 @@ export function buildReplayExportBlueprint(
244244
snapshot: RuntimeSnapshotArtifact,
245245
options: Pick<ReplayableWordPressSiteBundleOptions, "landingPage"> = {},
246246
): Record<string, unknown> {
247+
const snapshotPath = "/tmp/wp-codebox-runtime-snapshot.json"
247248
return {
248249
$schema: "https://playground.wordpress.net/blueprint-schema.json",
249250
preferredVersions: {
250251
wp: snapshot.compatibility.wordpressVersion,
251252
php: snapshot.compatibility.phpVersion,
252253
},
253254
landingPage: options.landingPage ?? "/",
254-
steps: [],
255-
"x-wp-codebox": {
256-
schema: "wp-codebox/replay-export-blueprint/v1",
257-
replayStatus: "external-runtime-snapshot",
258-
snapshotPath: "files/runtime-snapshot.json",
259-
restoreCommand: "wordpress.run-php",
260-
restoreCode: "runtimeSnapshotRestorePhp(files/runtime-snapshot.json)",
261-
note: "The runtime snapshot is stored beside this blueprint instead of embedded as one large runPHP string.",
262-
},
255+
steps: [
256+
{
257+
step: "writeFile",
258+
path: snapshotPath,
259+
data: {
260+
resource: "bundled",
261+
path: "files/runtime-snapshot.json",
262+
},
263+
},
264+
{
265+
step: "runPHP",
266+
code: runtimeSnapshotRestorePhpFromFile(snapshotPath),
267+
},
268+
],
263269
}
264270
}
265271

@@ -277,6 +283,13 @@ export function buildReplayableWordPressSiteLimitations(
277283
activePlugins: snapshot.metadata.activePlugins,
278284
},
279285
source: options.source ?? { kind: "unspecified" },
286+
restore: {
287+
schema: "wp-codebox/replay-export-blueprint/v1",
288+
replayStatus: "external-runtime-snapshot",
289+
snapshotPath: "files/runtime-snapshot.json",
290+
restoreSteps: ["writeFile", "runPHP"],
291+
note: "The runtime snapshot is stored beside the blueprint instead of embedded as one large runPHP string.",
292+
},
280293
limitations: [
281294
"The bundle replays captured database tables and wp-content files only.",
282295
"The exporter input must be policy-approved by the caller; this builder does not acquire or authorize private site sources.",

packages/runtime-playground/src/runtime-snapshot.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,11 +373,28 @@ echo wp_codebox_snapshot_json_encode( array(
373373
}
374374

375375
export function runtimeSnapshotRestorePhp(payload: RuntimeSnapshotArtifact): string {
376-
return `${String.raw`
376+
return runtimeSnapshotRestorePhpForPayload(`${String.raw`
377377
$payload = json_decode(<<<'WP_CODEBOX_SNAPSHOT_JSON'
378378
`}${JSON.stringify(payload)}${String.raw`
379379
WP_CODEBOX_SNAPSHOT_JSON
380380
, true );
381+
`}`)
382+
}
383+
384+
export function runtimeSnapshotRestorePhpFromFile(path: string): string {
385+
const encodedPath = JSON.stringify(path)
386+
return runtimeSnapshotRestorePhpForPayload(`${String.raw`
387+
$wp_codebox_snapshot_path = `}${encodedPath}${String.raw`;
388+
$wp_codebox_snapshot_json = file_get_contents( $wp_codebox_snapshot_path );
389+
if ( false === $wp_codebox_snapshot_json ) {
390+
throw new RuntimeException( 'Failed to read WordPress runtime snapshot payload: ' . $wp_codebox_snapshot_path );
391+
}
392+
$payload = json_decode( $wp_codebox_snapshot_json, true );
393+
`}`)
394+
}
395+
396+
function runtimeSnapshotRestorePhpForPayload(payloadLoaderPhp: string): string {
397+
return `${payloadLoaderPhp}${String.raw`
381398
382399
if ( ! is_array( $payload ) || ( $payload['schema'] ?? '' ) !== 'wp-codebox/wordpress-runtime-snapshot/v1' ) {
383400
throw new RuntimeException( 'Invalid WordPress runtime snapshot payload.' );
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { validateBlueprint } from "@wp-playground/blueprints"
2+
import { buildReplayExportBlueprint } from "../packages/runtime-playground/src/replayable-wordpress-site-bundle.ts"
3+
import type { RuntimeSnapshotArtifact } from "../packages/runtime-playground/src/runtime-snapshot.ts"
4+
5+
const snapshot: RuntimeSnapshotArtifact = {
6+
schema: "wp-codebox/wordpress-runtime-snapshot/v1",
7+
version: 1,
8+
id: "snapshot-smoke",
9+
createdAt: "2026-06-15T00:00:00.000Z",
10+
compatibility: {
11+
backend: "wordpress-playground",
12+
wordpressVersion: "latest",
13+
phpVersion: "8.3",
14+
},
15+
metadata: {
16+
runtime: {
17+
id: "runtime-smoke",
18+
backend: "wordpress-playground",
19+
status: "destroyed",
20+
createdAt: "2026-06-15T00:00:00.000Z",
21+
environment: {
22+
kind: "wordpress",
23+
name: "runtime-smoke",
24+
version: "latest",
25+
},
26+
},
27+
mounts: [],
28+
mountedInputs: [],
29+
activeTheme: "twentytwentyfour",
30+
activePlugins: [],
31+
wpContentPath: "/wordpress/wp-content",
32+
},
33+
database: { tables: [] },
34+
files: [
35+
{
36+
scope: "wp-content",
37+
path: "smoke.txt",
38+
bytes: 5,
39+
sha256: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
40+
base64: "aGVsbG8=",
41+
},
42+
],
43+
hashes: {
44+
database: { algorithm: "sha256", value: "database-smoke" },
45+
files: { algorithm: "sha256", value: "files-smoke" },
46+
},
47+
}
48+
49+
const blueprint = buildReplayExportBlueprint(snapshot)
50+
const validation = validateBlueprint(blueprint)
51+
52+
if (!validation.valid) {
53+
throw new Error(`Replay export blueprint is not schema-valid: ${JSON.stringify(validation.errors, null, 2)}`)
54+
}
55+
56+
if ("x-wp-codebox" in blueprint) {
57+
throw new Error("Replay export blueprint must not include top-level x-wp-codebox metadata")
58+
}
59+
60+
if (!Array.isArray(blueprint.steps) || blueprint.steps.length === 0) {
61+
throw new Error("Replay export blueprint must include restore steps")
62+
}
63+
64+
const serialized = JSON.stringify(blueprint)
65+
if (serialized.includes(snapshot.files[0].base64)) {
66+
throw new Error("Replay export blueprint must not inline runtime snapshot file payloads")
67+
}
68+
69+
const [writeSnapshotStep, restoreStep] = blueprint.steps as Array<Record<string, unknown>>
70+
if (writeSnapshotStep?.step !== "writeFile") {
71+
throw new Error("Replay export blueprint must first write the external runtime snapshot into Playground")
72+
}
73+
74+
const writeSnapshotData = writeSnapshotStep.data as Record<string, unknown> | undefined
75+
if (writeSnapshotData?.resource !== "bundled" || writeSnapshotData.path !== "files/runtime-snapshot.json") {
76+
throw new Error("Replay export blueprint must reference files/runtime-snapshot.json as a bundled external resource")
77+
}
78+
79+
if (restoreStep?.step !== "runPHP" || typeof restoreStep.code !== "string" || !restoreStep.code.includes("file_get_contents")) {
80+
throw new Error("Replay export blueprint must restore by reading the written runtime snapshot file")
81+
}
82+
83+
console.log("replay-export-blueprint-smoke passed")

scripts/smoke-manifest.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export const smokeGroups = {
6161
tsxSmoke("artifact-diagnostics-normalizer-smoke"),
6262
tsxSmoke("partial-artifact-discovery-smoke"),
6363
tsxSmoke("mounted-workspace-diff-smoke"),
64+
tsxSmoke("replay-export-blueprint-smoke"),
6465
],
6566
},
6667
runtime: {

0 commit comments

Comments
 (0)