Skip to content

Commit 3f9ca38

Browse files
authored
fix: preserve browser environments across action journeys (#2099)
* fix: preserve browser environments across action journeys * fix: preserve browser environment continuity * fix: close browser environment validation gaps * fix: resolve recipe browser payload paths * test: bind adaptive oracle expectations to environments
1 parent e58ea10 commit 3f9ca38

22 files changed

Lines changed: 1061 additions & 158 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,8 @@ 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`, `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.
743+
742744
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.
743745

744746
The arbitrary-JS `evaluate` step is policy-gated **separately** from the non-JS interaction steps: a script containing `evaluate` requires `wordpress.browser-actions.evaluate` in the runtime policy in addition to `wordpress.browser-actions`. Click/fill/drag/expect and friends never require the extra grant, so a consumer can allow UI driving while still forbidding arbitrary page JS.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@
181181
"test:generic-primitives": "npm run test:artifact-path-primitives && npm run test:browser-callback-materialization-contracts && npm run test:browser-canonical-preview-origin && npm run test:source-package-compiler-primitives && npm run test:bench-command-step-behavior && npm run test:generic-ability-runtime-run",
182182
"test:browser-artifact-session": "tsx tests/browser-artifact-session.test.ts",
183183
"test:browser-environment-matrix": "tsx --test tests/browser-environment-matrix.test.ts tests/browser-environment-matrix.browser.test.ts",
184+
"test:browser-actions-environment": "tsx --test tests/browser-actions-environment.browser.test.ts",
185+
"test:browser-recipe-file-payloads-integration": "tsx tests/browser-recipe-file-payloads.integration.test.ts",
184186
"test:browser-diagnostic-providers": "tsx tests/browser-diagnostic-providers.test.ts",
185187
"test:browser-capture-html-diagnostics-reliability": "tsx tests/browser-capture-html-diagnostics-reliability.test.ts",
186188
"test:browser-provider-permissions": "tsx tests/browser-provider-permissions.test.ts",

packages/cli/src/agent-sandbox.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export function agentRuntimeMounts(options: AgentRuntimeProbeOptions): AgentRunt
117117

118118
export async function recipeExecutionSpec(step: WorkspaceRecipe["workflow"]["steps"][number], recipeDirectory: string, sandboxWorkspace?: SandboxWorkspaceContract, options: { inputMountPathMap?: readonly InputMountPathMapping[] } = {}): Promise<ResolvedRecipeExecutionSpec> {
119119
const originalArgs = step.args ?? []
120-
const resolvedStep = { ...step, args: rewriteRecipeExecutionArgs(step.command, originalArgs, options.inputMountPathMap) }
120+
const resolvedStep = { ...step, args: rewriteRecipeExecutionArgs(step.command, originalArgs, recipeDirectory, options.inputMountPathMap) }
121121
const finish = (spec: ExecutionSpec & { args?: string[] }): ResolvedRecipeExecutionSpec => {
122122
// Commands can generate PHP and serialized payloads after their source args
123123
// are resolved, so canonicalize the generated execution spec as well.
@@ -186,8 +186,8 @@ export async function recipeExecutionSpec(step: WorkspaceRecipe["workflow"]["ste
186186
return finish({ command: resolvedStep.command, args: [...(resolvedStep.args ?? []), ...commandDiagnosticsCaptureArgs(resolvedStep.diagnostics)], diagnostics: resolvedStep.diagnostics })
187187
}
188188

189-
function rewriteRecipeExecutionArgs(command: string, args: readonly string[], inputMountPathMap: readonly InputMountPathMapping[] = []): string[] {
190-
const rewritten = rewriteInputMountPathArgs(args, inputMountPathMap)
189+
function rewriteRecipeExecutionArgs(command: string, args: readonly string[], recipeDirectory: string, inputMountPathMap: readonly InputMountPathMapping[] = []): string[] {
190+
const rewritten = rewriteRecipeBrowserPayloadArgs(command, rewriteInputMountPathArgs(args, inputMountPathMap), recipeDirectory)
191191
if (command === "wordpress.run-workload") {
192192
return rewriteInputMountPathJsonArgs(rewritten, ["workload-json"], inputMountPathMap)
193193
}
@@ -197,6 +197,22 @@ function rewriteRecipeExecutionArgs(command: string, args: readonly string[], in
197197
return rewritten
198198
}
199199

200+
function rewriteRecipeBrowserPayloadArgs(command: string, args: readonly string[], recipeDirectory: string): string[] {
201+
const fileBackedArgs = command === "wordpress.browser-actions"
202+
? new Set(["steps-json", "browser-environment-json"])
203+
: command === "wordpress.browser-scenario"
204+
? new Set(["scenario-json", "steps-json", "browser-environment-json"])
205+
: undefined
206+
if (!fileBackedArgs) return [...args]
207+
return args.map((arg) => {
208+
const separator = arg.indexOf("=")
209+
if (separator < 0 || !fileBackedArgs.has(arg.slice(0, separator))) return arg
210+
const value = arg.slice(separator + 1)
211+
if (!value.startsWith("@")) return arg
212+
return `${arg.slice(0, separator + 1)}@${resolve(recipeDirectory, value.slice(1))}`
213+
})
214+
}
215+
200216
async function wordpressRunWorkloadExecutionSpec(step: WorkspaceRecipe["workflow"]["steps"][number], recipeDirectory: string): Promise<ExecutionSpec & { args: string[] }> {
201217
const args = step.args ?? []
202218
const workloadJson = commandArgValue(args, "workload-json")

packages/cli/src/commands/recipe-run.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
110110
await artifactPointer.update({ commandStatus: "queued" })
111111
const issues = [
112112
...await validateWorkspaceRecipe(recipe, recipePath),
113-
...validateRecipeRuntimePolicy(recipe, options.policy ?? recipePolicy(recipe)),
113+
...validateRecipeRuntimePolicy(recipe, options.policy ?? recipePolicy(recipe, recipeDirectory), recipeDirectory),
114114
]
115115
if (issues.length > 0) {
116116
const failure = {
@@ -727,7 +727,7 @@ async function validateRecipe(options: RecipeValidateOptions): Promise<RecipeVal
727727
const recipe = await loadWorkspaceRecipe(recipePath)
728728
const issues = [
729729
...await validateWorkspaceRecipe(recipe, recipePath),
730-
...validateRecipeRuntimePolicy(recipe, options.policy ?? recipePolicy(recipe)),
730+
...validateRecipeRuntimePolicy(recipe, options.policy ?? recipePolicy(recipe, dirname(recipePath)), dirname(recipePath)),
731731
]
732732

733733
return {

packages/cli/src/recipe-dry-run.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ export async function dryRunRecipe(options: RecipeDryRunOptions, context: Recipe
275275

276276
const issues = [
277277
...await validateWorkspaceRecipe(recipe, recipePath),
278-
...validateRecipeRuntimePolicy(recipe, options.policy),
278+
...validateRecipeRuntimePolicy(recipe, options.policy, recipeDirectory),
279279
]
280280

281281
if (issues.length > 0) {
@@ -324,7 +324,7 @@ export async function dryRunRecipe(options: RecipeDryRunOptions, context: Recipe
324324
}
325325

326326
export async function planWorkspaceRecipe(recipe: WorkspaceRecipe, recipeDirectory: string, options: RecipePlanOptions, context: RecipePlanContext): Promise<RecipePlan> {
327-
const policy = options.policy ?? recipePolicy(recipe)
327+
const policy = options.policy ?? recipePolicy(recipe, recipeDirectory)
328328
const policyValidation = validateRuntimePolicy(policy)
329329
const workspaces = recipeDryRunWorkspaces(recipe, recipeDirectory)
330330
const extraPlugins = recipeDryRunExtraPlugins(recipe, recipeDirectory)

0 commit comments

Comments
 (0)