Skip to content

Commit 1c566e5

Browse files
committed
fix: distinguish policy blocks from browser failures
1 parent 95c3bb3 commit 1c566e5

5 files changed

Lines changed: 207 additions & 9 deletions

File tree

packages/runtime-core/src/browser-adaptive-exploration.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface BrowserAdaptiveExplorationContract {
3232
revisitPolicy: { maxStateVisits: number; maxActionVisits: number }
3333
descriptorLimits: { maxPerState: number; maxDiagnostics: number; maxTextLength: number }
3434
stabilization: { pollIntervalMs: number; quietWindowMs: number; maxWaitMs: number; maxMutationRecords: number }
35+
oraclePolicy: { policyBlocks: "evidence" | "finding" }
3536
failOnFinding: boolean
3637
accessibility?: BrowserAccessibilityContract
3738
metadata?: Record<string, unknown>
@@ -84,12 +85,23 @@ export interface BrowserAdaptiveTransition {
8485
loadingBefore: number
8586
loadingAfter: number
8687
oracleFingerprints: string[]
88+
networkFailures?: BrowserAdaptiveNetworkFailure[]
8789
accessibilityFindingFingerprints?: string[]
8890
}
8991
status: "ok" | "revisited" | "rejected" | "error" | "cancelled"
9092
diagnostic?: { code: string; message: string }
9193
}
9294

