Skip to content

Commit 4001ad3

Browse files
committed
fix: preserve browser environment continuity
1 parent 93facbd commit 4001ad3

17 files changed

Lines changed: 522 additions & 155 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -739,7 +739,7 @@ For synthetic web performance investigations, enable `capture=performance` and u
739739

740740
`wordpress.browser-actions` drives the preview with an ordered interaction script so Codebox can prove a plugin still *works* under interaction, not just that it renders. Pass the script as `steps-json=<array>` (inline JSON, or `@<path>` to read it from a file). Each step is a thin, stable mapping over a Playwright locator action — this is not a test-runner DSL.
741741

742-
Action journeys and adaptive exploration can declare their browser context with `browser-environment-json=<object>` (inline or `@<path>`) or the compatible `device`, `viewport`, `device-scale-factor`, `is-mobile`, `has-touch`, `locale`, `timezone`, and `geolocation-*` arguments. These controls are applied when the Playwright context is created, so mobile/touch emulation and granted, denied, or prompt geolocation remain effective for the full journey. Action summaries record requested and effective environment state, provider capabilities, and explicit unsupported dimensions. `wordpress.browser-scenario` accepts the same arguments or an `environment` object inside `scenario-json` and carries it into authored action steps.
742+
Action journeys and adaptive exploration can declare their browser context with `browser-environment-json=<object>` (inline or `@<path>`) or the compatible `device`, `viewport`, `device-scale-factor`, `is-mobile`, `has-touch`, `user-agent`, `permissions`, `locale`, `timezone`, and `geolocation-*` arguments. These controls are applied when the Playwright context is created, so mobile/touch emulation and granted, denied, or prompt geolocation remain active for the full journey. Action summaries keep requested configuration, provider-resolved configuration, and browser-observed state separate, with explicit unsupported and inconclusive dimensions. `wordpress.browser-scenario` accepts the same arguments or an `environment` object inside `scenario-json`; probe collection and authored actions share one owned context/page, while multi-actor scenarios create one isolated configured context per actor.
743743

744744
Step kinds: `navigate` (`url`, optional `waitFor=domcontentloaded|load|networkidle`), `click`/`hover` (`selector` or `text`), `fill`/`type` (`selector`, `value`), `press` (`key`, optional `selector`), `drag` (`from` selector, `to` as `{ "selector": ... }` or `{ "x": n, "y": n }`), `select` (`selector`, `value` or `values`), `waitFor` (`selector` or `waitFor=domcontentloaded|load|networkidle|duration|selector:<sel>`), `evaluate` (`expression`, optional `assert` to deep-equal the result), `expect` (`selector`, optional `state=visible|hidden|attached|detached|enabled|disabled|checked|unchecked|editable`), and `screenshot` (optional `name` for a named capture). Every step may set its own `timeout=<n>s`; the command also accepts a global `step-timeout=<n>s` (per step) and `timeout=<n>s` (total-script budget). Both are bounded and deterministic — the run stops cleanly on the first failing step, with no silent partial success.
745745

