Skip to content

Commit 2039a10

Browse files
committed
fix: preserve browser environments across action journeys
1 parent 6445ee1 commit 2039a10

11 files changed

Lines changed: 371 additions & 30 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`, `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.
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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@
180180
"test:generic-primitives": "npm run test:artifact-path-primitives && npm run test:browser-callback-materialization-contracts && npm run test:source-package-compiler-primitives && npm run test:bench-command-step-behavior && npm run test:generic-ability-runtime-run",
181181
"test:browser-artifact-session": "tsx tests/browser-artifact-session.test.ts",
182182
"test:browser-environment-matrix": "tsx --test tests/browser-environment-matrix.test.ts tests/browser-environment-matrix.browser.test.ts",
183+
"test:browser-actions-environment": "tsx --test tests/browser-actions-environment.browser.test.ts",
183184
"test:browser-diagnostic-providers": "tsx tests/browser-diagnostic-providers.test.ts",
184185
"test:browser-capture-html-diagnostics-reliability": "tsx tests/browser-capture-html-diagnostics-reliability.test.ts",
185186
"test:browser-provider-permissions": "tsx tests/browser-provider-permissions.test.ts",

packages/cli/src/recipe-validation.ts

Lines changed: 14 additions & 5 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, 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 { 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"
@@ -1548,20 +1548,29 @@ async function validateRecipeStepArgs(step: WorkspaceRecipe["workflow"]["steps"]
15481548
return
15491549
}
15501550

1551-
if (step.command === "wordpress.browser-probe") {
1551+
if (["wordpress.browser-probe", "wordpress.browser-actions", "wordpress.browser-scenario"].includes(step.command)) {
15521552
const latitude = recipeStepArgValue(step.args ?? [], "geolocation-latitude")
15531553
const longitude = recipeStepArgValue(step.args ?? [], "geolocation-longitude")
15541554
const accuracy = recipeStepArgValue(step.args ?? [], "geolocation-accuracy")
15551555
const permission = recipeStepArgValue(step.args ?? [], "geolocation-permission")
15561556
if (Boolean(latitude) !== Boolean(longitude)) {
1557-
addIssue("incomplete-geolocation", `${path}.args`, "wordpress.browser-probe geolocation requires both geolocation-latitude and geolocation-longitude.")
1557+
addIssue("incomplete-geolocation", `${path}.args`, `${step.command} geolocation requires both geolocation-latitude and geolocation-longitude.`)
15581558
}
15591559
if (accuracy && (!latitude || !longitude)) {
1560-
addIssue("incomplete-geolocation", `${path}.args`, "wordpress.browser-probe geolocation-accuracy requires geolocation-latitude and geolocation-longitude.")
1560+
addIssue("incomplete-geolocation", `${path}.args`, `${step.command} geolocation-accuracy requires geolocation-latitude and geolocation-longitude.`)
15611561
}
15621562
if (permission && (!latitude || !longitude)) {
1563-
addIssue("incomplete-geolocation", `${path}.args`, "wordpress.browser-probe geolocation-permission requires geolocation-latitude and geolocation-longitude.")
1563+
addIssue("incomplete-geolocation", `${path}.args`, `${step.command} geolocation-permission requires geolocation-latitude and geolocation-longitude.`)
15641564
}
1565+
const environmentJson = recipeStepArgValue(step.args ?? [], "browser-environment-json")
1566+
if (environmentJson && !environmentJson.startsWith("@")) {
1567+
try {
1568+
browserEnvironment(JSON.parse(environmentJson))
1569+
} catch (error) {
1570+
addIssue("invalid-browser-environment", `${path}.args`, `${step.command} browser-environment-json is invalid: ${error instanceof Error ? error.message : String(error)}`)
1571+
}
1572+
}
1573+
if (step.command !== "wordpress.browser-probe") return
15651574
for (const assertion of (step.args ?? []).filter((arg) => arg.startsWith("assert=")).map((arg) => arg.slice("assert=".length).trim())) {
15661575
const rawNormalized = assertion.startsWith("advisory:") ? assertion.slice("advisory:".length).trim() : assertion
15671576
const frameSeparator = rawNormalized.startsWith("frame:") || rawNormalized.startsWith("frame-url:") ? rawNormalized.indexOf("|") : -1

packages/runtime-core/src/browser-environment-matrix.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,11 @@ export interface BrowserEnvironmentMatrixRunnerOptions {
155155
replayCommand?: (matrix: BrowserEnvironmentMatrix, cell: BrowserEnvironmentCell) => string
156156
}
157157

158+
export function browserEnvironment(input: BrowserEnvironment): BrowserEnvironment {
159+
validateEnvironment(input)
160+
return canonicalEnvironment(input)
161+
}
162+
158163
export function browserEnvironmentMatrix(input: Omit<BrowserEnvironmentMatrix, "schema" | "limits" | "failOnFinding"> & { limits?: Partial<BrowserEnvironmentMatrixLimits>; failOnFinding?: boolean }): BrowserEnvironmentMatrix {
159164
if (!safeId(input.id) || !input.seed) throw new Error("Browser environment matrices require a safe non-empty id and seed.")
160165
if (!Array.isArray(input.dimensions) || input.dimensions.length === 0) throw new Error("Browser environment matrices require at least one dimension.")
@@ -320,8 +325,14 @@ function normalizeLimits(input: Partial<BrowserEnvironmentMatrixLimits> | undefi
320325

321326
function validateEnvironment(environment: BrowserEnvironment): void {
322327
if (!environment || typeof environment !== "object" || Array.isArray(environment)) throw new Error("Browser environment values must be objects.")
328+
const supportedKeys = new Set(["viewport", "device", "deviceScaleFactor", "isMobile", "hasTouch", "orientation", "zoom", "colorScheme", "reducedMotion", "forcedColors", "contrast", "locale", "timezone", "networkProfile", "cpuProfile", "online", "clock", "geolocation", "capabilities"])
329+
const unsupportedKeys = Object.keys(environment).filter((key) => !supportedKeys.has(key))
330+
if (unsupportedKeys.length > 0) throw new Error(`Browser environment contains unsupported controls: ${unsupportedKeys.sort().join(", ")}.`)
323331
if (environment.viewport && (!positiveInteger(environment.viewport.width) || !positiveInteger(environment.viewport.height))) throw new Error("Browser environment viewport width and height must be positive integers.")
332+
if (environment.isMobile !== undefined && typeof environment.isMobile !== "boolean") throw new Error("Browser environment isMobile must be boolean.")
333+
if (environment.hasTouch !== undefined && typeof environment.hasTouch !== "boolean") throw new Error("Browser environment hasTouch must be boolean.")
324334
if (environment.deviceScaleFactor !== undefined && (!Number.isFinite(environment.deviceScaleFactor) || environment.deviceScaleFactor <= 0)) throw new Error("Browser environment deviceScaleFactor must be positive.")
335+
if (environment.online !== undefined && typeof environment.online !== "boolean") throw new Error("Browser environment online must be boolean.")
325336
if (environment.zoom !== undefined && (!Number.isFinite(environment.zoom) || environment.zoom < 0.25 || environment.zoom > 5)) throw new Error("Browser environment zoom must be between 0.25 and 5.")
326337
if (environment.clock?.mode === "fixed" && (!environment.clock.at || !Number.isFinite(Date.parse(environment.clock.at)))) throw new Error("Fixed browser environment clocks require an ISO-compatible at value.")
327338
}

0 commit comments

Comments
 (0)