Skip to content

Commit 1c0df60

Browse files
committed
fix: preserve browser environment continuity
1 parent 61dd4dd commit 1c0df60

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 {
@@ -123,6 +126,8 @@ export interface BrowserAdaptiveFinding {
123126
expectedStateDigest?: string
124127
actions: BrowserAdaptiveAction[]
125128
resetPolicy: BrowserAdaptiveExplorationContract["resetPolicy"]
129+
environment: BrowserEnvironment
130+
environmentDigest: string
126131
}
127132
}
128133

@@ -145,7 +150,7 @@ export interface BrowserAdaptiveExplorationResult {
145150
findings: number
146151
budgetExhausted?: keyof BrowserAdaptiveExplorationContract["budgets"] | "maxKeyboardActions" | "cancelled" | "frontier"
147152
}
148-
replay: { schema: typeof BROWSER_ADAPTIVE_EXPLORATION_SCHEMA; seed: string; startUrl: string; contract: BrowserAdaptiveExplorationContract }
153+
replay: { schema: typeof BROWSER_ADAPTIVE_EXPLORATION_SCHEMA; seed: string; startUrl: string; environment: BrowserEnvironment; environmentDigest: string; contract: BrowserAdaptiveExplorationContract }
149154
}
150155

151156
export interface BrowserAdaptiveExplorationArtifact {
@@ -169,6 +174,7 @@ export function browserAdaptiveExplorationContract(input: Record<string, unknown
169174
: []
170175
const actionFamilies = [...new Set(families.filter((value): value is BrowserAdaptiveActionFamily => (BROWSER_ADAPTIVE_ACTION_FAMILIES as readonly unknown[]).includes(value)))]
171176
const startUrl = string(input.startUrl ?? input.start_url) ?? (context === "admin" ? "/wp-admin/" : context === "editor" ? "/wp-admin/post-new.php" : "/")
177+
const environment = browserEnvironment(isPlainObject(input.environment) ? input.environment as BrowserEnvironment : {})
172178
return stripUndefined({
173179
schema: BROWSER_ADAPTIVE_EXPLORATION_SCHEMA,
174180
context,
@@ -203,6 +209,8 @@ export function browserAdaptiveExplorationContract(input: Record<string, unknown
203209
failOnFinding: input.failOnFinding !== false && input.fail_on_finding !== false,
204210
accessibility,
205211
metadata: isPlainObject(input.metadata) ? input.metadata : undefined,
212+
environment,
213+
environmentDigest: browserEnvironmentDigest(environment),
206214
})
207215
}
208216

0 commit comments

Comments
 (0)