packages/cli/src/recipe-validation.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { readFile, stat } from "node:fs/promises"
22
import { dirname, join, resolve } from "node:path"
3-
import { RUNTIME_BACKED_FUZZ_SUITE_RUNNER_CAPABILITIES, assertFixtureImportDeterministicIdsSupported, assertWorkspaceRecipeJsonSchema, browserEnvironment, commandArgValue, normalizeRuntimeBackendKind, normalizeRuntimeMountTarget, parseCommandJson, safeArtifactRelativePath, validateBrowserInteractionScript, validateRuntimePolicy, validateSourcePackage, workspaceRecipeRuntimeCollectedArtifacts, type MountSpec, type RuntimeAssetSpec, type RuntimePolicy, type RuntimePreviewSpec, type WorkspaceRecipe, type WorkspaceRecipeDeclaredArtifact, type WorkspaceRecipeDependencyOverlay, type WorkspaceRecipeDistribution, type WorkspaceRecipeDistributionStartupProbe, type WorkspaceRecipeFixtureDatabase, type WorkspaceRecipeFuzzCasePhase, type WorkspaceRecipeMount, type WorkspaceRecipePluginRuntime, type WorkspaceRecipePluginRuntimeHealthProbe, type WorkspaceRecipeProbe, type WorkspaceRecipeRuntimeBackendPackage, type WorkspaceRecipeRuntimeOverlay, type WorkspaceRecipeSiteSeed } from "@automattic/wp-codebox-core"
3+
import { BROWSER_PROBE_CHROMIUM_PROFILE_IDS, RUNTIME_BACKED_FUZZ_SUITE_RUNNER_CAPABILITIES, assertFixtureImportDeterministicIdsSupported, assertWorkspaceRecipeJsonSchema, browserEnvironment, commandArgValue, normalizeRuntimeBackendKind, normalizeRuntimeMountTarget, parseCommandJson, safeArtifactRelativePath, validateBrowserInteractionScript, validateRuntimePolicy, validateSourcePackage, workspaceRecipeRuntimeCollectedArtifacts, type MountSpec, type RuntimeAssetSpec, type RuntimePolicy, type RuntimePreviewSpec, type WorkspaceRecipe, type WorkspaceRecipeDeclaredArtifact, type WorkspaceRecipeDependencyOverlay, type WorkspaceRecipeDistribution, type WorkspaceRecipeDistributionStartupProbe, type WorkspaceRecipeFixtureDatabase, type WorkspaceRecipeFuzzCasePhase, type WorkspaceRecipeMount, type WorkspaceRecipePluginRuntime, type WorkspaceRecipePluginRuntimeHealthProbe, type WorkspaceRecipeProbe, type WorkspaceRecipeRuntimeBackendPackage, type WorkspaceRecipeRuntimeOverlay, type WorkspaceRecipeSiteSeed } from "@automattic/wp-codebox-core"
44
import { commandValidationDescriptorFor, effectivePolicyCommandsFor, type CommandArgValidationDescriptor } from "@automattic/wp-codebox-core/contracts"
55
import { composerPackageVendorPath, evaluateRecipeSourcePolicy, isComposerPackageName, pluginTarget, recipeExtraPluginSlug, recipeExtraPluginSource, recipeExtraPluginSourceRoot, recipeExtraPluginSourceSubpath, recipeExtraPlugins, recipeSource, resolveRecipeExtraPluginFile } from "./recipe-sources.js"
66
import { loadConfiguredRuntimeOverlayDescriptors, registeredRuntimeOverlayDescriptors, runtimeOverlayDescriptor, runtimeOverlayTarget } from "./runtime-overlay-registry.js"
@@ -582,7 +582,7 @@ export async function validateWorkspaceRecipeSemantics(recipe: WorkspaceRecipe,
582582
continue
583583
}
584584

585-
await validateRecipeStepArgs(step, path, addIssue)
585+
await validateRecipeStepArgs(step, path, addIssue, recipeDirectory)
586586
}
587587

