Skip to content

Commit 869e0f7

Browse files
authored
fix: honor routed preview hosts in adaptive exploration (#2036)
1 parent 9e20ff1 commit 869e0f7

5 files changed

Lines changed: 216 additions & 25 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ export async function runBrowserActionsCommand({
238238
baseUrl: preview.effectiveOrigin,
239239
contract: adaptiveContract,
240240
observations: { consoleMessages, errors, network },
241+
navigationScope: topology.navigationScope,
241242
signal: spec.signal,
242243
})
243244
adaptiveExplorationArtifact = { schema: "wp-codebox/browser-adaptive-exploration-artifact/v1", contract: adaptiveContract, result, capturedAt: now() }

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

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { stableJson } from "@automattic/wp-codebox-core/internals"
1616
import type { Frame, Page } from "playwright"
1717

1818
import { discoverBrowserActionCorpusDescriptors } from "./browser-action-discovery.js"
19+
import type { BrowserPreviewNavigationScope } from "./browser-preview-routing.js"
1920

2021
interface AdaptiveObservationSources {
2122
consoleMessages: object[]
@@ -48,13 +49,15 @@ export async function exploreAdaptiveBrowserStateMachine({
4849
observations,
4950
signal,
5051
now = Date.now,
52+
navigationScope,
5153
}: {
5254
page: Page
5355
baseUrl: string
5456
contract: BrowserAdaptiveExplorationContract
5557
observations: AdaptiveObservationSources
5658
signal?: AbortSignal
5759
now?: () => number
60+
navigationScope?: BrowserPreviewNavigationScope
5861
}): Promise<BrowserAdaptiveExplorationResult> {
5962
const started = now()
6063
const states = new Map<string, BrowserAdaptiveState>()
@@ -67,7 +70,7 @@ export async function exploreAdaptiveBrowserStateMachine({
6770
let revisits = 0
6871
let exhausted: BrowserAdaptiveExplorationResult["summary"]["budgetExhausted"]
6972

70-
const initial = await captureAdaptiveState(page, contract, 0)
73+
const initial = await captureAdaptiveState(page, contract, 0, navigationScope)
7174
appendDiagnostics(diagnostics, initial.diagnostics, contract.descriptorLimits.maxDiagnostics)
7275
states.set(initial.state.digest, initial.state)
7376
const frontier: FrontierEntry[] = [{ state: initial.state, path: [] }]
@@ -90,7 +93,7 @@ export async function exploreAdaptiveBrowserStateMachine({
9093

9194
if (contract.resetPolicy.mode === "start-url" && (transitions.length > 0 || source.path.length > 0)) {
9295
if (actions + source.path.length >= contract.budgets.maxActions) { exhausted = "maxActions"; break }
93-
const restored = await restoreAdaptivePath(page, baseUrl, contract, source.path, signal)
96+
const restored = await restoreAdaptivePath(page, baseUrl, contract, source.path, signal, navigationScope)
9497
actions += restored.executed
9598
appendDiagnostics(diagnostics, restored.diagnostics, contract.descriptorLimits.maxDiagnostics)
9699
if (!restored.state || restored.state.digest !== source.state.digest) {
@@ -113,8 +116,9 @@ export async function exploreAdaptiveBrowserStateMachine({
113116
actionError = error instanceof Error ? error.message : String(error)
114117
}
115118
actions += 1
116-
const stabilized = await stabilizeAdaptiveState(page, contract, source.path.length + 1, now)
119+
const stabilized = await stabilizeAdaptiveState(page, contract, source.path.length + 1, now, navigationScope)
117120
appendDiagnostics(diagnostics, stabilized.diagnostics, contract.descriptorLimits.maxDiagnostics)
121+
const scopeRejection = stabilized.diagnostics.find((diagnostic) => diagnostic.code === "browser_adaptive_redirect_scope_escape_rejected")
118122
const newConsoleErrors = consoleErrorMessages(observations.consoleMessages.slice(beforeConsole))
119123
const newPageErrors = errorMessages(observations.errors.slice(beforeErrors))
120124
const fingerprints = [...new Set([...newConsoleErrors, ...newPageErrors].map((message) => browserAdaptiveDigest("oracle", message)))].sort()
@@ -153,8 +157,8 @@ export async function exploreAdaptiveBrowserStateMachine({
153157
loadingAfter: stabilized.loading,
154158
oracleFingerprints: fingerprints,
155159
},
156-
status: actionError ? "error" : newState ? "ok" : "revisited",
157-
...(actionError ? { diagnostic: { code: "browser_adaptive_action_error", message: actionError } } : {}),
160+
status: actionError ? "error" : scopeRejection ? "rejected" : newState ? "ok" : "revisited",
161+
...(actionError ? { diagnostic: { code: "browser_adaptive_action_error", message: actionError } } : scopeRejection ? { diagnostic: { code: scopeRejection.code, message: scopeRejection.message } } : {}),
158162
}
159163
errors += newConsoleErrors.length + newPageErrors.length + (actionError ? 1 : 0)
160164
if (artifactBytes(states, [...transitions, transition], diagnostics, findings) > contract.budgets.maxArtifactBytes) {
@@ -199,7 +203,7 @@ export async function exploreAdaptiveBrowserStateMachine({
199203

200204
if (findings.length > 0 && !signal?.aborted) {
201205
for (const finding of findings) {
202-
const minimized = await minimizeAdaptiveFinding(page, baseUrl, contract, finding, observations, Math.max(0, contract.budgets.maxActions - actions), started + contract.budgets.maxDurationMs, signal, now)
206+
const minimized = await minimizeAdaptiveFinding(page, baseUrl, contract, finding, observations, Math.max(0, contract.budgets.maxActions - actions), started + contract.budgets.maxDurationMs, signal, now, navigationScope)
203207
actions += minimized.executed
204208
finding.minimizedPath = minimized.path
205209
finding.replay.actions = finding.minimizedPath
@@ -223,8 +227,9 @@ export async function exploreAdaptiveBrowserStateMachine({
223227
}
224228
}
225229

226-
async function captureAdaptiveState(page: Page, contract: BrowserAdaptiveExplorationContract, depth: number): Promise<CapturedAdaptiveState> {
230+
async function captureAdaptiveState(page: Page, contract: BrowserAdaptiveExplorationContract, depth: number, navigationScope?: BrowserPreviewNavigationScope): Promise<CapturedAdaptiveState> {
227231
const diagnostics: BrowserAdaptiveExplorationResult["diagnostics"] = []
232+
diagnostics.push(...(navigationScope?.drainDiagnostics() ?? []))
228233
const mainOrigin = origin(page.url())
229234
const frames = frameIdentities(page, contract.descriptorLimits.maxPerState)
230235
const descriptors: BrowserActionCorpusDescriptor[] = []
@@ -242,9 +247,15 @@ async function captureAdaptiveState(page: Page, contract: BrowserAdaptiveExplora
242247
const discovery = await discoverBrowserActionCorpusDescriptors(frame)
243248
diagnostics.push(...discovery.diagnostics.map((diagnostic) => ({ ...diagnostic, metadata: { ...diagnostic.metadata, frameId: identity.id } })))
244249
const scopedDescriptors = discovery.descriptors.filter((descriptor) => {
245-
const hrefOrigin = descriptor.href ? origin(descriptor.href) : undefined
246-
if (!hrefOrigin || !mainOrigin || hrefOrigin === mainOrigin) return true
247-
diagnostics.push({ code: "browser_adaptive_cross_origin_action_rejected", message: "A link leaving the exploration origin was excluded from the adaptive action frontier.", metadata: { frameId: identity.id, descriptorId: descriptor.id, href: descriptor.href } })
250+
if (!descriptor.href) return true
251+
const decision = navigationScope?.resolve(descriptor.href, frame.url())
252+
const hrefOrigin = origin(descriptor.href)
253+
if (decision?.allowed) {
254+
if (decision.routeDecision === "routed-preview") diagnostics.push({ code: "browser_adaptive_routed_action_allowed", message: "A link using a declared preview route was included in the adaptive action frontier.", metadata: { frameId: identity.id, descriptorId: descriptor.id, rawHrefOrigin: decision.rawOrigin, effectiveOrigin: decision.effectiveOrigin, routeDecision: decision.routeDecision, reason: decision.reason } })
255+
return true
256+
}
257+
if (!decision && (!hrefOrigin || !mainOrigin || hrefOrigin === mainOrigin)) return true
258+
diagnostics.push({ code: "browser_adaptive_cross_origin_action_rejected", message: "A link leaving the declared preview navigation scope was excluded from the adaptive action frontier.", metadata: { frameId: identity.id, descriptorId: descriptor.id, rawHrefOrigin: decision?.rawOrigin ?? hrefOrigin, effectiveOrigin: decision?.effectiveOrigin ?? mainOrigin, routeDecision: decision?.routeDecision ?? "external", reason: decision?.reason ?? "origin-mismatch" } })
248259
return false
249260
})
250261
descriptors.push(...scopedDescriptors.slice(0, contract.descriptorLimits.maxPerState).map((descriptor) => ({ ...descriptor, frameId: identity.id })))
@@ -332,22 +343,35 @@ async function executeAdaptiveAction(page: Page, action: BrowserAdaptiveAction,
332343
}
333344
}
334345

335-
async function stabilizeAdaptiveState(page: Page, contract: BrowserAdaptiveExplorationContract, depth: number, now: () => number): Promise<StabilizationResult> {
346+
async function stabilizeAdaptiveState(page: Page, contract: BrowserAdaptiveExplorationContract, depth: number, now: () => number, navigationScope?: BrowserPreviewNavigationScope): Promise<StabilizationResult> {
336347
const started = now()
348+
const diagnostics: BrowserAdaptiveExplorationResult["diagnostics"] = []
349+
const diagnosticKeys = new Set<string>()
350+
const retainDiagnostics = (incoming: BrowserAdaptiveExplorationResult["diagnostics"]) => {
351+
for (const diagnostic of incoming) {
352+
const key = stableJson(diagnostic)
353+
if (!diagnosticKeys.has(key)) {
354+
diagnosticKeys.add(key)
355+
diagnostics.push(diagnostic)
356+
}
357+
}
358+
}
337359
let polls = 0
338360
let quietSince = started
339361
let previous: CapturedAdaptiveState | undefined
340362
while (now() - started < contract.stabilization.maxWaitMs) {
341363
await page.waitForTimeout(contract.stabilization.pollIntervalMs)
342-
const current = await captureAdaptiveState(page, contract, depth)
364+
const current = await captureAdaptiveState(page, contract, depth, navigationScope)
365+
retainDiagnostics(current.diagnostics)
343366
polls += 1
344367
if (!previous || current.state.digest !== previous.state.digest) quietSince = now()
345368
previous = current
346369
if (current.loading === 0 && now() - quietSince >= contract.stabilization.quietWindowMs) break
347370
}
348-
const state = previous ?? await captureAdaptiveState(page, contract, depth)
371+
const state = previous ?? await captureAdaptiveState(page, contract, depth, navigationScope)
372+
if (!previous) retainDiagnostics(state.diagnostics)
349373
const mutations = await readMutationObservers(page, contract)
350-
return { ...state, waitedMs: Math.max(0, now() - started), polls, mutationRecords: mutations.count, mutationEvidenceTruncated: mutations.truncated }
374+
return { ...state, diagnostics, waitedMs: Math.max(0, now() - started), polls, mutationRecords: mutations.count, mutationEvidenceTruncated: mutations.truncated }
351375
}
352376

353377
async function installMutationObservers(page: Page, contract: BrowserAdaptiveExplorationContract): Promise<void> {
@@ -386,26 +410,26 @@ async function readMutationObservers(page: Page, contract: BrowserAdaptiveExplor
386410
return { count, truncated }
387411
}
388412

389-
async function restoreAdaptivePath(page: Page, baseUrl: string, contract: BrowserAdaptiveExplorationContract, path: BrowserAdaptiveAction[], signal?: AbortSignal): Promise<{ state?: BrowserAdaptiveState; executed: number; diagnostics: BrowserAdaptiveExplorationResult["diagnostics"] }> {
413+
async function restoreAdaptivePath(page: Page, baseUrl: string, contract: BrowserAdaptiveExplorationContract, path: BrowserAdaptiveAction[], signal?: AbortSignal, navigationScope?: BrowserPreviewNavigationScope): Promise<{ state?: BrowserAdaptiveState; executed: number; diagnostics: BrowserAdaptiveExplorationResult["diagnostics"] }> {
390414
const diagnostics: BrowserAdaptiveExplorationResult["diagnostics"] = []
391415
let executed = 0
392416
try {
393417
await page.goto(resolveUrl(contract.startUrl, baseUrl), { waitUntil: "domcontentloaded", timeout: contract.stabilization.maxWaitMs })
394-
await stabilizeAdaptiveState(page, contract, 0, Date.now)
418+
await stabilizeAdaptiveState(page, contract, 0, Date.now, navigationScope)
395419
for (const action of path) {
396420
if (signal?.aborted) return { executed, diagnostics }
397421
executed += 1
398422
await executeAdaptiveAction(page, action, contract.stabilization.maxWaitMs)
399-
await stabilizeAdaptiveState(page, contract, executed, Date.now)
423+
await stabilizeAdaptiveState(page, contract, executed, Date.now, navigationScope)
400424
}
401-
return { state: (await captureAdaptiveState(page, contract, path.length)).state, executed, diagnostics }
425+
return { state: (await captureAdaptiveState(page, contract, path.length, navigationScope)).state, executed, diagnostics }
402426
} catch (error) {
403427
diagnostics.push({ code: "browser_adaptive_reset_replay_failed", message: "The start-URL reset path could not reproduce a queued state.", metadata: { reason: error instanceof Error ? error.message : String(error) } })
404428
return { executed, diagnostics }
405429
}
406430
}
407431

408-
async function minimizeAdaptiveFinding(page: Page, baseUrl: string, contract: BrowserAdaptiveExplorationContract, finding: BrowserAdaptiveFinding, observations: AdaptiveObservationSources, maximumActions: number, deadline: number, signal?: AbortSignal, now: () => number = Date.now): Promise<{ path: BrowserAdaptiveAction[]; executed: number; exhausted?: "maxActions" | "maxDurationMs" | "cancelled" }> {
432+
async function minimizeAdaptiveFinding(page: Page, baseUrl: string, contract: BrowserAdaptiveExplorationContract, finding: BrowserAdaptiveFinding, observations: AdaptiveObservationSources, maximumActions: number, deadline: number, signal?: AbortSignal, now: () => number = Date.now, navigationScope?: BrowserPreviewNavigationScope): Promise<{ path: BrowserAdaptiveAction[]; executed: number; exhausted?: "maxActions" | "maxDurationMs" | "cancelled" }> {
409433
let current = [...finding.originalPath]
410434
let chunk = Math.max(1, Math.floor(current.length / 2))
411435
let executed = 0
@@ -419,7 +443,7 @@ async function minimizeAdaptiveFinding(page: Page, baseUrl: string, contract: Br
419443
if (executed + candidate.length > maximumActions) { exhausted = "maxActions"; break }
420444
const beforeConsole = observations.consoleMessages.length
421445
const beforeErrors = observations.errors.length
422-
const replay = await restoreAdaptivePath(page, baseUrl, contract, candidate, signal)
446+
const replay = await restoreAdaptivePath(page, baseUrl, contract, candidate, signal, navigationScope)
423447
executed += replay.executed
424448
const fingerprints = [...consoleErrorMessages(observations.consoleMessages.slice(beforeConsole)), ...errorMessages(observations.errors.slice(beforeErrors))].map((message) => browserAdaptiveDigest("oracle", message))
425449
if (replay.state && fingerprints.includes(finding.fingerprint) && (!finding.stateDigest || replay.state.digest === finding.stateDigest)) {

0 commit comments

Comments
 (0)