95+
export interface BrowserAdaptiveNetworkFailure {
96+
url: string
97+
host?: string
98+
urlClassification: "same-origin" | "external" | "invalid"
99+
policyDecision: "blocked" | "allowed" | "recorded" | "unknown"
100+
policyReason: string
101+
failure?: string
102+
oracleFinding: boolean
103+
}
104+
93105
export interface BrowserAdaptiveFinding {
94106
fingerprint: string
95107
stateDigest?: string
@@ -142,6 +154,7 @@ export function browserAdaptiveExplorationContract(input: Record<string, unknown
142154
const revisit = object(input.revisitPolicy ?? input.revisit_policy)
143155
const descriptors = object(input.descriptorLimits ?? input.descriptor_limits)
144156
const stabilization = object(input.stabilization)
157+
const oraclePolicy = object(input.oraclePolicy ?? input.oracle_policy)
145158
const reset = object(input.resetPolicy ?? input.reset_policy)
146159
const accessibility = browserAccessibilityContract(input.accessibility)
147160
const families = Array.isArray(input.actionFamilies ?? input.action_families)
@@ -179,6 +192,7 @@ export function browserAdaptiveExplorationContract(input: Record<string, unknown
179192
maxWaitMs: integer(stabilization.maxWaitMs ?? stabilization.max_wait_ms, 3_000, 50, 60_000),
180193
maxMutationRecords: integer(stabilization.maxMutationRecords ?? stabilization.max_mutation_records, 100, 1, 5_000),
181194
},
195+
oraclePolicy: { policyBlocks: (oraclePolicy.policyBlocks ?? oraclePolicy.policy_blocks) === "finding" ? "finding" as const : "evidence" as const },
182196
failOnFinding: input.failOnFinding !== false && input.fail_on_finding !== false,
183197
accessibility,
184198
metadata: isPlainObject(input.metadata) ? input.metadata : undefined,

packages/runtime-playground/src/browser-actions-runner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ export async function runBrowserActionsCommand({
240240
contract: adaptiveContract,
241241
observations: { consoleMessages, errors, network },
242242
navigationScope: topology.navigationScope,
243+
networkPolicy: topology.networkPolicy,
243244
signal: spec.signal,
244245
accessibilityCollector: adaptiveContract.accessibility ? createBrowserAccessibilityCollector(page, adaptiveContract.accessibility) : undefined,
245246
onAccessibilityFindingEvidence: adaptiveContract.accessibility ? async (scan) => {

packages/runtime-playground/src/browser-adaptive-explorer.ts

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type BrowserAdaptiveExplorationResult,
1010
type BrowserAdaptiveFinding,
1111
type BrowserAdaptiveFrameIdentity,
12+
type BrowserAdaptiveNetworkFailure,
1213
type BrowserAdaptiveState,
1314
type BrowserAdaptiveTransition,
1415
type BrowserAccessibilityCollector,
@@ -18,7 +19,7 @@ import { stableJson } from "@automattic/wp-codebox-core/internals"
1819
import type { Frame, Page } from "playwright"
1920

2021
import { discoverBrowserActionCorpusDescriptors } from "./browser-action-discovery.js"
21-
import type { BrowserPreviewNavigationScope } from "./browser-preview-routing.js"
22+
import { browserPreviewNetworkDecision, type BrowserPreviewNavigationScope, type BrowserPreviewNetworkPolicy } from "./browser-preview-routing.js"
2223

2324
interface AdaptiveObservationSources {
2425
consoleMessages: object[]
@@ -52,6 +53,7 @@ export async function exploreAdaptiveBrowserStateMachine({
5253
signal,
5354
now = Date.now,
5455
navigationScope,
56+
networkPolicy,
5557
accessibilityCollector,
5658
onAccessibilityFindingEvidence,
5759
}: {
@@ -62,6 +64,7 @@ export async function exploreAdaptiveBrowserStateMachine({
6264
signal?: AbortSignal
6365
now?: () => number
6466
navigationScope?: BrowserPreviewNavigationScope
67+
networkPolicy?: BrowserPreviewNetworkPolicy
6568
accessibilityCollector?: BrowserAccessibilityCollector
6669
onAccessibilityFindingEvidence?: (scan: Awaited<ReturnType<BrowserAccessibilityCollector["scan"]>>) => Promise<{ screenshot?: string; domSnapshot?: string }>
6770
}): Promise<BrowserAdaptiveExplorationResult> {
@@ -138,9 +141,11 @@ export async function exploreAdaptiveBrowserStateMachine({
138141
const stabilized = await stabilizeAdaptiveState(page, contract, source.path.length + 1, now, navigationScope)
139142
appendDiagnostics(diagnostics, stabilized.diagnostics, contract.descriptorLimits.maxDiagnostics)
140143
const scopeRejection = stabilized.diagnostics.find((diagnostic) => diagnostic.code === "browser_adaptive_redirect_scope_escape_rejected")
141-
const newConsoleErrors = consoleErrorMessages(observations.consoleMessages.slice(beforeConsole))
144+
const newConsoleRecords = consoleErrorRecords(observations.consoleMessages.slice(beforeConsole))
145+
const newConsoleErrors = newConsoleRecords.map(recordMessage)
142146
const newPageErrors = errorMessages(observations.errors.slice(beforeErrors))
143-
const fingerprints = [...new Set([...newConsoleErrors, ...newPageErrors].map((message) => browserAdaptiveDigest("oracle", message)))].sort()
147+
const oracleEvidence = adaptiveOracleEvidence(newConsoleRecords, observations.network.slice(beforeNetwork), newPageErrors, contract, networkPolicy)
148+
const fingerprints = [...oracleEvidence.fingerprints]
144149
const existing = states.get(stabilized.state.digest)
145150
const newState = !existing
146151
const transitionId = `transition-${transitions.length}`
@@ -183,12 +188,13 @@ export async function exploreAdaptiveBrowserStateMachine({
183188
loadingBefore: source.state.loadingIndicators,
184189
loadingAfter: stabilized.loading,
185190
oracleFingerprints: fingerprints,
191+
...(oracleEvidence.networkFailures.length > 0 ? { networkFailures: oracleEvidence.networkFailures } : {}),
186192
...(accessibilityFingerprints.length > 0 ? { accessibilityFindingFingerprints: accessibilityFingerprints } : {}),
187193
},
188194
status: actionError ? "error" : scopeRejection ? "rejected" : newState ? "ok" : "revisited",
189195
...(actionError ? { diagnostic: { code: "browser_adaptive_action_error", message: actionError } } : scopeRejection ? { diagnostic: { code: scopeRejection.code, message: scopeRejection.message } } : {}),
190196
}
191-
errors += newConsoleErrors.length + newPageErrors.length + (actionError ? 1 : 0)
197+
errors += oracleEvidence.errorCount + (actionError ? 1 : 0)
192198
if (artifactBytes(states, [...transitions, transition], diagnostics, findings) + (accessibilityCollector ? Buffer.byteLength(stableJson(accessibilityCollector.evidence())) : 0) > adaptiveJsonArtifactBudget(contract, accessibilityCollector)) {
193199
if (replayableDestination && newState) states.delete(stabilized.state.digest)
194200
exhausted = "maxArtifactBytes"
@@ -231,7 +237,7 @@ export async function exploreAdaptiveBrowserStateMachine({
231237

232238
if (findings.length > 0 && !signal?.aborted) {
233239
for (const finding of findings) {
234-
const minimized = await minimizeAdaptiveFinding(page, baseUrl, contract, finding, observations, Math.max(0, contract.budgets.maxActions - actions), Math.max(0, (contract.accessibility?.budgets.maxKeyboardActions ?? Number.POSITIVE_INFINITY) - keyboardActions), started + contract.budgets.maxDurationMs, signal, now, navigationScope, accessibilityCollector)
240+
const minimized = await minimizeAdaptiveFinding(page, baseUrl, contract, finding, observations, Math.max(0, contract.budgets.maxActions - actions), Math.max(0, (contract.accessibility?.budgets.maxKeyboardActions ?? Number.POSITIVE_INFINITY) - keyboardActions), started + contract.budgets.maxDurationMs, signal, now, navigationScope, networkPolicy, accessibilityCollector)
235241
actions += minimized.executed
236242
keyboardActions += minimized.keyboardExecuted
237243
finding.minimizedPath = minimized.path
@@ -491,7 +497,7 @@ async function restoreAdaptivePath(page: Page, baseUrl: string, contract: Browse
491497
}
492498
}
493499

494-
async function minimizeAdaptiveFinding(page: Page, baseUrl: string, contract: BrowserAdaptiveExplorationContract, finding: BrowserAdaptiveFinding, observations: AdaptiveObservationSources, maximumActions: number, maximumKeyboardActions: number, deadline: number, signal?: AbortSignal, now: () => number = Date.now, navigationScope?: BrowserPreviewNavigationScope, accessibilityCollector?: BrowserAccessibilityCollector): Promise<{ path: BrowserAdaptiveAction[]; executed: number; keyboardExecuted: number; exhausted?: "maxActions" | "maxKeyboardActions" | "maxDurationMs" | "cancelled" }> {
500+
async function minimizeAdaptiveFinding(page: Page, baseUrl: string, contract: BrowserAdaptiveExplorationContract, finding: BrowserAdaptiveFinding, observations: AdaptiveObservationSources, maximumActions: number, maximumKeyboardActions: number, deadline: number, signal?: AbortSignal, now: () => number = Date.now, navigationScope?: BrowserPreviewNavigationScope, networkPolicy?: BrowserPreviewNetworkPolicy, accessibilityCollector?: BrowserAccessibilityCollector): Promise<{ path: BrowserAdaptiveAction[]; executed: number; keyboardExecuted: number; exhausted?: "maxActions" | "maxKeyboardActions" | "maxDurationMs" | "cancelled" }> {
495501
let current = [...finding.originalPath]
496502
let chunk = Math.max(1, Math.floor(current.length / 2))
497503
let executed = 0
@@ -507,10 +513,11 @@ async function minimizeAdaptiveFinding(page: Page, baseUrl: string, contract: Br
507513
if (keyboardExecuted + countKeyboardActions(candidate) > maximumKeyboardActions) { exhausted = "maxKeyboardActions"; break }
508514
const beforeConsole = observations.consoleMessages.length
509515
const beforeErrors = observations.errors.length
516+
const beforeNetwork = observations.network.length
510517
const replay = await restoreAdaptivePath(page, baseUrl, contract, candidate, signal, navigationScope, accessibilityCollector)
511518
executed += replay.executed
512519
keyboardExecuted += replay.keyboardExecuted
513-
const fingerprints = [...consoleErrorMessages(observations.consoleMessages.slice(beforeConsole)), ...errorMessages(observations.errors.slice(beforeErrors))].map((message) => browserAdaptiveDigest("oracle", message)).concat(replay.finalAccessibilityFingerprints)
520+
const fingerprints = adaptiveOracleEvidence(consoleErrorRecords(observations.consoleMessages.slice(beforeConsole)), observations.network.slice(beforeNetwork), errorMessages(observations.errors.slice(beforeErrors)), contract, networkPolicy).fingerprints.concat(replay.finalAccessibilityFingerprints)
514521
if (replay.state && fingerprints.includes(finding.fingerprint) && (!finding.stateDigest || replay.state.digest === finding.stateDigest)) {
515522
current = candidate
516523
reduced = true
@@ -558,8 +565,47 @@ function rejectedTransition(state: BrowserAdaptiveState, action: BrowserAdaptive
558565
return { id: `transition-rejected-${browserAdaptiveDigest("action", `${state.digest}:${action.id}`).slice(0, 12)}`, sourceDigest: state.digest, action, sourceUrl: state.url, destinationUrl, history: { before: state.historyLength, after: state.historyLength, beforeStateDigest: state.historyStateDigest, afterStateDigest: state.historyStateDigest }, timing: { durationMs: 0, stabilizationMs: 0, polls: 0 }, novelty: { newState: false, newDescriptors: 0, mutationRecords: 0, mutationEvidenceTruncated: false }, observations: { networkEvents: 0, consoleErrors: [], pageErrors: [], loadingBefore: 0, loadingAfter: 0, oracleFingerprints: [] }, status: "rejected", diagnostic: { code, message } }
559566
}
560567

561-
function consoleErrorMessages(records: object[]): string[] {
562-
return records.map((record) => record as Record<string, unknown>).filter((record) => record.type === "error" || record.level === "error").map(recordMessage).filter(Boolean).slice(0, 100)
568+
function consoleErrorRecords(records: object[]): Record<string, unknown>[] {
569+
return records.map((record) => record as Record<string, unknown>).filter((record) => record.type === "error" || record.level === "error").slice(0, 100)
570+
}
571+
572+
function adaptiveOracleEvidence(consoleRecords: Record<string, unknown>[], networkRecords: object[], pageErrors: string[], contract: BrowserAdaptiveExplorationContract, networkPolicy?: BrowserPreviewNetworkPolicy): { fingerprints: string[]; networkFailures: BrowserAdaptiveNetworkFailure[]; errorCount: number } {
573+
const failures = networkRecords.map((record) => record as Record<string, unknown>).filter((record) => record.type === "requestfailed")
574+
const networkFailures = failures.map((record): BrowserAdaptiveNetworkFailure => {
575+
const url = typeof record.url === "string" ? record.url : ""
576+
const decision = networkPolicy ? browserPreviewNetworkDecision(url, networkPolicy) : { url, urlClassification: "invalid" as const, policyDecision: "unknown" as const, policyReason: "network-policy-unavailable" }
577+
const failure = networkFailureMessage(record)
578+
const expectedBlock = decision.policyDecision === "blocked" && /ERR_BLOCKED_BY_CLIENT|blockedbyclient/i.test(failure)
579+
return { ...decision, ...(failure ? { failure } : {}), oracleFinding: !expectedBlock || contract.oraclePolicy.policyBlocks === "finding" }
580+
})
581+
const expectedBlockUrls = networkFailures.filter((failure) => !failure.oracleFinding).map((failure) => failure.url)
582+
const findingConsoleRecords = consoleRecords.filter((record) => {
583+
if (!/ERR_BLOCKED_BY_CLIENT/i.test(recordMessage(record))) return true
584+
const location = objectRecord(record.location)
585+
const match = typeof location.url === "string" && location.url
586+
? expectedBlockUrls.indexOf(location.url)
587+
: expectedBlockUrls.length > 0 ? 0 : -1
588+
if (match < 0) return true
589+
expectedBlockUrls.splice(match, 1)
590+
return false
591+
})
592+
const consoleUrls = new Set(findingConsoleRecords.map((record) => objectRecord(record.location).url).filter((url): url is string => typeof url === "string"))
593+
const networkMessages = networkFailures.filter((failure) => failure.oracleFinding && !consoleUrls.has(failure.url)).map((failure) => stableJson({ type: "requestfailed", url: failure.url, failure: failure.failure, urlClassification: failure.urlClassification, policyDecision: failure.policyDecision, policyReason: failure.policyReason }))
594+
const messages = [...findingConsoleRecords.map(recordMessage), ...pageErrors, ...networkMessages]
595+
return {
596+
fingerprints: [...new Set(messages.map((message) => browserAdaptiveDigest("oracle", message)))].sort(),
597+
networkFailures,
598+
errorCount: findingConsoleRecords.length + pageErrors.length + networkMessages.length,
599+
}
600+
}
601+
602+
function networkFailureMessage(record: Record<string, unknown>): string {
603+
const failure = objectRecord(record.failure)
604+
return typeof failure.errorText === "string" ? failure.errorText : typeof record.failure === "string" ? record.failure : ""
605+
}
606+
607+
function objectRecord(value: unknown): Record<string, unknown> {
608+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}
563609
}
564610

565611
function errorMessages(records: object[]): string[] {

packages/runtime-playground/src/browser-preview-routing.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ export interface BrowserPreviewNavigationDecision {
2424
reason: string
2525
}
2626

27+
export interface BrowserPreviewNetworkDecision {
28+
url: string
29+
host?: string
30+
urlClassification: "same-origin" | "external" | "invalid"
31+
policyDecision: "blocked" | "allowed" | "recorded" | "unknown"
32+
policyReason: string
33+
}
34+
2735
export interface BrowserPreviewNavigationScope {
2836
resolve(href: string, documentUrl: string): BrowserPreviewNavigationDecision
2937
drainDiagnostics(): Array<{ code: string; message: string; metadata: Record<string, unknown> }>
@@ -232,6 +240,24 @@ export function browserPreviewNetworkPolicySummary(policy: BrowserPreviewNetwork
232240
}
233241
}
234242

243+
export function browserPreviewNetworkDecision(url: string, policy: BrowserPreviewNetworkPolicy): BrowserPreviewNetworkDecision {
244+
let parsed: URL
245+
try {
246+
parsed = new URL(url)
247+
} catch {
248+
return { url, urlClassification: "invalid", policyDecision: "unknown", policyReason: "url-invalid" }
249+
}
250+
const host = normalizeBrowserPreviewHost(parsed.hostname)
251+
const external = !policy.firstPartyHosts.has(host)
252+
const evidence = { url, host, urlClassification: external ? "external" as const : "same-origin" as const }
253+
if (policy.blockHosts.has(host)) return { ...evidence, policyDecision: "blocked", policyReason: "declared-block-host" }
254+
if (policy.mode === "block" && external && !policy.allowHosts.has(host)) return { ...evidence, policyDecision: "blocked", policyReason: "external-host-blocked-by-policy" }
255+
if (policy.allowHosts.has(host)) return { ...evidence, policyDecision: "allowed", policyReason: "declared-allow-host" }
256+
if (!external) return { ...evidence, policyDecision: "allowed", policyReason: "first-party-host" }
257+
if (policy.mode === "allow") return { ...evidence, policyDecision: "allowed", policyReason: "network-policy-allow" }
258+
return { ...evidence, policyDecision: "recorded", policyReason: "network-policy-record" }
259+
}
260+
235261
export async function routeBrowserPreviewPageNetwork(page: Page, policy: BrowserPreviewNetworkPolicy, previewOrigin: string, tracker?: BrowserPreviewRouteTracker): Promise<void> {
236262
await routeBrowserPreviewNetwork(page.route.bind(page), policy, previewOrigin, tracker)
237263
}

0 commit comments

Comments
 (0)