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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/recipe-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,11 @@ short-lived in-memory cookies from the disposable WordPress sandbox; `storage-st
imports caller-provided reusable state. Supplying both is rejected with structured
storage-state diagnostics rather than silently preferring one source.

Browser commands accept `capture=websocket` to write a generic
`browser-websocket` artifact. The artifact records safe connection metadata only:
redacted websocket URLs, open/close/error timestamps, frame counts, and aggregate
sent/received byte counts. Frame payloads are not written.

## Recipe Output Evidence

`recipe-run --json` returns `wp-codebox/recipe-run/v1`. Browser command sidecars
Expand Down
12 changes: 12 additions & 0 deletions packages/runtime-core/src/artifact-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ export interface ArtifactReviewBrowserSummary {
lifecycle?: string
network?: string
waterfall?: string
websocket?: string
networkEvents?: number
webSockets?: ArtifactReviewBrowserWebSocketSummary
checkpoints?: string
memory?: string
performance?: string
Expand Down Expand Up @@ -143,6 +145,16 @@ export interface ArtifactReviewBrowserSummary {
}>
}

export interface ArtifactReviewBrowserWebSocketSummary {
sockets: number
closed: number
errors: number
framesSent: number
framesReceived: number
bytesSent: number
bytesReceived: number
}

export interface ArtifactReviewBrowserRedirectDiagnosticsSummary {
status: "captured" | "not-applicable"
artifact?: string
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime-core/src/browser-probe-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type BrowserProbeProfileDefinition = {

export const BROWSER_PROBE_BROWSER_VALUES = ["chromium"] as const

export const BROWSER_PROBE_CAPTURE_VALUES = ["console", "errors", "html", "network", "performance", "memory", "screenshot"] as const
export const BROWSER_PROBE_CAPTURE_VALUES = ["console", "errors", "html", "network", "websocket", "performance", "memory", "screenshot"] as const
export const BROWSER_PROBE_CHROMIUM_PROFILE_IDS = ["desktop-chrome", "mobile-chrome", "low-end-mobile-slow-4g"] as const
export const BROWSER_PROBE_THROTTLE_PROFILE_IDS = ["low-end-mobile-slow-4g"] as const

Expand Down
16 changes: 12 additions & 4 deletions packages/runtime-playground/src/browser-actions-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { now, sha256 } from "@automattic/wp-codebox-core/internals"
import { browserInteractionStepsFromArgs, browserStepTimeoutMs, durationStringMs, sanitizeScreenshotName } from "./browser-actions.js"
import { BrowserArtifactSession } from "./browser-artifact-session.js"
import { BrowserCommandArtifactError, isBrowserCommandArtifactError } from "./browser-command-artifact-error.js"
import type { BrowserArtifact, BrowserProbeAuthSummary, BrowserProbeErrorRecord, BrowserProbeNetworkRecord, BrowserProbeViewport, BrowserStepRecord } from "./browser-artifacts.js"
import type { BrowserArtifact, BrowserProbeAuthSummary, BrowserProbeErrorRecord, BrowserProbeNetworkRecord, BrowserProbeViewport, BrowserProbeWebSocketRecord, BrowserStepRecord } from "./browser-artifacts.js"
import { attachBrowserCaptureListeners, launchChromiumBrowser, settleBrowserNetworkTasks } from "./browser-capture-session.js"
import { captureBrowserDomSnapshot, type BrowserDomSnapshotArtifact } from "./browser-dom-snapshot.js"
import { browserAssertionsSummary, browserStepRecord, executeBrowserInteractionStep } from "./browser-interactions.js"
Expand All @@ -13,7 +13,7 @@ import { serializeBrowserError } from "./browser-metrics.js"
import { browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, browserPreviewTopology, resolveBrowserPreviewUrl, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
import { BROWSER_PROBE_STATE_INIT_SCRIPT, browserProbeReplayability, browserProbeViewport } from "./browser-probe.js"
import { runBrowserProbeCommand, type BrowserProbeRunPlan } from "./browser-probe-runner.js"
import { browserActionTargetUrls, browserAuthRequest, browserProbeWaterfallArtifact, browserRedirectDiagnosticsArtifact, browserRequestCoverageArtifact, browserStorageStateAuthSummary, browserStorageStateImportFromArgs, browserWordPressDiagnosticsArtifact, createBrowserProbeProgressTracker, fileSha256, installBrowserWordPressDiagnostics, installWordPressAdminAuthCookies, livenessRemainingWallTimeMs, normalizeBrowserProbeScriptCheckpoint, type BrowserCommandProgressEvent, type BrowserStorageStateImport } from "./browser-probe-support.js"
import { browserActionTargetUrls, browserAuthRequest, browserProbeWaterfallArtifact, browserProbeWebSocketArtifact, browserProbeWebSocketSummary, browserRedirectDiagnosticsArtifact, browserRequestCoverageArtifact, browserStorageStateAuthSummary, browserStorageStateImportFromArgs, browserWordPressDiagnosticsArtifact, createBrowserProbeProgressTracker, fileSha256, installBrowserWordPressDiagnostics, installWordPressAdminAuthCookies, livenessRemainingWallTimeMs, normalizeBrowserProbeScriptCheckpoint, type BrowserCommandProgressEvent, type BrowserStorageStateImport } from "./browser-probe-support.js"
import { positiveIntegerArg } from "./command-args.js"
import { argValue, commaListArg, durationArg, viewportArg } from "./commands.js"
import type { PlaygroundRunResponse } from "./playground-command-errors.js"
Expand Down Expand Up @@ -82,8 +82,8 @@ export async function runBrowserActionsCommand({
const capture = runPlan.capture

for (const item of capture) {
if (!["steps", "console", "errors", "html", "network", "screenshot", "dom-snapshot"].includes(item)) {
throw new Error(`wordpress.browser-actions capture supports steps, console, errors, html, network, screenshot, dom-snapshot: ${item}`)
if (!["steps", "console", "errors", "html", "network", "websocket", "screenshot", "dom-snapshot"].includes(item)) {
throw new Error(`wordpress.browser-actions capture supports steps, console, errors, html, network, websocket, screenshot, dom-snapshot: ${item}`)
}
}

Expand All @@ -103,6 +103,7 @@ export async function runBrowserActionsCommand({
const consoleMessages: Record<string, unknown>[] = []
const errors: BrowserProbeErrorRecord[] = []
const network: BrowserProbeNetworkRecord[] = []
const webSockets: BrowserProbeWebSocketRecord[] = []
const networkTasks: Array<Promise<void>> = []
const screenshotPath = artifactSession.absolutePath("screenshot.png")
const startedAt = now()
Expand Down Expand Up @@ -156,11 +157,13 @@ export async function runBrowserActionsCommand({
captureConsole: capture.has("console"),
captureErrors: capture.has("errors"),
captureNetwork: true,
captureWebSocket: capture.has("websocket"),
consoleMessages,
errors,
network,
networkTasks,
page,
webSockets,
})

for (const [index, step] of steps.entries()) {
Expand Down Expand Up @@ -276,6 +279,9 @@ export async function runBrowserActionsCommand({
await artifactSession.writeJson("requestCoverage", "request-coverage.json", browserRequestCoverageArtifact(network, startedAt))
await artifactSession.writeJson("waterfall", "waterfall.json", browserProbeWaterfallArtifact(network, startedAt))
}
if (capture.has("websocket")) {
await artifactSession.writeJson("websocket", "websocket.json", browserProbeWebSocketArtifact(webSockets, startedAt))
}

const redirectDiagnostics = browserRedirectDiagnosticsArtifact({
artifactPath: "files/browser/redirect-diagnostics.json",
Expand Down Expand Up @@ -317,6 +323,7 @@ export async function runBrowserActionsCommand({
...(capture.has("network") ? { network: "files/browser/network.jsonl" } : {}),
...(capture.has("network") ? { requestCoverage: "files/browser/request-coverage.json" } : {}),
...(capture.has("network") ? { waterfall: "files/browser/waterfall.json" } : {}),
...(capture.has("websocket") ? { websocket: "files/browser/websocket.json" } : {}),
...(redirectDiagnostics ? { redirectDiagnostics: "files/browser/redirect-diagnostics.json" } : {}),
...(capture.has("screenshot") ? { screenshot: "files/browser/screenshot.png" } : {}),
...(domSnapshots.length > 0 ? { domSnapshots: domSnapshots.map((snapshot) => snapshot.snapshot) } : {}),
Expand All @@ -336,6 +343,7 @@ export async function runBrowserActionsCommand({
...(domSnapshots.length > 0 ? { domSnapshots } : {}),
liveness: { wallTimeoutMs: totalTimeoutMs, networkSettleTimeoutMs: livenessPolicy.networkSettleTimeoutMs },
networkEvents: network.length,
...(capture.has("websocket") ? { webSockets: browserProbeWebSocketSummary(webSockets) } : {}),
...(redirectDiagnosticsSummary ? { redirectDiagnostics: redirectDiagnosticsSummary } : {}),
...(wordpressDiagnosticsSummary ? { wordpressDiagnostics: wordpressDiagnosticsSummary } : {}),
replayability: browserProbeReplayability(capture),
Expand Down
45 changes: 44 additions & 1 deletion packages/runtime-playground/src/browser-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export interface BrowserArtifactFiles {
network?: string
requestCoverage?: string
waterfall?: string
websocket?: string
performance?: string
review?: string
screenshot?: string
Expand Down Expand Up @@ -158,6 +159,7 @@ export interface BrowserArtifactSummary {
memory?: BrowserProbeMemorySummary
metrics?: Record<string, number>
networkEvents: number
webSockets?: BrowserProbeWebSocketSummary
phaseMetrics?: BrowserProbePhaseMetricsArtifact
performance?: BrowserProbePerformanceSummary
progress?: BrowserProbeProgressSummary
Expand Down Expand Up @@ -338,6 +340,7 @@ export interface BrowserProbeReviewSummary {
probe: BrowserProbeIssueSummary
}
network: BrowserProbeNetworkReviewSummary
webSockets?: BrowserProbeWebSocketReviewSummary
redirectDiagnostics?: BrowserRedirectDiagnosticsSummary
wordpressDiagnostics?: BrowserWordPressDiagnosticsSummary
milestones: BrowserProbeMilestoneSummary
Expand Down Expand Up @@ -374,6 +377,21 @@ export interface BrowserProbeNetworkCountSummary {
transferSizeBytes: number
}

export interface BrowserProbeWebSocketSummary {
sockets: number
closed: number
errors: number
framesSent: number
framesReceived: number
bytesSent: number
bytesReceived: number
}

export interface BrowserProbeWebSocketReviewSummary extends BrowserProbeWebSocketSummary {
status: "captured" | "not-captured"
artifact?: BrowserProbeArtifactRef
}

export interface BrowserProbeMilestoneSummary {
status: "captured" | "not-captured"
count: number
Expand Down Expand Up @@ -485,7 +503,7 @@ export interface BrowserProbeProgressSummary {
terminalFailure?: BrowserProbeTerminalFailure
}

export type BrowserProbeProgressSource = "navigation" | "network" | "console" | "pageerror" | "checkpoint" | "script" | "duration" | "probe-error"
export type BrowserProbeProgressSource = "navigation" | "network" | "websocket" | "console" | "pageerror" | "checkpoint" | "script" | "duration" | "probe-error"

export interface BrowserProbeTerminalFailure {
message: string
Expand Down Expand Up @@ -852,6 +870,28 @@ export interface BrowserProbeNetworkRecord {
failure?: ReturnType<Request["failure"]>
}

export interface BrowserProbeWebSocketRecord {
url: string
openedAt: string
closedAt?: string
lastFrameAt?: string
lastErrorAt?: string
framesSent: number
framesReceived: number
bytesSent: number
bytesReceived: number
errors: number
}

export interface BrowserProbeWebSocketArtifact {
schema: "wp-codebox/browser-websocket/v1"
version: 1
capturedAt: string
startedAt: string
summary: BrowserProbeWebSocketSummary
sockets: BrowserProbeWebSocketRecord[]
}

export interface BrowserProbeWaterfallArtifact {
schema: "wp-codebox/browser-waterfall/v1"
version: 1
Expand Down Expand Up @@ -941,7 +981,9 @@ export function browserReviewSummary(probes: BrowserArtifact[]): ArtifactReviewB
lifecycle: probe.files.lifecycle,
network: probe.files.network,
waterfall: probe.files.waterfall,
websocket: probe.files.websocket,
networkEvents: probe.summary.networkEvents,
webSockets: probe.summary.webSockets,
screenshot: probe.files.screenshot,
domSnapshots: probe.files.domSnapshots,
console: probe.files.console,
Expand Down Expand Up @@ -1013,6 +1055,7 @@ const BROWSER_ARTIFACT_FILE_MANIFEST: Record<keyof BrowserArtifactFiles, Browser
network: { kind: "browser-network", contentType: "application/x-ndjson", redact: true },
requestCoverage: { kind: "browser-request-coverage", contentType: "application/json", redact: true },
waterfall: { kind: "browser-waterfall", contentType: "application/json", redact: true },
websocket: { kind: "browser-websocket", contentType: "application/json", redact: true },
performance: { kind: "browser-performance", contentType: "application/json", redact: true },
review: { kind: "browser-review", contentType: "application/json", redact: true },
screenshot: { kind: "browser-screenshot", contentType: "image/png", redact: false },
Expand Down
65 changes: 64 additions & 1 deletion packages/runtime-playground/src/browser-capture-session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { BrowserProbeErrorRecord, BrowserProbeNetworkRecord } from "./browser-artifacts.js"
import { redactString } from "@automattic/wp-codebox-core"
import type { BrowserProbeErrorRecord, BrowserProbeNetworkRecord, BrowserProbeWebSocketRecord } from "./browser-artifacts.js"
import { browserCommandLivenessPolicy } from "./browser-liveness.js"
import { serializeBrowserConsoleMessage, serializeBrowserError, serializeBrowserFinishedRequest, serializeBrowserRequestFailure } from "./browser-metrics.js"
import type { Browser, Page } from "playwright"
Expand All @@ -24,26 +25,32 @@ export function attachBrowserCaptureListeners({
captureConsole,
captureErrors,
captureNetwork,
captureWebSocket,
consoleMessages,
errors,
network,
networkTasks,
onConsole,
onNetwork,
onPageError,
onWebSocket,
page,
webSockets,
}: {
captureConsole: boolean
captureErrors: boolean
captureNetwork: boolean
captureWebSocket?: boolean
consoleMessages: Record<string, unknown>[]
errors: BrowserProbeErrorRecord[]
network: BrowserProbeNetworkRecord[]
networkTasks?: Array<Promise<void>>
onConsole?: () => void
onNetwork?: () => void
onPageError?: () => void
onWebSocket?: () => void
page: Page
webSockets?: BrowserProbeWebSocketRecord[]
}): void {
if (captureConsole) {
page.on("console", (message) => {
Expand Down Expand Up @@ -71,6 +78,62 @@ export function attachBrowserCaptureListeners({
network.push(serializeBrowserRequestFailure(request, new Date().toISOString()))
})
}
if (captureWebSocket && webSockets) {
page.on("websocket", (socket) => {
onWebSocket?.()
const record = createBrowserWebSocketRecord(socket.url(), new Date().toISOString())
webSockets.push(record)
socket.on("framesent", ({ payload }) => {
onWebSocket?.()
record.framesSent += 1
record.bytesSent += browserWebSocketPayloadBytes(payload)
record.lastFrameAt = new Date().toISOString()
})
socket.on("framereceived", ({ payload }) => {
onWebSocket?.()
record.framesReceived += 1
record.bytesReceived += browserWebSocketPayloadBytes(payload)
record.lastFrameAt = new Date().toISOString()
})
socket.on("socketerror", () => {
onWebSocket?.()
record.errors += 1
record.lastErrorAt = new Date().toISOString()
})
socket.on("close", () => {
onWebSocket?.()
record.closedAt = new Date().toISOString()
})
})
}
}

export function createBrowserWebSocketRecord(url: string, openedAt: string): BrowserProbeWebSocketRecord {
return {
url: redactBrowserWebSocketUrl(url),
openedAt,
framesSent: 0,
framesReceived: 0,
bytesSent: 0,
bytesReceived: 0,
errors: 0,
}
}

export function browserWebSocketPayloadBytes(payload: string | Buffer): number {
return typeof payload === "string" ? Buffer.byteLength(payload, "utf8") : payload.byteLength
}

function redactBrowserWebSocketUrl(url: string): string {
try {
const parsed = new URL(url)
const search = [...parsed.searchParams.keys()].length > 0
? `?${[...parsed.searchParams.keys()].map((key) => `${encodeURIComponent(key)}=[redacted]`).join("&")}`
: ""
return `${parsed.origin}${parsed.pathname}${search}${parsed.hash ? "#[redacted]" : ""}`
} catch {
return redactString(url, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true })
}
}

export async function settleBrowserNetworkTasks(networkTasks: Array<Promise<void>>, timeoutMs = browserCommandLivenessPolicy().networkSettleTimeoutMs): Promise<void> {
Expand Down
Loading