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
63 changes: 55 additions & 8 deletions packages/runtime-playground/src/browser-actions-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { BrowserArtifactSession } from "./browser-artifact-session.js"
import { BrowserCommandArtifactError, isBrowserCommandArtifactError } from "./browser-command-artifact-error.js"
import { runBrowserMultiActorScenarioCommand } from "./browser-multi-actor-scenario-runner.js"
import type { BrowserArtifact, BrowserProbeAuthSummary, BrowserProbeErrorRecord, BrowserProbeNetworkRecord, BrowserProbeViewport, BrowserProbeWebSocketRecord, BrowserStepRecord } from "./browser-artifacts.js"
import { attachBrowserCaptureListeners, launchChromiumBrowser, settleBrowserNetworkTasks } from "./browser-capture-session.js"
import { attachBrowserCaptureListeners, captureBrowserPageHtml, launchChromiumBrowser, settleBrowserNetworkTasks, trackBrowserNavigation, type BrowserNavigationTracker } from "./browser-capture-session.js"
import { captureBrowserDomSnapshot, type BrowserDomSnapshotArtifact } from "./browser-dom-snapshot.js"
import { browserAssertionsSummary, browserStepRecord, executeBrowserInteractionStep } from "./browser-interactions.js"
import { browserCommandLivenessPolicy, isBrowserCommandLivenessError, withBrowserCommandLiveness } from "./browser-liveness.js"
Expand Down Expand Up @@ -159,6 +159,9 @@ export async function runBrowserActionsCommand({
let environmentEvidence: BrowserArtifact["summary"]["environment"] | undefined
let resolvedEnvironment: Awaited<ReturnType<typeof resolvePlaywrightBrowserEnvironment>> | undefined
let activePage: Page | undefined
let navigationTracker: BrowserNavigationTracker | undefined
let adaptiveCaptureNavigationUnsettled = false
let adaptiveCaptureBudgetMs = 0
let installedTransportFaults: InstalledBrowserTransportFaults | undefined
let transportFaultReport: BrowserTransportFaultReport | undefined
const abortHandler = () => {
Expand Down Expand Up @@ -205,6 +208,7 @@ export async function runBrowserActionsCommand({
}
if (context && runPlan.transportFaults) installedTransportFaults = await installBrowserTransportFaults(context, runPlan.transportFaults, { policy: browserPreviewTransportFaultPolicy(networkPolicy, topology.origins.localProxyOrigin), serviceWorkersBlocked: true })
const page = activePage = environmentRuntime?.page ?? await browser.newPage()
navigationTracker = trackBrowserNavigation(page)
if (onProgress) {
await page.exposeFunction("__wpCodeboxProbeCheckpointEvent", (checkpoint: unknown) => {
const normalized = normalizeBrowserProbeScriptCheckpoint(checkpoint)
Expand Down Expand Up @@ -433,9 +437,30 @@ export async function runBrowserActionsCommand({

if (capture.has("html")) {
try {
const html = await page.content()
await artifactSession.writeText("html", "snapshot.html", html)
htmlSha256 = sha256(Buffer.from(html, "utf8"))
const captureBudgetMs = Math.min(
runPlan.adaptiveExploration?.stabilization.maxWaitMs ?? stepTimeoutMs,
livenessRemainingWallTimeMs(startedAtMs, totalTimeoutMs),
)
adaptiveCaptureBudgetMs = captureBudgetMs
const captureResult = await captureBrowserPageHtml(page, navigationTracker, captureBudgetMs)
if (captureResult.status === "captured") {
await artifactSession.writeText("html", "snapshot.html", captureResult.html)
htmlSha256 = sha256(Buffer.from(captureResult.html, "utf8"))
} else if (adaptiveExplorationArtifact) {
adaptiveCaptureNavigationUnsettled = true
adaptiveExplorationArtifact.result.status = "incomplete"
adaptiveExplorationArtifact.result.diagnostics.unshift({
code: "browser_adaptive_capture_navigation_unsettled",
message: "Adaptive exploration ended while document navigation remained active; HTML capture was omitted and partial browser evidence was retained.",
metadata: { attempts: captureResult.attempts, budgetMs: captureBudgetMs, waitedMs: captureResult.waitedMs, reason: captureResult.reason },
})
adaptiveExplorationArtifact.result.diagnostics.splice(adaptiveExplorationArtifact.contract.descriptorLimits.maxDiagnostics)
boundAdaptiveExplorationArtifact(adaptiveExplorationArtifact, Math.max(512, Math.floor(adaptiveExplorationArtifact.contract.budgets.maxArtifactBytes / 2)))
await artifactSession.writeJson("adaptiveExploration", "adaptive-exploration.json", adaptiveExplorationArtifact)
if (adaptiveExplorationSummary) adaptiveExplorationSummary.status = "incomplete"
} else {
throw new Error(captureResult.reason)
}
} catch (error) {
const serialized = serializeBrowserError("probe-error", error)
errors.push(serialized)
Expand All @@ -447,7 +472,19 @@ export async function runBrowserActionsCommand({

if (capture.has("screenshot")) {
try {
await artifactSession.writeGenerated("screenshot", "screenshot.png", (path) => page.screenshot({ path, fullPage: true }).then(() => undefined))
const screenshotCaptureBudgetMs = adaptiveCaptureNavigationUnsettled ? Math.min(adaptiveCaptureBudgetMs, livenessRemainingWallTimeMs(startedAtMs, totalTimeoutMs)) : 0
if (adaptiveCaptureNavigationUnsettled && screenshotCaptureBudgetMs <= 0) throw new Error("Adaptive screenshot capture budget was exhausted before capture started.")
const screenshotCapture = artifactSession.writeGenerated("screenshot", "screenshot.png", (path) => page.screenshot({ path, fullPage: true }).then(() => undefined))
if (adaptiveCaptureNavigationUnsettled) {
await withBrowserCommandLiveness({
command: "wordpress.browser-actions",
phase: "adaptive partial screenshot capture",
operation: screenshotCapture,
policy: { wallTimeoutMs: screenshotCaptureBudgetMs, idleTimeoutMs: 0 },
})
} else {
await screenshotCapture
}
screenshotSha256 = await fileSha256(screenshotPath)
if (capture.has("dom-snapshot")) {
domSnapshots.push(await captureBrowserActionDomSnapshot({
Expand All @@ -463,7 +500,16 @@ export async function runBrowserActionsCommand({
} catch (error) {
const serialized = serializeBrowserError("probe-error", error)
errors.push(serialized)
if (!pendingError) {
if (adaptiveCaptureNavigationUnsettled && adaptiveExplorationArtifact) {
adaptiveExplorationArtifact.result.diagnostics.unshift({
code: "browser_adaptive_capture_screenshot_unavailable",
message: "Screenshot capture did not settle inside the remaining adaptive capture budget; other partial browser evidence was retained.",
metadata: { budgetMs: adaptiveCaptureBudgetMs, reason: error instanceof Error ? error.message : String(error) },
})
adaptiveExplorationArtifact.result.diagnostics.splice(adaptiveExplorationArtifact.contract.descriptorLimits.maxDiagnostics)
boundAdaptiveExplorationArtifact(adaptiveExplorationArtifact, Math.max(512, Math.floor(adaptiveExplorationArtifact.contract.budgets.maxArtifactBytes / 2)))
await artifactSession.writeJson("adaptiveExploration", "adaptive-exploration.json", adaptiveExplorationArtifact)
} else if (!pendingError) {
pendingError = error instanceof Error ? error : new Error(String(error))
}
}
Expand Down Expand Up @@ -547,7 +593,7 @@ export async function runBrowserActionsCommand({
...(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" } : {}),
...(screenshotSha256 ? { screenshot: "files/browser/screenshot.png" } : {}),
...(screenshots.length > 0 ? { screenshots } : {}),
...(domSnapshots.length > 0 ? { domSnapshots: domSnapshots.map((snapshot) => snapshot.snapshot) } : {}),
...(verifierResults.length > 0 ? { verifierResults: verifierResults.map((result) => result.artifact) } : {}),
Expand Down Expand Up @@ -578,7 +624,7 @@ export async function runBrowserActionsCommand({
...(wordpressDiagnosticsSummary ? { wordpressDiagnostics: wordpressDiagnosticsSummary } : {}),
...(transportFaultReport ? { transportFaults: browserTransportFaultSummary(transportFaultReport) } : {}),
replayability: browserProbeReplayability(capture),
screenshot: capture.has("screenshot"),
screenshot: Boolean(screenshotSha256),
auth: authSummary,
environment: environmentEvidence,
viewport,
Expand Down Expand Up @@ -617,6 +663,7 @@ export async function runBrowserActionsCommand({
summary: artifact.summary,
})
abortSignal?.removeEventListener("abort", abortHandler)
navigationTracker?.dispose()
}

if (pendingError) {
Expand Down
135 changes: 134 additions & 1 deletion packages/runtime-playground/src/browser-capture-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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"
import type { Browser, Page, Request } from "playwright"
import { assertPlaywrightBrowserReady } from "./playwright-browser-provenance.js"

export async function launchChromiumBrowser(): Promise<Browser> {
Expand All @@ -23,6 +23,139 @@ export function chromiumBrowserMetadata(browser: Browser): { name: "chromium"; c
}
}

export interface BrowserNavigationTracker {
navigating(): boolean
waitForSettlement(timeoutMs: number): Promise<boolean>
dispose(): void
}

export type BrowserHtmlCaptureResult = {
status: "captured"
html: string
attempts: number
waitedMs: number
navigationObserved: boolean
} | {
status: "navigation_unsettled"
attempts: number
waitedMs: number
navigationObserved: true
reason: string
}

export function trackBrowserNavigation(page: Page): BrowserNavigationTracker {
const active = new Set<Request>()
const waiters = new Set<() => void>()
const notify = () => {
if (active.size > 0) return
for (const resolve of waiters) resolve()
waiters.clear()
}
const onRequest = (request: Request) => {
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) active.add(request)
}
const onRequestFailed = (request: Request) => {
if (!active.has(request)) return
active.clear()
notify()
}
const onDomContentLoaded = () => {
active.clear()
notify()
}
page.on("request", onRequest)
page.on("requestfailed", onRequestFailed)
page.on("domcontentloaded", onDomContentLoaded)

return {
navigating: () => active.size > 0,
async waitForSettlement(timeoutMs) {
if (active.size === 0) return true
if (timeoutMs <= 0) return false
return await new Promise<boolean>((resolve) => {
let timeout: ReturnType<typeof setTimeout> | undefined
const settled = () => {
if (timeout) clearTimeout(timeout)
waiters.delete(settled)
resolve(true)
}
waiters.add(settled)
if (active.size === 0) {
settled()
return
}
timeout = setTimeout(() => {
waiters.delete(settled)
resolve(false)
}, timeoutMs)
})
},
dispose() {
page.off("request", onRequest)
page.off("requestfailed", onRequestFailed)
page.off("domcontentloaded", onDomContentLoaded)
active.clear()
notify()
},
}
}

export async function captureBrowserPageHtml(page: Page, navigation: BrowserNavigationTracker, timeoutMs: number): Promise<BrowserHtmlCaptureResult> {
const startedAt = Date.now()
const deadline = startedAt + Math.max(0, timeoutMs)
let attempts = 0
let navigationObserved = navigation.navigating()
let reason = "Navigation did not settle before the browser capture budget expired."

while (true) {
if (navigation.navigating()) {
navigationObserved = true
const settled = await navigation.waitForSettlement(Math.max(0, deadline - Date.now()))
if (!settled) return { status: "navigation_unsettled", attempts, waitedMs: Date.now() - startedAt, navigationObserved: true, reason }
}

attempts += 1
try {
const content = await captureBrowserContentWithin(page, Math.max(0, deadline - Date.now()))
if (content.status === "timeout") {
return { status: "navigation_unsettled", attempts, waitedMs: Date.now() - startedAt, navigationObserved: true, reason: "page.content did not settle before the browser capture budget expired." }
}
return { status: "captured", html: content.html, attempts, waitedMs: Date.now() - startedAt, navigationObserved }
} catch (error) {
if (!browserContentNavigationRace(error)) throw error
navigationObserved = true
reason = error instanceof Error ? error.message : String(error)
const remainingMs = Math.max(0, deadline - Date.now())
if (remainingMs <= 0) return { status: "navigation_unsettled", attempts, waitedMs: Date.now() - startedAt, navigationObserved: true, reason }
if (!navigation.navigating()) await page.waitForTimeout(Math.min(10, remainingMs))
const settled = await navigation.waitForSettlement(remainingMs)
if (!settled || Date.now() >= deadline) return { status: "navigation_unsettled", attempts, waitedMs: Date.now() - startedAt, navigationObserved: true, reason }
}
}
}

async function captureBrowserContentWithin(page: Page, timeoutMs: number): Promise<{ status: "captured"; html: string } | { status: "timeout" }> {
if (timeoutMs <= 0) return { status: "timeout" }
const content = page.content()
content.catch(() => undefined)
let timeout: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
content.then((html) => ({ status: "captured" as const, html })),
new Promise<{ status: "timeout" }>((resolve) => {
timeout = setTimeout(() => resolve({ status: "timeout" }), timeoutMs)
}),
])
} finally {
if (timeout) clearTimeout(timeout)
}
}

function browserContentNavigationRace(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /page\.content: Unable to retrieve content because the page is navigating and changing the content\./i.test(message)
}

export function attachBrowserCaptureListeners({
captureConsole,
captureErrors,
Expand Down
Loading
Loading