588588
for (const [index, mount] of (recipe.inputs?.mounts ?? []).entries()) {
@@ -693,7 +693,7 @@ export async function validateWorkspaceRecipeSemantics(recipe: WorkspaceRecipe,
693693
validateAbsoluteSandboxPath(`${pluginTarget(overlay.consumer, loadAs)}/vendor/${composerPackageVendorPath(overlay.package)}`, `${path}.target`, addIssue)
694694
}
695695

696-
await validateRecipePluginRuntime(recipe.inputs?.pluginRuntime, addIssue)
696+
await validateRecipePluginRuntime(recipe.inputs?.pluginRuntime, addIssue, recipeDirectory)
697697

698698
for (const [index, fixture] of (recipe.inputs?.fixtureDatabases ?? []).entries()) {
699699
const path = `$.inputs.fixtureDatabases[${index}]`
@@ -711,7 +711,7 @@ export async function validateWorkspaceRecipeSemantics(recipe: WorkspaceRecipe,
711711
addIssue("unsupported-command", `${path}.command`, `Unsupported recipe probe command: ${probe.step.command}`)
712712
continue
713713
}
714-
await validateRecipeStepArgs(probe.step, path, addIssue)
714+
await validateRecipeStepArgs(probe.step, path, addIssue, recipeDirectory)
715715
}
716716

717717
for (const [index, name] of (recipe.inputs?.secretEnv ?? []).entries()) {
@@ -1318,7 +1318,7 @@ export function recipeWpCliCommandFromArgs(args: string[]): string {
13181318
return recipeStepArgValue(args, "command")?.trim() ?? args.join(" ").trim()
13191319
}
13201320

1321-
async function validateRecipePluginRuntime(pluginRuntime: WorkspaceRecipePluginRuntime | undefined, addIssue: (code: string, path: string, message: string) => void): Promise<void> {
1321+
async function validateRecipePluginRuntime(pluginRuntime: WorkspaceRecipePluginRuntime | undefined, addIssue: (code: string, path: string, message: string) => void, recipeDirectory: string): Promise<void> {
13221322
if (!pluginRuntime) {
13231323
return
13241324
}
@@ -1366,7 +1366,7 @@ async function validateRecipePluginRuntime(pluginRuntime: WorkspaceRecipePluginR
13661366
addIssue("unsupported-plugin-runtime-setup-command", `${path}.command`, `Unsupported plugin runtime setup command: ${step.command}`)
13671367
continue
13681368
}
1369-
await validateRecipeStepArgs(step, path, addIssue)
1369+
await validateRecipeStepArgs(step, path, addIssue, recipeDirectory)
13701370
}
13711371

13721372
for (const [index, probe] of (pluginRuntime.healthProbes ?? []).entries()) {
@@ -1501,7 +1501,7 @@ export function hasExplicitSiteSeedSelectors(scope: NonNullable<WorkspaceRecipeS
15011501
return [scope.ids, scope.slugs, scope.names].some((values) => Array.isArray(values) && values.length > 0)
15021502
}
15031503

1504-
async function validateRecipeStepArgs(step: WorkspaceRecipe["workflow"]["steps"][number], path: string, addIssue: (code: string, path: string, message: string) => void): Promise<void> {
1504+
async function validateRecipeStepArgs(step: WorkspaceRecipe["workflow"]["steps"][number], path: string, addIssue: (code: string, path: string, message: string) => void, recipeDirectory: string): Promise<void> {
15051505
validateRecipeStepDescriptorArgs(step, path, addIssue)
15061506

15071507
if (step.command === "wordpress.run-php" || step.command === "wordpress.phpunit" || step.command === "wordpress.core-phpunit") {
@@ -1563,14 +1563,17 @@ async function validateRecipeStepArgs(step: WorkspaceRecipe["workflow"]["steps"]
15631563
addIssue("incomplete-geolocation", `${path}.args`, `${step.command} geolocation-permission requires geolocation-latitude and geolocation-longitude.`)
15641564
}
15651565
const environmentJson = recipeStepArgValue(step.args ?? [], "browser-environment-json")
1566-
if (environmentJson && !environmentJson.startsWith("@")) {
1566+
if (environmentJson) {
15671567
try {
1568-
browserEnvironment(JSON.parse(environmentJson))
1568+
const rawEnvironment = environmentJson.startsWith("@") ? await readFile(resolve(recipeDirectory, environmentJson.slice(1)), "utf8") : environmentJson
1569+
browserEnvironment(JSON.parse(rawEnvironment))
15691570
} catch (error) {
15701571
addIssue("invalid-browser-environment", `${path}.args`, `${step.command} browser-environment-json is invalid: ${error instanceof Error ? error.message : String(error)}`)
15711572
}
15721573
}
1573-
if (step.command !== "wordpress.browser-probe") return
1574+
}
1575+
1576+
if (step.command === "wordpress.browser-probe") {
15741577
for (const assertion of (step.args ?? []).filter((arg) => arg.startsWith("assert=")).map((arg) => arg.slice("assert=".length).trim())) {
15751578
const rawNormalized = assertion.startsWith("advisory:") ? assertion.slice("advisory:".length).trim() : assertion
15761579
const frameSeparator = rawNormalized.startsWith("frame:") || rawNormalized.startsWith("frame-url:") ? rawNormalized.indexOf("|") : -1
@@ -1673,6 +1676,18 @@ async function validateRecipeStepArgs(step: WorkspaceRecipe["workflow"]["steps"]
16731676
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
16741677
addIssue("invalid-scenario-json", `${path}.args`, "wordpress.browser-scenario scenario-json must be a JSON object.")
16751678
} else {
1679+
const profile = (parsed as { profile?: unknown }).profile
1680+
if (profile !== undefined && (typeof profile !== "string" || !(BROWSER_PROBE_CHROMIUM_PROFILE_IDS as readonly string[]).includes(profile))) {
1681+
addIssue("invalid-profile", `${path}.args`, `wordpress.browser-scenario profile is unsupported: ${String(profile)}`)
1682+
}
1683+
const environment = (parsed as { environment?: unknown }).environment
1684+
if (environment !== undefined) {
1685+
try {
1686+
browserEnvironment(environment as Parameters<typeof browserEnvironment>[0])
1687+
} catch (error) {
1688+
addIssue("invalid-browser-environment", `${path}.args`, `wordpress.browser-scenario environment is invalid: ${error instanceof Error ? error.message : String(error)}`)
1689+
}
1690+
}
16761691
const steps = (parsed as { steps?: unknown }).steps
16771692
if (steps !== undefined) {
16781693
const result = validateBrowserInteractionScript(normalizeBrowserScenarioStepsForValidation(steps))

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto"
33
import type { BrowserActionCorpusDescriptor, BrowserInteractionStep, BrowserRandomWalkContext } from "./browser-interaction.js"
44
import { browserAccessibilityContract, type BrowserAccessibilityContract, type BrowserAccessibilityEvidence } from "./browser-accessibility.js"
55
import { isPlainObject, stableJson, stripUndefined } from "./object-utils.js"
6+
import { browserEnvironment, browserEnvironmentDigest, type BrowserEnvironment } from "./browser-environment-matrix.js"
67

78
export const BROWSER_ADAPTIVE_EXPLORATION_SCHEMA = "wp-codebox/browser-adaptive-exploration/v1" as const
89
export const BROWSER_ADAPTIVE_EXPLORATION_ARTIFACT_SCHEMA = "wp-codebox/browser-adaptive-exploration-artifact/v1" as const
@@ -36,6 +37,8 @@ export interface BrowserAdaptiveExplorationContract {
3637
failOnFinding: boolean
3738
accessibility?: BrowserAccessibilityContract
3839
metadata?: Record<string, unknown>
40+
environment: BrowserEnvironment
41+
environmentDigest: string
3942
}
4043

4144
export interface BrowserAdaptiveFrameIdentity {
@@ -116,6 +119,8 @@ export interface BrowserAdaptiveFinding {
116119
expectedStateDigest?: string
117120
actions: BrowserAdaptiveAction[]
118121
resetPolicy: BrowserAdaptiveExplorationContract["resetPolicy"]
122+
environment: BrowserEnvironment
123+
environmentDigest: string
119124
}
120125
}
121126

@@ -138,7 +143,7 @@ export interface BrowserAdaptiveExplorationResult {
138143
findings: number
139144
budgetExhausted?: keyof BrowserAdaptiveExplorationContract["budgets"] | "maxKeyboardActions" | "cancelled" | "frontier"
140145
}
141-
replay: { schema: typeof BROWSER_ADAPTIVE_EXPLORATION_SCHEMA; seed: string; startUrl: string; contract: BrowserAdaptiveExplorationContract }
146+
replay: { schema: typeof BROWSER_ADAPTIVE_EXPLORATION_SCHEMA; seed: string; startUrl: string; environment: BrowserEnvironment; environmentDigest: string; contract: BrowserAdaptiveExplorationContract }
142147
}
143148

144149
export interface BrowserAdaptiveExplorationArtifact {
@@ -162,6 +167,7 @@ export function browserAdaptiveExplorationContract(input: Record<string, unknown
162167
: []
163168
const actionFamilies = [...new Set(families.filter((value): value is BrowserAdaptiveActionFamily => (BROWSER_ADAPTIVE_ACTION_FAMILIES as readonly unknown[]).includes(value)))]
164169
const startUrl = string(input.startUrl ?? input.start_url) ?? (context === "admin" ? "/wp-admin/" : context === "editor" ? "/wp-admin/post-new.php" : "/")
170+
const environment = browserEnvironment(isPlainObject(input.environment) ? input.environment as BrowserEnvironment : {})
165171
return stripUndefined({
166172
schema: BROWSER_ADAPTIVE_EXPLORATION_SCHEMA,
167173
context,
@@ -196,6 +202,8 @@ export function browserAdaptiveExplorationContract(input: Record<string, unknown
196202
failOnFinding: input.failOnFinding !== false && input.fail_on_finding !== false,
197203
accessibility,
198204
metadata: isPlainObject(input.metadata) ? input.metadata : undefined,
205+
environment,
206+
environmentDigest: browserEnvironmentDigest(environment),
199207
})
200208
}
201209

0 commit comments

Comments
 (0)