From 9627961e78fd75d7a225842381437a1688ab94f9 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 29 May 2026 21:00:08 -0400 Subject: [PATCH 1/3] fix: inline browser runtime bundles --- packages/runtime-playground/src/index.ts | 26 ++++++----- .../src/class-wp-codebox-abilities.php | 45 ++++++++++++++++--- .../class-wp-codebox-task-input-contract.php | 2 + tests/smoke-wordpress-plugin.php | 6 +-- 4 files changed, 59 insertions(+), 20 deletions(-) diff --git a/packages/runtime-playground/src/index.ts b/packages/runtime-playground/src/index.ts index 427d16d..68d4af1 100644 --- a/packages/runtime-playground/src/index.ts +++ b/packages/runtime-playground/src/index.ts @@ -1234,9 +1234,10 @@ class PlaygroundRuntime implements Runtime { let screenshotSha256: string | undefined let viewport: BrowserProbeViewport | null = null let scriptResult: unknown + let page: Page | null = null try { - const page = await browser.newPage() + page = await browser.newPage() viewport = await browserProbeViewport(page) if (capture.has("console")) { page.on("console", (message) => consoleMessages.push(serializeBrowserConsoleMessage(message))) @@ -1261,20 +1262,23 @@ class PlaygroundRuntime implements Runtime { } finalUrl = page.url() - if (capture.has("html")) { - const html = await page.content() - await writeFile(htmlPath, html) - htmlSha256 = sha256(Buffer.from(html, "utf8")) - } - - if (capture.has("screenshot")) { - await page.screenshot({ path: screenshotPath, fullPage: true }) - screenshotSha256 = await fileSha256(screenshotPath) - } } catch (error) { errors.push(serializeBrowserError("probe-error", error)) throw error } finally { + if (page) { + finalUrl = page.url() + if (capture.has("html")) { + const html = await page.content() + await writeFile(htmlPath, html) + htmlSha256 = sha256(Buffer.from(html, "utf8")) + } + + if (capture.has("screenshot")) { + await page.screenshot({ path: screenshotPath, fullPage: true }) + screenshotSha256 = await fileSha256(screenshotPath) + } + } await browser.close() if (capture.has("console")) { await writeFile(consolePath, jsonLines(consoleMessages)) diff --git a/packages/wordpress-plugin/src/class-wp-codebox-abilities.php b/packages/wordpress-plugin/src/class-wp-codebox-abilities.php index f27f729..fc07440 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-abilities.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-abilities.php @@ -87,8 +87,8 @@ private function register(): void { 'input_schema' => array( 'type' => 'object', 'anyOf' => array( - array( 'required' => array( 'goal' ) ), - array( 'required' => array( 'task' ) ), + array( 'type' => 'object', 'required' => array( 'goal' ) ), + array( 'type' => 'object', 'required' => array( 'task' ) ), ), 'properties' => array( 'goal' => $task_input_schema['properties']['goal'], @@ -291,8 +291,8 @@ private function register(): void { 'input_schema' => array( 'type' => 'object', 'anyOf' => array( - array( 'required' => array( 'goal' ) ), - array( 'required' => array( 'task' ) ), + array( 'type' => 'object', 'required' => array( 'goal' ) ), + array( 'type' => 'object', 'required' => array( 'task' ) ), ), 'properties' => array( 'goal' => $task_input_schema['properties']['goal'], @@ -1568,8 +1568,13 @@ private static function browser_package_component_plugin( string $slug, string $ return new WP_Error( 'wp_codebox_browser_plugin_package_hash_failed', 'Could not hash browser runtime plugin package.', array( 'status' => 500, 'slug' => $slug ) ); } + $data_url = self::browser_plugin_data_url( $zip_path, $slug ); + if ( is_wp_error( $data_url ) ) { + return $data_url; + } + return array( - 'url' => $url, + 'url' => $data_url, 'path' => $zip_path, 'sha256' => $sha256, ); @@ -1622,13 +1627,33 @@ private static function browser_package_remote_plugin( string $slug, string $url return new WP_Error( 'wp_codebox_browser_plugin_package_url_missing', 'Browser runtime plugin package URL is missing.', array( 'status' => 500, 'slug' => $slug ) ); } + $data_url = self::browser_plugin_data_url( $zip_path, $slug ); + if ( is_wp_error( $data_url ) ) { + return $data_url; + } + return array( - 'url' => $url, + 'url' => $data_url, 'path' => $zip_path, 'sha256' => $sha256, ); } + private static function browser_plugin_data_url( string $zip_path, string $slug ): string|WP_Error { + $max_bytes = (int) apply_filters( 'wp_codebox_browser_plugin_data_url_max_bytes', 24 * 1024 * 1024, $zip_path, $slug ); + $size = filesize( $zip_path ); + if ( is_int( $size ) && $size > $max_bytes ) { + return new WP_Error( 'wp_codebox_browser_plugin_package_too_large', 'Browser runtime plugin package is too large for inline browser delivery.', array( 'status' => 500, 'slug' => $slug, 'bytes' => $size, 'max_bytes' => $max_bytes ) ); + } + + $contents = file_get_contents( $zip_path ); + if ( ! is_string( $contents ) || '' === $contents ) { + return new WP_Error( 'wp_codebox_browser_plugin_package_read_failed', 'Could not read browser runtime plugin package.', array( 'status' => 500, 'slug' => $slug ) ); + } + + return 'data:application/zip;base64,' . base64_encode( $contents ); + } + private static function browser_download_remote_plugin( string $url, string $zip_path, string $slug ): true|WP_Error { $request = function_exists( 'wp_safe_remote_get' ) ? 'wp_safe_remote_get' : ( function_exists( 'wp_remote_get' ) ? 'wp_remote_get' : null ); if ( null === $request ) { @@ -1800,6 +1825,14 @@ private static function normalize_browser_plugins( array $plugins, string $field /** @return array{url:string,origin:string,host:string}|WP_Error */ private static function browser_local_plugin_url( string $url, int $index ): array|WP_Error { + if ( str_starts_with( $url, 'data:application/zip;base64,' ) ) { + return array( + 'url' => $url, + 'origin' => 'data:', + 'host' => 'data', + ); + } + $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { return new WP_Error( 'wp_codebox_browser_plugin_url_invalid', 'Browser plugin URL must be absolute.', array( 'status' => 400, 'index' => $index ) ); diff --git a/packages/wordpress-plugin/src/class-wp-codebox-task-input-contract.php b/packages/wordpress-plugin/src/class-wp-codebox-task-input-contract.php index 2ace384..96c759a 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-task-input-contract.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-task-input-contract.php @@ -20,10 +20,12 @@ public static function schema(): array { 'required' => array( 'schema', 'version', 'goal', 'target', 'allowed_tools', 'expected_artifacts', 'policy', 'context' ), 'properties' => array( 'schema' => array( + 'type' => 'string', 'const' => self::SCHEMA, 'description' => 'Task input contract schema id.', ), 'version' => array( + 'type' => 'integer', 'const' => self::VERSION, 'description' => 'Task input contract version.', ), diff --git a/tests/smoke-wordpress-plugin.php b/tests/smoke-wordpress-plugin.php index 9c26944..6fc7281 100644 --- a/tests/smoke-wordpress-plugin.php +++ b/tests/smoke-wordpress-plugin.php @@ -472,10 +472,10 @@ function get_users( array $args ): array { return array( new WP_User( 11, 'Priva $assert( 'browser Playground session defaults to latest WordPress and PHP', ! is_wp_error( $browser_session ) && 'latest' === ( $browser_session['playground']['blueprint']['preferredVersions']['wp'] ?? '' ) && 'latest' === ( $browser_session['playground']['blueprint']['preferredVersions']['php'] ?? '' ) ); $assert( 'browser Playground session logs in before admin workflows', ! is_wp_error( $browser_session ) && 'login' === ( $browser_session['playground']['blueprint']['steps'][0]['step'] ?? '' ) && 'admin' === ( $browser_session['playground']['blueprint']['steps'][0]['username'] ?? '' ) ); $assert( 'browser Playground session installs caller browser plugins without duplicating packaged components', ! is_wp_error( $browser_session ) && 'installPlugin' === ( $browser_session['playground']['blueprint']['steps'][1]['step'] ?? '' ) && 'https://example.test/agents-api.zip' === ( $browser_session['playground']['blueprint']['steps'][1]['pluginData']['url'] ?? '' ) && 1 === count( array_filter( $browser_session['plugins'], static fn( array $plugin ): bool => 'agents-api' === ( $plugin['slug'] ?? '' ) ) ) ); -$assert( 'browser Playground session packages required host runtime plugins', ! is_wp_error( $browser_session ) && str_starts_with( (string) ( $browser_session['plugins'][1]['url'] ?? '' ), 'https://parent.example.test/uploads/wp-codebox/browser-runtime-plugins/data-machine-' ) && str_starts_with( (string) ( $browser_session['plugins'][2]['url'] ?? '' ), 'https://parent.example.test/uploads/wp-codebox/browser-runtime-plugins/data-machine-code-' ) && 64 === strlen( (string) ( $browser_session['plugins'][1]['provenance']['sha256'] ?? '' ) ) ); +$assert( 'browser Playground session packages required host runtime plugins', ! is_wp_error( $browser_session ) && str_starts_with( (string) ( $browser_session['plugins'][1]['url'] ?? '' ), 'data:application/zip;base64,' ) && str_starts_with( (string) ( $browser_session['plugins'][2]['url'] ?? '' ), 'data:application/zip;base64,' ) && 64 === strlen( (string) ( $browser_session['plugins'][1]['provenance']['sha256'] ?? '' ) ) ); $assert( 'browser Playground session accepts structured runtime dependencies', ! is_wp_error( $browser_session ) && 'wp-codebox/browser-runtime-dependencies/v1' === ( $browser_session['runtime']['schema'] ?? '' ) && 6 === ( $browser_session['runtime']['summary']['plugins'] ?? 0 ) && 2 === ( $browser_session['runtime']['component_plugins'] ?? 0 ) && 1 === ( $browser_session['runtime']['summary']['mu_plugins'] ?? 0 ) && 1 === ( $browser_session['runtime']['summary']['themes'] ?? 0 ) && 1 === ( $browser_session['runtime']['summary']['bootstrap'] ?? 0 ) ); -$assert( 'browser Playground session server-packages remote runtime plugins after required components', ! is_wp_error( $browser_session ) && 'installPlugin' === ( $browser_session['playground']['blueprint']['steps'][4]['step'] ?? '' ) && str_starts_with( (string) ( $browser_session['playground']['blueprint']['steps'][4]['pluginData']['url'] ?? '' ), 'https://parent.example.test/uploads/wp-codebox/browser-runtime-plugins/static-site-importer-' ) && false === ( $browser_session['playground']['blueprint']['steps'][4]['options']['activate'] ?? true ) && 'runtime-plugin-remote-package' === ( $browser_session['plugins'][3]['provenance']['source'] ?? '' ) ); -$assert( 'browser Playground session packages declarative runtime plugin paths', ! is_wp_error( $browser_session ) && str_starts_with( (string) ( $browser_session['plugins'][4]['url'] ?? '' ), 'https://parent.example.test/uploads/wp-codebox/browser-runtime-plugins/studio-web-' ) && 64 === strlen( (string) ( $browser_session['plugins'][4]['provenance']['sha256'] ?? '' ) ) ); +$assert( 'browser Playground session server-packages remote runtime plugins after required components', ! is_wp_error( $browser_session ) && 'installPlugin' === ( $browser_session['playground']['blueprint']['steps'][4]['step'] ?? '' ) && str_starts_with( (string) ( $browser_session['playground']['blueprint']['steps'][4]['pluginData']['url'] ?? '' ), 'data:application/zip;base64,' ) && false === ( $browser_session['playground']['blueprint']['steps'][4]['options']['activate'] ?? true ) && 'runtime-plugin-remote-package' === ( $browser_session['plugins'][3]['provenance']['source'] ?? '' ) ); +$assert( 'browser Playground session packages declarative runtime plugin paths', ! is_wp_error( $browser_session ) && str_starts_with( (string) ( $browser_session['plugins'][4]['url'] ?? '' ), 'data:application/zip;base64,' ) && 64 === strlen( (string) ( $browser_session['plugins'][4]['provenance']['sha256'] ?? '' ) ) ); $assert( 'browser Playground session compiles git directory runtime plugins', ! is_wp_error( $browser_session ) && 'git:directory' === ( $browser_session['playground']['blueprint']['steps'][6]['pluginData']['resource'] ?? '' ) && 'plugins/example-git-plugin' === ( $browser_session['playground']['blueprint']['steps'][6]['pluginData']['path'] ?? '' ) && 'example-git-plugin' === ( $browser_session['playground']['blueprint']['steps'][6]['options']['targetFolderName'] ?? '' ) ); $assert( 'browser Playground session compiles mu-plugin runtime dependency', ! is_wp_error( $browser_session ) && 'runPHP' === ( $browser_session['playground']['blueprint']['steps'][7]['step'] ?? '' ) && str_contains( (string) ( $browser_session['playground']['blueprint']['steps'][7]['code'] ?? '' ), '/wordpress/wp-content/mu-plugins/example-review.php' ) ); $assert( 'browser Playground session compiles theme runtime dependency', ! is_wp_error( $browser_session ) && 'runPHP' === ( $browser_session['playground']['blueprint']['steps'][8]['step'] ?? '' ) && str_contains( (string) ( $browser_session['playground']['blueprint']['steps'][8]['code'] ?? '' ), '/wordpress/wp-content/themes/example-starter/style.css' ) && str_contains( (string) ( $browser_session['playground']['blueprint']['steps'][8]['code'] ?? '' ), "require_once '/wordpress/wp-load.php'" ) && str_contains( (string) ( $browser_session['playground']['blueprint']['steps'][8]['code'] ?? '' ), "require_once ABSPATH . WPINC . '/theme.php'" ) && str_contains( (string) ( $browser_session['playground']['blueprint']['steps'][8]['code'] ?? '' ), "switch_theme( 'example-starter' )" ) ); From 753cf0e571d4bf3ede8b068edc40a83971d0bcdd Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 29 May 2026 21:05:03 -0400 Subject: [PATCH 2/3] Add browser-probe memory and performance capture --- README.md | 2 +- packages/cli/src/index.ts | 2 +- packages/runtime-core/src/index.ts | 7 +- packages/runtime-playground/src/index.ts | 411 ++++++++++++++++++++++- scripts/browser-probe-artifact-smoke.ts | 45 ++- 5 files changed, 456 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3f84aa5..4a7aa71 100644 --- a/README.md +++ b/README.md @@ -468,7 +468,7 @@ Supported runtime commands today: `wordpress.wp-cli` automatically enables Playground's `wp-cli` extra library when the command is allowed by runtime policy. -`wordpress.browser-probe` accepts `wait-for=domcontentloaded|load|networkidle|selector:|duration`, `duration=s`, and `capture=console,errors,html,network,screenshot`. It records machine-readable evidence refs such as `files/browser/console.jsonl`, `files/browser/errors.jsonl`, `files/browser/network.jsonl`, `files/browser/snapshot.html`, `files/browser/screenshot.png`, and `files/browser/summary.json` when those captures are enabled. The summary includes requested/final URLs, viewport/device metadata, HTML and screenshot hashes, network event counts, and a generic `artifact-backed|partial|diagnostic-only` replayability classification. WP Codebox intentionally keeps these browser evidence fields generic; consumers such as eval harnesses may interpret them without WP Codebox adding scoring, grading, or benchmark semantics. +`wordpress.browser-probe` accepts `wait-for=domcontentloaded|load|networkidle|selector:|duration`, `duration=s`, and `capture=console,errors,html,network,performance,memory,screenshot`. It records machine-readable evidence refs such as `files/browser/console.jsonl`, `files/browser/errors.jsonl`, `files/browser/network.jsonl`, `files/browser/performance.json`, `files/browser/memory.json`, `files/browser/checkpoints.jsonl`, `files/browser/snapshot.html`, `files/browser/screenshot.png`, and `files/browser/summary.json` when those captures are enabled. The summary includes requested/final URLs, viewport/device metadata, HTML and screenshot hashes, network event counts, optional final/peak browser memory and performance summaries, and a generic `artifact-backed|partial|diagnostic-only` replayability classification. Performance and memory captures use generic browser/CDP data only: JS heap when available, CDP `Performance.getMetrics`, CDP DOM counters, DOM/resource counts and byte totals, and long task counts/duration. WP Codebox intentionally keeps these browser evidence fields generic; consumers such as eval harnesses may interpret them without WP Codebox adding scoring, grading, or benchmark semantics. `wordpress.browser-actions` accepts `actions-json=` with ordered `navigate`, `click`, `fill`, `press`, `wait`, and `capture` actions. `navigate` uses `url` plus optional `waitFor=domcontentloaded|load|networkidle`; `click` uses `selector` or `text`; `fill` uses `selector` and `value`; `press` uses `key` plus optional `selector`; `wait` uses `selector` or `waitFor=domcontentloaded|load|networkidle|duration` with `duration=s|ms`. It records `files/browser/actions.jsonl`, `files/browser/action-summary.json`, and optional `console`, `errors`, `network`, `html`, and `screenshot` captures. Failures identify the failed action index/type in the action log, include serialized browser errors, and still write the requested audit artifacts when possible. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f9818c7..08bb3d9 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3629,7 +3629,7 @@ async function validateRecipeStepArgs(step: WorkspaceRecipe["workflow"]["steps"] const capture = recipeStepArgValue(step.args ?? [], "capture") if (capture) { for (const item of capture.split(",").map((value) => value.trim()).filter(Boolean)) { - if (!["console", "errors", "html", "network", "screenshot"].includes(item)) { + if (!["console", "errors", "html", "network", "performance", "memory", "screenshot"].includes(item)) { addIssue("invalid-capture", `${path}.args`, `wordpress.browser-probe capture does not support: ${item}`) } } diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index ac61455..e87d359 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -169,9 +169,9 @@ export const commandRegistry = [ { name: "wait-for", description: "Navigation wait condition.", format: "domcontentloaded|load|networkidle|selector:|duration" }, { name: "duration", description: "Extra capture duration, or wait time when wait-for=duration.", format: "duration, e.g. 2s or 500ms" }, { name: "script", description: "Optional page-side JavaScript to evaluate after navigation and before final capture.", format: "JavaScript function body" }, - { name: "capture", description: "Comma-separated artifacts to capture.", format: "console,errors,html,network,screenshot" }, + { name: "capture", description: "Comma-separated artifacts to capture.", format: "console,errors,html,network,performance,memory,screenshot" }, ], - outputShape: "JSON summary plus files/browser/console.jsonl, errors.jsonl, network.jsonl, snapshot.html, summary.json, and screenshot.png when captured.", + outputShape: "JSON summary plus files/browser/console.jsonl, errors.jsonl, network.jsonl, performance.json, memory.json, checkpoints.jsonl, snapshot.html, summary.json, and screenshot.png when captured.", policyRequirement: "Runtime policy commands must include wordpress.browser-probe.", recipe: true, handler: { kind: "playground", method: "runBrowserProbe" }, @@ -1454,6 +1454,9 @@ export interface ArtifactReviewBrowserSummary { html?: string network?: string networkEvents?: number + checkpoints?: string + memory?: string + performance?: string screenshot?: string console?: string errorsFile?: string diff --git a/packages/runtime-playground/src/index.ts b/packages/runtime-playground/src/index.ts index 427d16d..099d07b 100644 --- a/packages/runtime-playground/src/index.ts +++ b/packages/runtime-playground/src/index.ts @@ -43,6 +43,31 @@ import type { } from "@chubes4/wp-codebox-core" import type { ConsoleMessage, Page, Request, Response } from "playwright" +const BROWSER_PROBE_CAPTURE_VALUES = ["console", "errors", "html", "network", "performance", "memory", "screenshot"] as const +const BROWSER_PROBE_PERFORMANCE_INIT_SCRIPT = ` +(() => { + const state = globalThis.__wpCodeboxBrowserProbe = globalThis.__wpCodeboxBrowserProbe || { longTasks: [] }; + if (state.longTaskObserverInstalled || typeof PerformanceObserver === 'undefined') { + return; + } + try { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + state.longTasks.push({ + name: entry.name, + startTime: entry.startTime, + duration: entry.duration, + }); + } + }); + observer.observe({ type: 'longtask', buffered: true }); + state.longTaskObserverInstalled = true; + } catch { + state.longTaskObserverInstalled = false; + } +})(); +` + function now(): string { return new Date().toISOString() } @@ -525,10 +550,13 @@ interface BrowserProbeArtifact { url: string files: { actions?: string + checkpoints?: string console?: string errors?: string html?: string + memory?: string network?: string + performance?: string screenshot?: string summary: string } @@ -538,7 +566,9 @@ interface BrowserProbeArtifact { errors: number finalUrl: string htmlSnapshot: boolean + memory?: BrowserProbeMemorySummary networkEvents: number + performance?: BrowserProbePerformanceSummary replayability: BrowserProbeReplayability screenshot: boolean scriptResult?: unknown @@ -546,6 +576,95 @@ interface BrowserProbeArtifact { } } +interface BrowserProbeMetricDigest { + final: number | null + peak: number | null +} + +interface BrowserProbeMemorySummary { + usedJSHeapSize: BrowserProbeMetricDigest + totalJSHeapSize: BrowserProbeMetricDigest + jsHeapSizeLimit: number | null + domNodes: BrowserProbeMetricDigest + documents: BrowserProbeMetricDigest + jsEventListeners: BrowserProbeMetricDigest +} + +interface BrowserProbePerformanceSummary { + resources: number + transferSizeBytes: number + encodedBodySizeBytes: number + decodedBodySizeBytes: number + longTasks: number + longTaskDurationMs: number + domNodes: BrowserProbeMetricDigest + cdpMetrics: Record +} + +interface BrowserProbeCheckpointRecord { + schema: "wp-codebox/browser-checkpoint/v1" + name: string + timestamp: string + metrics: BrowserProbeMetricsSnapshot +} + +interface BrowserProbeMetricsSnapshot { + timestamp: string + memory: { + performanceMemory: { + usedJSHeapSize: number | null + totalJSHeapSize: number | null + jsHeapSizeLimit: number | null + } + cdpHeap: { + usedSize: number | null + totalSize: number | null + } + domCounters: { + documents: number | null + nodes: number | null + jsEventListeners: number | null + } + } + performance: { + cdpMetrics: Record + dom: { + nodes: number + documents: number + iframes: number + } + resources: { + count: number + transferSizeBytes: number + encodedBodySizeBytes: number + decodedBodySizeBytes: number + } + longTasks: { + count: number + totalDurationMs: number + maxDurationMs: number + } + } +} + +interface BrowserProbeMemoryArtifact { + schema: "wp-codebox/browser-memory/v1" + version: 1 + capturedAt: string + final: BrowserProbeMetricsSnapshot["memory"] + peak: BrowserProbeMemorySummary + checkpoints: BrowserProbeCheckpointRecord[] +} + +interface BrowserProbePerformanceArtifact { + schema: "wp-codebox/browser-performance/v1" + version: 1 + capturedAt: string + final: BrowserProbeMetricsSnapshot["performance"] + peak: BrowserProbePerformanceSummary + checkpoints: BrowserProbeCheckpointRecord[] +} + interface BrowserProbeViewport { width: number height: number @@ -1205,14 +1324,15 @@ class PlaygroundRuntime implements Runtime { } for (const item of capture) { - if (!["console", "errors", "html", "network", "screenshot"].includes(item)) { - throw new Error(`wordpress.browser-probe capture supports console, errors, html, network, screenshot: ${item}`) + if (!(BROWSER_PROBE_CAPTURE_VALUES as readonly string[]).includes(item)) { + throw new Error(`wordpress.browser-probe capture supports ${BROWSER_PROBE_CAPTURE_VALUES.join(", ")}: ${item}`) } } const waitFor = argValue(args, "wait-for")?.trim() || "domcontentloaded" const durationMs = durationArg(args, "duration", 0) const script = argValue(args, "script") + const capturesBrowserMetrics = capture.has("performance") || capture.has("memory") const targetUrl = resolveBrowserProbeUrl(urlArg, server.serverUrl) const browserDirectory = join(this.artifactRoot, "files", "browser") await mkdir(browserDirectory, { recursive: true }) @@ -1220,10 +1340,14 @@ class PlaygroundRuntime implements Runtime { const consoleMessages: Record[] = [] const errors: BrowserProbeErrorRecord[] = [] const network: BrowserProbeNetworkRecord[] = [] + const checkpoints: BrowserProbeCheckpointRecord[] = [] const consolePath = join(browserDirectory, "console.jsonl") + const checkpointsPath = join(browserDirectory, "checkpoints.jsonl") const errorsPath = join(browserDirectory, "errors.jsonl") const htmlPath = join(browserDirectory, "snapshot.html") + const memoryPath = join(browserDirectory, "memory.json") const networkPath = join(browserDirectory, "network.jsonl") + const performancePath = join(browserDirectory, "performance.json") const screenshotPath = join(browserDirectory, "screenshot.png") const summaryPath = join(browserDirectory, "summary.json") const startedAt = now() @@ -1234,9 +1358,14 @@ class PlaygroundRuntime implements Runtime { let screenshotSha256: string | undefined let viewport: BrowserProbeViewport | null = null let scriptResult: unknown + let memoryArtifact: BrowserProbeMemoryArtifact | undefined + let performanceArtifact: BrowserProbePerformanceArtifact | undefined try { const page = await browser.newPage() + if (capturesBrowserMetrics) { + await page.addInitScript(BROWSER_PROBE_PERFORMANCE_INIT_SCRIPT) + } viewport = await browserProbeViewport(page) if (capture.has("console")) { page.on("console", (message) => consoleMessages.push(serializeBrowserConsoleMessage(message))) @@ -1250,17 +1379,36 @@ class PlaygroundRuntime implements Runtime { } await navigateBrowserProbe(page, targetUrl, waitFor, durationMs) + if (capturesBrowserMetrics) { + checkpoints.push(await browserProbeCheckpoint(page, "after-navigation")) + } if (script) { scriptResult = await page.evaluate(async (source) => { const run = new Function(`return (async () => {\n${source}\n})()`) return run() }, script) + if (capturesBrowserMetrics) { + checkpoints.push(await browserProbeCheckpoint(page, "after-script")) + } } if (durationMs > 0 && waitFor !== "duration") { await page.waitForTimeout(durationMs) + if (capturesBrowserMetrics) { + checkpoints.push(await browserProbeCheckpoint(page, "after-duration")) + } } finalUrl = page.url() + if (capturesBrowserMetrics) { + checkpoints.push(await browserProbeCheckpoint(page, "final")) + if (capture.has("memory")) { + memoryArtifact = browserProbeMemoryArtifact(checkpoints) + } + if (capture.has("performance")) { + performanceArtifact = browserProbePerformanceArtifact(checkpoints) + } + } + if (capture.has("html")) { const html = await page.content() await writeFile(htmlPath, html) @@ -1285,15 +1433,27 @@ class PlaygroundRuntime implements Runtime { if (capture.has("network")) { await writeFile(networkPath, jsonLines(network)) } + if (checkpoints.length > 0) { + await writeFile(checkpointsPath, jsonLines(checkpoints)) + } + if (memoryArtifact) { + await writeFile(memoryPath, `${JSON.stringify(memoryArtifact, null, 2)}\n`) + } + if (performanceArtifact) { + await writeFile(performancePath, `${JSON.stringify(performanceArtifact, null, 2)}\n`) + } const artifact: BrowserProbeArtifact = { requestedUrl: targetUrl, url: targetUrl, files: { ...(capture.has("console") ? { console: "files/browser/console.jsonl" } : {}), + ...(checkpoints.length > 0 ? { checkpoints: "files/browser/checkpoints.jsonl" } : {}), ...(capture.has("errors") ? { errors: "files/browser/errors.jsonl" } : {}), ...(capture.has("html") ? { html: "files/browser/snapshot.html" } : {}), + ...(memoryArtifact ? { memory: "files/browser/memory.json" } : {}), ...(capture.has("network") ? { network: "files/browser/network.jsonl" } : {}), + ...(performanceArtifact ? { performance: "files/browser/performance.json" } : {}), ...(capture.has("screenshot") ? { screenshot: "files/browser/screenshot.png" } : {}), summary: "files/browser/summary.json", }, @@ -1302,7 +1462,9 @@ class PlaygroundRuntime implements Runtime { errors: errors.length, finalUrl, htmlSnapshot: capture.has("html"), + ...(memoryArtifact ? { memory: memoryArtifact.peak } : {}), networkEvents: network.length, + ...(performanceArtifact ? { performance: performanceArtifact.peak } : {}), replayability: browserProbeReplayability(capture), screenshot: capture.has("screenshot"), ...(typeof scriptResult !== "undefined" ? { scriptResult } : {}), @@ -2413,9 +2575,12 @@ echo wp_json_encode( array( networkEvents: probe.summary.networkEvents, screenshot: probe.files.screenshot, console: probe.files.console, + checkpoints: probe.files.checkpoints, errorsFile: probe.files.errors, + memory: probe.files.memory, actions: probe.files.actions, actionCount: probe.summary.actions, + performance: probe.files.performance, summaryFile: probe.files.summary, })), } @@ -2434,15 +2599,24 @@ echo wp_json_encode( array( if (probe.files.console) { files.set(probe.files.console, { kind: "browser-console", contentType: "application/x-ndjson" }) } + if (probe.files.checkpoints) { + files.set(probe.files.checkpoints, { kind: "browser-checkpoints", contentType: "application/x-ndjson" }) + } if (probe.files.errors) { files.set(probe.files.errors, { kind: "browser-errors", contentType: "application/x-ndjson" }) } if (probe.files.html) { files.set(probe.files.html, { kind: "browser-html-snapshot", contentType: "text/html; charset=utf-8" }) } + if (probe.files.memory) { + files.set(probe.files.memory, { kind: "browser-memory", contentType: "application/json" }) + } if (probe.files.network) { files.set(probe.files.network, { kind: "browser-network", contentType: "application/x-ndjson" }) } + if (probe.files.performance) { + files.set(probe.files.performance, { kind: "browser-performance", contentType: "application/json" }) + } if (probe.files.screenshot) { files.set(probe.files.screenshot, { kind: "browser-screenshot", contentType: "image/png" }) } @@ -2469,7 +2643,7 @@ echo wp_json_encode( array( private async redactBrowserArtifacts(redactor: ArtifactRedactor): Promise { for (const probe of this.browserProbes) { - for (const path of [probe.files.actions, probe.files.console, probe.files.errors, probe.files.html, probe.files.network, probe.files.summary]) { + for (const path of [probe.files.actions, probe.files.checkpoints, probe.files.console, probe.files.errors, probe.files.html, probe.files.memory, probe.files.network, probe.files.performance, probe.files.summary]) { if (!path) { continue } @@ -2755,6 +2929,237 @@ function browserProbeReplayability(capture: Set): BrowserProbeReplayabil return "diagnostic-only" } +async function browserProbeCheckpoint(page: Page, name: string): Promise { + return { + schema: "wp-codebox/browser-checkpoint/v1", + name, + timestamp: now(), + metrics: await browserProbeMetricsSnapshot(page), + } +} + +async function browserProbeMetricsSnapshot(page: Page): Promise { + const [pageMetrics, cdpMetrics] = await Promise.all([ + page.evaluate(() => { + const memory = (performance as Performance & { memory?: { usedJSHeapSize?: number; totalJSHeapSize?: number; jsHeapSizeLimit?: number } }).memory + const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[] + const longTasks = ((globalThis as typeof globalThis & { __wpCodeboxBrowserProbe?: { longTasks?: Array<{ duration?: number }> } }).__wpCodeboxBrowserProbe?.longTasks ?? []) + .map((entry) => Number(entry.duration ?? 0)) + .filter((duration) => Number.isFinite(duration) && duration >= 0) + + return { + performanceMemory: { + usedJSHeapSize: finiteNumberOrNull(memory?.usedJSHeapSize), + totalJSHeapSize: finiteNumberOrNull(memory?.totalJSHeapSize), + jsHeapSizeLimit: finiteNumberOrNull(memory?.jsHeapSizeLimit), + }, + dom: { + nodes: document.querySelectorAll("*").length, + documents: 1, + iframes: document.querySelectorAll("iframe").length, + }, + resources: { + count: resources.length, + transferSizeBytes: resourceTotal(resources, "transferSize"), + encodedBodySizeBytes: resourceTotal(resources, "encodedBodySize"), + decodedBodySizeBytes: resourceTotal(resources, "decodedBodySize"), + }, + longTasks: { + count: longTasks.length, + totalDurationMs: longTasks.reduce((total, duration) => total + duration, 0), + maxDurationMs: longTasks.reduce((max, duration) => Math.max(max, duration), 0), + }, + } + + function finiteNumberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null + } + + function resourceTotal(resources: PerformanceResourceTiming[], field: "transferSize" | "encodedBodySize" | "decodedBodySize"): number { + return resources.reduce((total, resource) => { + const value = resource[field] + return total + (Number.isFinite(value) && value > 0 ? value : 0) + }, 0) + } + }), + browserProbeCdpMetrics(page), + ]) + + return { + timestamp: now(), + memory: { + performanceMemory: pageMetrics.performanceMemory, + cdpHeap: cdpMetrics.heap, + domCounters: cdpMetrics.domCounters, + }, + performance: { + cdpMetrics: cdpMetrics.performance, + dom: { + nodes: cdpMetrics.domCounters.nodes ?? pageMetrics.dom.nodes, + documents: cdpMetrics.domCounters.documents ?? pageMetrics.dom.documents, + iframes: pageMetrics.dom.iframes, + }, + resources: pageMetrics.resources, + longTasks: pageMetrics.longTasks, + }, + } +} + +async function browserProbeCdpMetrics(page: Page): Promise<{ + performance: Record + domCounters: { documents: number | null; nodes: number | null; jsEventListeners: number | null } + heap: { usedSize: number | null; totalSize: number | null } +}> { + const fallback = { + performance: {}, + domCounters: { documents: null, nodes: null, jsEventListeners: null }, + heap: { usedSize: null, totalSize: null }, + } + + try { + const session = await page.context().newCDPSession(page) + try { + await session.send("Performance.enable").catch(() => undefined) + const [performanceResult, domCountersResult, heapResult] = await Promise.all([ + session.send("Performance.getMetrics").catch(() => undefined), + session.send("Memory.getDOMCounters").catch(() => undefined), + session.send("Runtime.getHeapUsage").catch(() => undefined), + ]) + return { + performance: cdpPerformanceMetrics(performanceResult), + domCounters: cdpDomCounters(domCountersResult), + heap: cdpHeapUsage(heapResult), + } + } finally { + await session.detach().catch(() => undefined) + } + } catch { + return fallback + } +} + +function browserProbeMemoryArtifact(checkpoints: BrowserProbeCheckpointRecord[]): BrowserProbeMemoryArtifact { + const final = checkpoints.at(-1)?.metrics.memory ?? { + performanceMemory: { usedJSHeapSize: null, totalJSHeapSize: null, jsHeapSizeLimit: null }, + cdpHeap: { usedSize: null, totalSize: null }, + domCounters: { documents: null, nodes: null, jsEventListeners: null }, + } + + return { + schema: "wp-codebox/browser-memory/v1", + version: 1, + capturedAt: now(), + final, + peak: browserProbeMemorySummary(checkpoints), + checkpoints, + } +} + +function browserProbePerformanceArtifact(checkpoints: BrowserProbeCheckpointRecord[]): BrowserProbePerformanceArtifact { + const final = checkpoints.at(-1)?.metrics.performance ?? { + cdpMetrics: {}, + dom: { nodes: 0, documents: 0, iframes: 0 }, + resources: { count: 0, transferSizeBytes: 0, encodedBodySizeBytes: 0, decodedBodySizeBytes: 0 }, + longTasks: { count: 0, totalDurationMs: 0, maxDurationMs: 0 }, + } + + return { + schema: "wp-codebox/browser-performance/v1", + version: 1, + capturedAt: now(), + final, + peak: browserProbePerformanceSummary(checkpoints), + checkpoints, + } +} + +function browserProbeMemorySummary(checkpoints: BrowserProbeCheckpointRecord[]): BrowserProbeMemorySummary { + return { + usedJSHeapSize: metricDigest(checkpoints.map((checkpoint) => checkpoint.metrics.memory.performanceMemory.usedJSHeapSize ?? checkpoint.metrics.memory.cdpHeap.usedSize)), + totalJSHeapSize: metricDigest(checkpoints.map((checkpoint) => checkpoint.metrics.memory.performanceMemory.totalJSHeapSize ?? checkpoint.metrics.memory.cdpHeap.totalSize)), + jsHeapSizeLimit: lastNumber(checkpoints.map((checkpoint) => checkpoint.metrics.memory.performanceMemory.jsHeapSizeLimit)), + domNodes: metricDigest(checkpoints.map((checkpoint) => checkpoint.metrics.memory.domCounters.nodes ?? checkpoint.metrics.performance.dom.nodes)), + documents: metricDigest(checkpoints.map((checkpoint) => checkpoint.metrics.memory.domCounters.documents ?? checkpoint.metrics.performance.dom.documents)), + jsEventListeners: metricDigest(checkpoints.map((checkpoint) => checkpoint.metrics.memory.domCounters.jsEventListeners)), + } +} + +function browserProbePerformanceSummary(checkpoints: BrowserProbeCheckpointRecord[]): BrowserProbePerformanceSummary { + const final = checkpoints.at(-1)?.metrics.performance + const metricNames = new Set() + for (const checkpoint of checkpoints) { + for (const key of Object.keys(checkpoint.metrics.performance.cdpMetrics)) { + metricNames.add(key) + } + } + + return { + resources: final?.resources.count ?? 0, + transferSizeBytes: final?.resources.transferSizeBytes ?? 0, + encodedBodySizeBytes: final?.resources.encodedBodySizeBytes ?? 0, + decodedBodySizeBytes: final?.resources.decodedBodySizeBytes ?? 0, + longTasks: final?.longTasks.count ?? 0, + longTaskDurationMs: final?.longTasks.totalDurationMs ?? 0, + domNodes: metricDigest(checkpoints.map((checkpoint) => checkpoint.metrics.performance.dom.nodes)), + cdpMetrics: Object.fromEntries([...metricNames].sort().map((name) => [name, metricDigest(checkpoints.map((checkpoint) => checkpoint.metrics.performance.cdpMetrics[name]))])), + } +} + +function cdpPerformanceMetrics(value: unknown): Record { + if (!isRecord(value) || !Array.isArray(value.metrics)) { + return {} + } + + return Object.fromEntries(value.metrics.flatMap((metric) => { + if (!isRecord(metric) || typeof metric.name !== "string" || typeof metric.value !== "number" || !Number.isFinite(metric.value)) { + return [] + } + return [[metric.name, metric.value]] + })) +} + +function cdpDomCounters(value: unknown): { documents: number | null; nodes: number | null; jsEventListeners: number | null } { + return { + documents: recordNumberOrNull(value, "documents"), + nodes: recordNumberOrNull(value, "nodes"), + jsEventListeners: recordNumberOrNull(value, "jsEventListeners"), + } +} + +function cdpHeapUsage(value: unknown): { usedSize: number | null; totalSize: number | null } { + return { + usedSize: recordNumberOrNull(value, "usedSize"), + totalSize: recordNumberOrNull(value, "totalSize"), + } +} + +function recordNumberOrNull(value: unknown, key: string): number | null { + if (!isRecord(value)) { + return null + } + const field = value[key] + return typeof field === "number" && Number.isFinite(field) ? field : null +} + +function metricDigest(values: Array): BrowserProbeMetricDigest { + const numbers = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value)) + return { + final: numbers.at(-1) ?? null, + peak: numbers.length > 0 ? Math.max(...numbers) : null, + } +} + +function lastNumber(values: Array): number | null { + for (let index = values.length - 1; index >= 0; index -= 1) { + const value = values[index] + if (typeof value === "number" && Number.isFinite(value)) { + return value + } + } + + return null +} + function serializeBrowserResponse(response: Response): BrowserProbeNetworkRecord { const request = response.request() return { diff --git a/scripts/browser-probe-artifact-smoke.ts b/scripts/browser-probe-artifact-smoke.ts index b07f10e..757920b 100644 --- a/scripts/browser-probe-artifact-smoke.ts +++ b/scripts/browser-probe-artifact-smoke.ts @@ -41,7 +41,7 @@ await writeFile(recipePath, `${JSON.stringify({ "url=/", "wait-for=load", "duration=1s", - "capture=console,errors,html,network,screenshot", + "capture=console,errors,html,network,performance,memory,screenshot", "script=console.info('wp-codebox fixture browser script'); return { title: document.title, hasBody: !!document.body };", ], }, @@ -65,18 +65,24 @@ assert.ok(output.artifacts?.directory, "recipe-run should return an artifact dir const artifactDirectory = output.artifacts.directory const consolePath = join(artifactDirectory, "files", "browser", "console.jsonl") +const checkpointsPath = join(artifactDirectory, "files", "browser", "checkpoints.jsonl") const errorsPath = join(artifactDirectory, "files", "browser", "errors.jsonl") const htmlPath = join(artifactDirectory, "files", "browser", "snapshot.html") +const memoryPath = join(artifactDirectory, "files", "browser", "memory.json") const networkPath = join(artifactDirectory, "files", "browser", "network.jsonl") +const performancePath = join(artifactDirectory, "files", "browser", "performance.json") const screenshotPath = join(artifactDirectory, "files", "browser", "screenshot.png") const summaryPath = join(artifactDirectory, "files", "browser", "summary.json") const manifestPath = join(artifactDirectory, "manifest.json") const reviewPath = join(artifactDirectory, "files", "review.json") assert.equal(existsSync(consolePath), true, "console.jsonl should be captured") +assert.equal(existsSync(checkpointsPath), true, "checkpoints.jsonl should be captured") assert.equal(existsSync(errorsPath), true, "errors.jsonl should be captured") assert.equal(existsSync(htmlPath), true, "snapshot.html should be captured") +assert.equal(existsSync(memoryPath), true, "memory.json should be captured") assert.equal(existsSync(networkPath), true, "network.jsonl should be captured") +assert.equal(existsSync(performancePath), true, "performance.json should be captured") assert.equal(existsSync(screenshotPath), true, "screenshot.png should be captured") assert.equal(existsSync(summaryPath), true, "summary.json should be captured") @@ -84,24 +90,46 @@ const consoleLog = await readFile(consolePath, "utf8") const errorLog = await readFile(errorsPath, "utf8") const htmlSnapshot = await readFile(htmlPath, "utf8") const networkLog = await readFile(networkPath, "utf8") +const checkpointsLog = await readFile(checkpointsPath, "utf8") assert.match(consoleLog, /wp-codebox fixture console error/) assert.match(consoleLog, /wp-codebox fixture browser script/) assert.match(errorLog, /wp-codebox fixture browser error/) assert.match(htmlSnapshot, /Browser Error Fixture|wp-codebox fixture console error/) assert.match(networkLog, /"type":"response"/) +assert.match(checkpointsLog, /"schema":"wp-codebox\/browser-checkpoint\/v1"/) + +const memory = JSON.parse(await readFile(memoryPath, "utf8")) as { schema: string; final: { domCounters: { nodes: number | null } }; peak: { domNodes: { final: number | null; peak: number | null } }; checkpoints: unknown[] } +const performance = JSON.parse(await readFile(performancePath, "utf8")) as { schema: string; final: { resources: { count: number }; dom: { nodes: number } }; peak: { resources: number; domNodes: { final: number | null; peak: number | null } }; checkpoints: unknown[] } +assert.equal(memory.schema, "wp-codebox/browser-memory/v1") +assert.ok((memory.final.domCounters.nodes ?? memory.peak.domNodes.final ?? 0) > 0, "memory artifact should include DOM node counts") +assert.ok(memory.checkpoints.length >= 1, "memory artifact should include checkpoints") +assert.equal(performance.schema, "wp-codebox/browser-performance/v1") +assert.ok(performance.final.resources.count >= 1, "performance artifact should include resource counts") +assert.ok(performance.final.dom.nodes > 0, "performance artifact should include DOM node counts") +assert.ok(performance.checkpoints.length >= 1, "performance artifact should include checkpoints") const summary = JSON.parse(await readFile(summaryPath, "utf8")) as { requestedUrl: string finalUrl: string - files: { html?: string; network?: string; screenshot?: string } + files: { checkpoints?: string; html?: string; memory?: string; network?: string; performance?: string; screenshot?: string } hashes: { html?: { value: string }; screenshot?: { value: string } } viewport: { width: number; height: number; userAgent: string } - summary: { replayability: string; networkEvents: number; htmlSnapshot: boolean; scriptResult?: { title?: string; hasBody?: boolean } } + summary: { + replayability: string + networkEvents: number + htmlSnapshot: boolean + scriptResult?: { title?: string; hasBody?: boolean } + memory?: { usedJSHeapSize: { final: number | null; peak: number | null }; domNodes: { final: number | null; peak: number | null } } + performance?: { resources: number; domNodes: { final: number | null; peak: number | null }; longTasks: number } + } } assert.equal(summary.requestedUrl.endsWith("/"), true, "summary should include requested URL") assert.equal(summary.finalUrl.endsWith("/"), true, "summary should include final URL") assert.equal(summary.files.html, "files/browser/snapshot.html") +assert.equal(summary.files.checkpoints, "files/browser/checkpoints.jsonl") +assert.equal(summary.files.memory, "files/browser/memory.json") assert.equal(summary.files.network, "files/browser/network.jsonl") +assert.equal(summary.files.performance, "files/browser/performance.json") assert.match(summary.hashes.html?.value ?? "", /^[a-f0-9]{64}$/) assert.match(summary.hashes.screenshot?.value ?? "", /^[a-f0-9]{64}$/) assert.equal(summary.summary.replayability, "artifact-backed") @@ -109,23 +137,32 @@ assert.equal(summary.summary.htmlSnapshot, true) assert.equal(summary.summary.scriptResult?.title, "My WordPress Website") assert.equal(summary.summary.scriptResult?.hasBody, true) assert.ok(summary.summary.networkEvents >= 1, "summary should count network events") +assert.ok((summary.summary.memory?.domNodes.final ?? 0) > 0, "summary should include memory DOM node counts") +assert.ok((summary.summary.performance?.resources ?? 0) >= 1, "summary should include performance resource counts") +assert.ok((summary.summary.performance?.domNodes.final ?? 0) > 0, "summary should include performance DOM node counts") assert.ok(summary.viewport.width > 0, "summary should include viewport width") assert.ok(summary.viewport.height > 0, "summary should include viewport height") assert.ok(summary.viewport.userAgent.length > 0, "summary should include user agent") const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { files: Array<{ path: string; kind: string }> } assert.ok(manifest.files.some((file) => file.path === "files/browser/console.jsonl" && file.kind === "browser-console")) +assert.ok(manifest.files.some((file) => file.path === "files/browser/checkpoints.jsonl" && file.kind === "browser-checkpoints")) assert.ok(manifest.files.some((file) => file.path === "files/browser/errors.jsonl" && file.kind === "browser-errors")) assert.ok(manifest.files.some((file) => file.path === "files/browser/snapshot.html" && file.kind === "browser-html-snapshot")) +assert.ok(manifest.files.some((file) => file.path === "files/browser/memory.json" && file.kind === "browser-memory")) assert.ok(manifest.files.some((file) => file.path === "files/browser/network.jsonl" && file.kind === "browser-network")) +assert.ok(manifest.files.some((file) => file.path === "files/browser/performance.json" && file.kind === "browser-performance")) assert.ok(manifest.files.some((file) => file.path === "files/browser/screenshot.png" && file.kind === "browser-screenshot")) -const review = JSON.parse(await readFile(reviewPath, "utf8")) as { browser?: { probes?: Array<{ consoleMessages: number; errors: number; html?: string; network?: string; finalUrl?: string; replayability?: string }> } } +const review = JSON.parse(await readFile(reviewPath, "utf8")) as { browser?: { probes?: Array<{ consoleMessages: number; errors: number; checkpoints?: string; html?: string; memory?: string; network?: string; performance?: string; finalUrl?: string; replayability?: string }> } } assert.ok(review.browser?.probes?.[0], "review should include browser probe summary") assert.ok(review.browser.probes[0].consoleMessages >= 1, "review should count console messages") assert.ok(review.browser.probes[0].errors >= 1, "review should count browser errors") assert.equal(review.browser.probes[0].html, "files/browser/snapshot.html") +assert.equal(review.browser.probes[0].checkpoints, "files/browser/checkpoints.jsonl") +assert.equal(review.browser.probes[0].memory, "files/browser/memory.json") assert.equal(review.browser.probes[0].network, "files/browser/network.jsonl") +assert.equal(review.browser.probes[0].performance, "files/browser/performance.json") assert.equal(review.browser.probes[0].replayability, "artifact-backed") assert.equal(review.browser.probes[0].finalUrl?.endsWith("/"), true, "review should include final URL") From 5f1ce14277d81458bf813d76dfa8ce6d5596bb9c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 29 May 2026 21:00:08 -0400 Subject: [PATCH 3/3] fix: inline browser runtime bundles --- packages/runtime-playground/src/index.ts | 47 ++++++++++--------- .../src/class-wp-codebox-abilities.php | 45 +++++++++++++++--- .../class-wp-codebox-task-input-contract.php | 2 + tests/smoke-wordpress-plugin.php | 6 +-- 4 files changed, 69 insertions(+), 31 deletions(-) diff --git a/packages/runtime-playground/src/index.ts b/packages/runtime-playground/src/index.ts index 099d07b..c23c245 100644 --- a/packages/runtime-playground/src/index.ts +++ b/packages/runtime-playground/src/index.ts @@ -1360,9 +1360,10 @@ class PlaygroundRuntime implements Runtime { let scriptResult: unknown let memoryArtifact: BrowserProbeMemoryArtifact | undefined let performanceArtifact: BrowserProbePerformanceArtifact | undefined + let page: Page | null = null try { - const page = await browser.newPage() + page = await browser.newPage() if (capturesBrowserMetrics) { await page.addInitScript(BROWSER_PROBE_PERFORMANCE_INIT_SCRIPT) } @@ -1398,31 +1399,33 @@ class PlaygroundRuntime implements Runtime { } } finalUrl = page.url() - - if (capturesBrowserMetrics) { - checkpoints.push(await browserProbeCheckpoint(page, "final")) - if (capture.has("memory")) { - memoryArtifact = browserProbeMemoryArtifact(checkpoints) - } - if (capture.has("performance")) { - performanceArtifact = browserProbePerformanceArtifact(checkpoints) - } - } - - if (capture.has("html")) { - const html = await page.content() - await writeFile(htmlPath, html) - htmlSha256 = sha256(Buffer.from(html, "utf8")) - } - - if (capture.has("screenshot")) { - await page.screenshot({ path: screenshotPath, fullPage: true }) - screenshotSha256 = await fileSha256(screenshotPath) - } } catch (error) { errors.push(serializeBrowserError("probe-error", error)) throw error } finally { + if (page) { + finalUrl = page.url() + if (capturesBrowserMetrics) { + checkpoints.push(await browserProbeCheckpoint(page, "final")) + if (capture.has("memory")) { + memoryArtifact = browserProbeMemoryArtifact(checkpoints) + } + if (capture.has("performance")) { + performanceArtifact = browserProbePerformanceArtifact(checkpoints) + } + } + + if (capture.has("html")) { + const html = await page.content() + await writeFile(htmlPath, html) + htmlSha256 = sha256(Buffer.from(html, "utf8")) + } + + if (capture.has("screenshot")) { + await page.screenshot({ path: screenshotPath, fullPage: true }) + screenshotSha256 = await fileSha256(screenshotPath) + } + } await browser.close() if (capture.has("console")) { await writeFile(consolePath, jsonLines(consoleMessages)) diff --git a/packages/wordpress-plugin/src/class-wp-codebox-abilities.php b/packages/wordpress-plugin/src/class-wp-codebox-abilities.php index f27f729..fc07440 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-abilities.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-abilities.php @@ -87,8 +87,8 @@ private function register(): void { 'input_schema' => array( 'type' => 'object', 'anyOf' => array( - array( 'required' => array( 'goal' ) ), - array( 'required' => array( 'task' ) ), + array( 'type' => 'object', 'required' => array( 'goal' ) ), + array( 'type' => 'object', 'required' => array( 'task' ) ), ), 'properties' => array( 'goal' => $task_input_schema['properties']['goal'], @@ -291,8 +291,8 @@ private function register(): void { 'input_schema' => array( 'type' => 'object', 'anyOf' => array( - array( 'required' => array( 'goal' ) ), - array( 'required' => array( 'task' ) ), + array( 'type' => 'object', 'required' => array( 'goal' ) ), + array( 'type' => 'object', 'required' => array( 'task' ) ), ), 'properties' => array( 'goal' => $task_input_schema['properties']['goal'], @@ -1568,8 +1568,13 @@ private static function browser_package_component_plugin( string $slug, string $ return new WP_Error( 'wp_codebox_browser_plugin_package_hash_failed', 'Could not hash browser runtime plugin package.', array( 'status' => 500, 'slug' => $slug ) ); } + $data_url = self::browser_plugin_data_url( $zip_path, $slug ); + if ( is_wp_error( $data_url ) ) { + return $data_url; + } + return array( - 'url' => $url, + 'url' => $data_url, 'path' => $zip_path, 'sha256' => $sha256, ); @@ -1622,13 +1627,33 @@ private static function browser_package_remote_plugin( string $slug, string $url return new WP_Error( 'wp_codebox_browser_plugin_package_url_missing', 'Browser runtime plugin package URL is missing.', array( 'status' => 500, 'slug' => $slug ) ); } + $data_url = self::browser_plugin_data_url( $zip_path, $slug ); + if ( is_wp_error( $data_url ) ) { + return $data_url; + } + return array( - 'url' => $url, + 'url' => $data_url, 'path' => $zip_path, 'sha256' => $sha256, ); } + private static function browser_plugin_data_url( string $zip_path, string $slug ): string|WP_Error { + $max_bytes = (int) apply_filters( 'wp_codebox_browser_plugin_data_url_max_bytes', 24 * 1024 * 1024, $zip_path, $slug ); + $size = filesize( $zip_path ); + if ( is_int( $size ) && $size > $max_bytes ) { + return new WP_Error( 'wp_codebox_browser_plugin_package_too_large', 'Browser runtime plugin package is too large for inline browser delivery.', array( 'status' => 500, 'slug' => $slug, 'bytes' => $size, 'max_bytes' => $max_bytes ) ); + } + + $contents = file_get_contents( $zip_path ); + if ( ! is_string( $contents ) || '' === $contents ) { + return new WP_Error( 'wp_codebox_browser_plugin_package_read_failed', 'Could not read browser runtime plugin package.', array( 'status' => 500, 'slug' => $slug ) ); + } + + return 'data:application/zip;base64,' . base64_encode( $contents ); + } + private static function browser_download_remote_plugin( string $url, string $zip_path, string $slug ): true|WP_Error { $request = function_exists( 'wp_safe_remote_get' ) ? 'wp_safe_remote_get' : ( function_exists( 'wp_remote_get' ) ? 'wp_remote_get' : null ); if ( null === $request ) { @@ -1800,6 +1825,14 @@ private static function normalize_browser_plugins( array $plugins, string $field /** @return array{url:string,origin:string,host:string}|WP_Error */ private static function browser_local_plugin_url( string $url, int $index ): array|WP_Error { + if ( str_starts_with( $url, 'data:application/zip;base64,' ) ) { + return array( + 'url' => $url, + 'origin' => 'data:', + 'host' => 'data', + ); + } + $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { return new WP_Error( 'wp_codebox_browser_plugin_url_invalid', 'Browser plugin URL must be absolute.', array( 'status' => 400, 'index' => $index ) ); diff --git a/packages/wordpress-plugin/src/class-wp-codebox-task-input-contract.php b/packages/wordpress-plugin/src/class-wp-codebox-task-input-contract.php index 2ace384..96c759a 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-task-input-contract.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-task-input-contract.php @@ -20,10 +20,12 @@ public static function schema(): array { 'required' => array( 'schema', 'version', 'goal', 'target', 'allowed_tools', 'expected_artifacts', 'policy', 'context' ), 'properties' => array( 'schema' => array( + 'type' => 'string', 'const' => self::SCHEMA, 'description' => 'Task input contract schema id.', ), 'version' => array( + 'type' => 'integer', 'const' => self::VERSION, 'description' => 'Task input contract version.', ), diff --git a/tests/smoke-wordpress-plugin.php b/tests/smoke-wordpress-plugin.php index 9c26944..6fc7281 100644 --- a/tests/smoke-wordpress-plugin.php +++ b/tests/smoke-wordpress-plugin.php @@ -472,10 +472,10 @@ function get_users( array $args ): array { return array( new WP_User( 11, 'Priva $assert( 'browser Playground session defaults to latest WordPress and PHP', ! is_wp_error( $browser_session ) && 'latest' === ( $browser_session['playground']['blueprint']['preferredVersions']['wp'] ?? '' ) && 'latest' === ( $browser_session['playground']['blueprint']['preferredVersions']['php'] ?? '' ) ); $assert( 'browser Playground session logs in before admin workflows', ! is_wp_error( $browser_session ) && 'login' === ( $browser_session['playground']['blueprint']['steps'][0]['step'] ?? '' ) && 'admin' === ( $browser_session['playground']['blueprint']['steps'][0]['username'] ?? '' ) ); $assert( 'browser Playground session installs caller browser plugins without duplicating packaged components', ! is_wp_error( $browser_session ) && 'installPlugin' === ( $browser_session['playground']['blueprint']['steps'][1]['step'] ?? '' ) && 'https://example.test/agents-api.zip' === ( $browser_session['playground']['blueprint']['steps'][1]['pluginData']['url'] ?? '' ) && 1 === count( array_filter( $browser_session['plugins'], static fn( array $plugin ): bool => 'agents-api' === ( $plugin['slug'] ?? '' ) ) ) ); -$assert( 'browser Playground session packages required host runtime plugins', ! is_wp_error( $browser_session ) && str_starts_with( (string) ( $browser_session['plugins'][1]['url'] ?? '' ), 'https://parent.example.test/uploads/wp-codebox/browser-runtime-plugins/data-machine-' ) && str_starts_with( (string) ( $browser_session['plugins'][2]['url'] ?? '' ), 'https://parent.example.test/uploads/wp-codebox/browser-runtime-plugins/data-machine-code-' ) && 64 === strlen( (string) ( $browser_session['plugins'][1]['provenance']['sha256'] ?? '' ) ) ); +$assert( 'browser Playground session packages required host runtime plugins', ! is_wp_error( $browser_session ) && str_starts_with( (string) ( $browser_session['plugins'][1]['url'] ?? '' ), 'data:application/zip;base64,' ) && str_starts_with( (string) ( $browser_session['plugins'][2]['url'] ?? '' ), 'data:application/zip;base64,' ) && 64 === strlen( (string) ( $browser_session['plugins'][1]['provenance']['sha256'] ?? '' ) ) ); $assert( 'browser Playground session accepts structured runtime dependencies', ! is_wp_error( $browser_session ) && 'wp-codebox/browser-runtime-dependencies/v1' === ( $browser_session['runtime']['schema'] ?? '' ) && 6 === ( $browser_session['runtime']['summary']['plugins'] ?? 0 ) && 2 === ( $browser_session['runtime']['component_plugins'] ?? 0 ) && 1 === ( $browser_session['runtime']['summary']['mu_plugins'] ?? 0 ) && 1 === ( $browser_session['runtime']['summary']['themes'] ?? 0 ) && 1 === ( $browser_session['runtime']['summary']['bootstrap'] ?? 0 ) ); -$assert( 'browser Playground session server-packages remote runtime plugins after required components', ! is_wp_error( $browser_session ) && 'installPlugin' === ( $browser_session['playground']['blueprint']['steps'][4]['step'] ?? '' ) && str_starts_with( (string) ( $browser_session['playground']['blueprint']['steps'][4]['pluginData']['url'] ?? '' ), 'https://parent.example.test/uploads/wp-codebox/browser-runtime-plugins/static-site-importer-' ) && false === ( $browser_session['playground']['blueprint']['steps'][4]['options']['activate'] ?? true ) && 'runtime-plugin-remote-package' === ( $browser_session['plugins'][3]['provenance']['source'] ?? '' ) ); -$assert( 'browser Playground session packages declarative runtime plugin paths', ! is_wp_error( $browser_session ) && str_starts_with( (string) ( $browser_session['plugins'][4]['url'] ?? '' ), 'https://parent.example.test/uploads/wp-codebox/browser-runtime-plugins/studio-web-' ) && 64 === strlen( (string) ( $browser_session['plugins'][4]['provenance']['sha256'] ?? '' ) ) ); +$assert( 'browser Playground session server-packages remote runtime plugins after required components', ! is_wp_error( $browser_session ) && 'installPlugin' === ( $browser_session['playground']['blueprint']['steps'][4]['step'] ?? '' ) && str_starts_with( (string) ( $browser_session['playground']['blueprint']['steps'][4]['pluginData']['url'] ?? '' ), 'data:application/zip;base64,' ) && false === ( $browser_session['playground']['blueprint']['steps'][4]['options']['activate'] ?? true ) && 'runtime-plugin-remote-package' === ( $browser_session['plugins'][3]['provenance']['source'] ?? '' ) ); +$assert( 'browser Playground session packages declarative runtime plugin paths', ! is_wp_error( $browser_session ) && str_starts_with( (string) ( $browser_session['plugins'][4]['url'] ?? '' ), 'data:application/zip;base64,' ) && 64 === strlen( (string) ( $browser_session['plugins'][4]['provenance']['sha256'] ?? '' ) ) ); $assert( 'browser Playground session compiles git directory runtime plugins', ! is_wp_error( $browser_session ) && 'git:directory' === ( $browser_session['playground']['blueprint']['steps'][6]['pluginData']['resource'] ?? '' ) && 'plugins/example-git-plugin' === ( $browser_session['playground']['blueprint']['steps'][6]['pluginData']['path'] ?? '' ) && 'example-git-plugin' === ( $browser_session['playground']['blueprint']['steps'][6]['options']['targetFolderName'] ?? '' ) ); $assert( 'browser Playground session compiles mu-plugin runtime dependency', ! is_wp_error( $browser_session ) && 'runPHP' === ( $browser_session['playground']['blueprint']['steps'][7]['step'] ?? '' ) && str_contains( (string) ( $browser_session['playground']['blueprint']['steps'][7]['code'] ?? '' ), '/wordpress/wp-content/mu-plugins/example-review.php' ) ); $assert( 'browser Playground session compiles theme runtime dependency', ! is_wp_error( $browser_session ) && 'runPHP' === ( $browser_session['playground']['blueprint']['steps'][8]['step'] ?? '' ) && str_contains( (string) ( $browser_session['playground']['blueprint']['steps'][8]['code'] ?? '' ), '/wordpress/wp-content/themes/example-starter/style.css' ) && str_contains( (string) ( $browser_session['playground']['blueprint']['steps'][8]['code'] ?? '' ), "require_once '/wordpress/wp-load.php'" ) && str_contains( (string) ( $browser_session['playground']['blueprint']['steps'][8]['code'] ?? '' ), "require_once ABSPATH . WPINC . '/theme.php'" ) && str_contains( (string) ( $browser_session['playground']['blueprint']['steps'][8]['code'] ?? '' ), "switch_theme( 'example-starter' )" ) );