Skip to content

Commit f22389e

Browse files
committed
fix: close browser environment validation gaps
1 parent 4001ad3 commit f22389e

6 files changed

Lines changed: 140 additions & 41 deletions

File tree

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)

packages/cli/src/recipe-validation.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { readFileSync } from "node:fs"
12
import { readFile, stat } from "node:fs/promises"
23
import { dirname, join, resolve } from "node:path"
34
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"
@@ -513,7 +514,7 @@ export async function validateWorkspaceRecipe(recipe: WorkspaceRecipe, recipePat
513514
return validateWorkspaceRecipeSemantics(recipe, recipePath)
514515
}
515516

516-
export function validateRecipeRuntimePolicy(recipe: WorkspaceRecipe, policy: RuntimePolicy | undefined): RecipeValidationIssue[] {
517+
export function validateRecipeRuntimePolicy(recipe: WorkspaceRecipe, policy: RuntimePolicy | undefined, recipeDirectory?: string): RecipeValidationIssue[] {
517518
if (!policy) {
518519
return []
519520
}
@@ -528,7 +529,7 @@ export function validateRecipeRuntimePolicy(recipe: WorkspaceRecipe, policy: Run
528529
})
529530
}
530531

531-
const requiredCommands = recipePolicy(recipe).commands
532+
const requiredCommands = recipePolicy(recipe, recipeDirectory).commands
532533
for (const command of requiredCommands) {
533534
if (!policy.commands.includes(command)) {
534535
issues.push({
@@ -1051,7 +1052,7 @@ function recipeFuzzWorkflowSteps(recipe: WorkspaceRecipe): RecipeWorkflowStepRef
10511052
}))))
10521053
}
10531054

1054-
export function recipePolicy(recipe: WorkspaceRecipe): RuntimePolicy {
1055+
export function recipePolicy(recipe: WorkspaceRecipe, recipeDirectory?: string): RuntimePolicy {
10551056
const pluginRuntimeCommands = [
10561057
...(recipe.inputs?.pluginRuntime?.setup ?? []),
10571058
...(recipe.inputs?.pluginRuntime?.healthProbes ?? []).map(pluginRuntimeHealthProbeStep),
@@ -1091,7 +1092,7 @@ export function recipePolicy(recipe: WorkspaceRecipe): RuntimePolicy {
10911092
// Auto-grant the evaluate capability when a browser-actions step opts into the
10921093
// arbitrary-JS escape hatch by including an evaluate step. Recipe authors opt in
10931094
// by writing the step; direct `run` invocations still control the gate via --policy.
1094-
if (recipeDeclaredWorkflowSteps(recipe).some(({ step }) => (step.command === "wordpress.browser-actions" || step.command === "wordpress.browser-scenario") && recipeStepUsesEvaluate(step))) {
1095+
if (recipeDeclaredWorkflowSteps(recipe).some(({ step }) => (step.command === "wordpress.browser-actions" || step.command === "wordpress.browser-scenario") && recipeStepUsesEvaluate(step, recipeDirectory))) {
10951096
commands.push("wordpress.browser-actions.evaluate")
10961097
}
10971098

@@ -1649,10 +1650,10 @@ async function validateRecipeStepArgs(step: WorkspaceRecipe["workflow"]["steps"]
16491650
if (step.command === "wordpress.browser-actions") {
16501651
const stepsJson = recipeStepArgValue(step.args ?? [], "steps-json")
16511652

1652-
if (stepsJson && !stepsJson.startsWith("@")) {
1653+
if (stepsJson) {
16531654
let parsed: unknown
16541655
try {
1655-
parsed = JSON.parse(stepsJson)
1656+
parsed = JSON.parse(stepsJson.startsWith("@") ? await readFile(resolve(recipeDirectory, stepsJson.slice(1)), "utf8") : stepsJson)
16561657
} catch (error) {
16571658
addIssue("invalid-steps-json", `${path}.args`, `wordpress.browser-actions steps-json must be valid JSON: ${error instanceof Error ? error.message : String(error)}`)
16581659
parsed = undefined
@@ -1670,9 +1671,9 @@ async function validateRecipeStepArgs(step: WorkspaceRecipe["workflow"]["steps"]
16701671
if (step.command === "wordpress.browser-scenario") {
16711672
const scenarioJson = recipeStepArgValue(step.args ?? [], "scenario-json")
16721673

1673-
if (scenarioJson && !scenarioJson.startsWith("@")) {
1674+
if (scenarioJson) {
16741675
try {
1675-
const parsed = JSON.parse(scenarioJson) as unknown
1676+
const parsed = JSON.parse(scenarioJson.startsWith("@") ? await readFile(resolve(recipeDirectory, scenarioJson.slice(1)), "utf8") : scenarioJson) as unknown
16761677
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
16771678
addIssue("invalid-scenario-json", `${path}.args`, "wordpress.browser-scenario scenario-json must be a JSON object.")
16781679
} else {
@@ -1702,10 +1703,10 @@ async function validateRecipeStepArgs(step: WorkspaceRecipe["workflow"]["steps"]
17021703
}
17031704

17041705
const stepsJson = recipeStepArgValue(step.args ?? [], "steps-json")
1705-
if (stepsJson && !stepsJson.startsWith("@")) {
1706+
if (stepsJson) {
17061707
let parsed: unknown
17071708
try {
1708-
parsed = JSON.parse(stepsJson)
1709+
parsed = JSON.parse(stepsJson.startsWith("@") ? await readFile(resolve(recipeDirectory, stepsJson.slice(1)), "utf8") : stepsJson)
17091710
} catch (error) {
17101711
addIssue("invalid-steps-json", `${path}.args`, `wordpress.browser-scenario steps-json must be valid JSON: ${error instanceof Error ? error.message : String(error)}`)
17111712
}
@@ -1969,31 +1970,37 @@ function recipeBenchWorkloadsUseWpCli(value: unknown): boolean {
19691970
return record.type === "wp-cli" || recipeBenchWorkloadsUseWpCli(record.run)
19701971
}
19711972

1972-
function recipeStepUsesEvaluate(step: WorkspaceRecipe["workflow"]["steps"][number]): boolean {
1973+
function recipeStepUsesEvaluate(step: WorkspaceRecipe["workflow"]["steps"][number], recipeDirectory?: string): boolean {
19731974
const scenarioRaw = recipeStepArgValue(step.args ?? [], "scenario-json")
1974-
if (scenarioRaw && !scenarioRaw.startsWith("@")) {
1975+
if (scenarioRaw) {
19751976
try {
1976-
const parsed = parseCommandJson(scenarioRaw, "scenario-json") as { steps?: unknown; assertions?: unknown }
1977+
const parsed = parseCommandJson(recipeJsonArgForPolicy(scenarioRaw, recipeDirectory), "scenario-json") as { steps?: unknown; assertions?: unknown }
19771978
const steps = normalizeBrowserScenarioStepsForValidation(parsed.steps)
19781979
const assertions = normalizeBrowserScenarioAssertionsForValidation(parsed.assertions)
1979-
return [...steps, ...assertions].some((entry) => entry && typeof entry === "object" && (entry as { kind?: unknown }).kind === "evaluate")
1980+
if ([...steps, ...assertions].some((entry) => entry && typeof entry === "object" && (entry as { kind?: unknown }).kind === "evaluate")) return true
19801981
} catch {
1981-
return false
1982+
// Semantic validation reports malformed or missing file-backed payloads.
19821983
}
19831984
}
19841985

19851986
const raw = recipeStepArgValue(step.args ?? [], "steps-json")
1986-
if (!raw || raw.startsWith("@")) {
1987+
if (!raw) {
19871988
return false
19881989
}
19891990
try {
1990-
const parsed = parseCommandJson(raw, "steps-json")
1991+
const parsed = parseCommandJson(recipeJsonArgForPolicy(raw, recipeDirectory), "steps-json")
19911992
return Array.isArray(parsed) && parsed.some((entry) => entry && typeof entry === "object" && (entry as { kind?: unknown }).kind === "evaluate")
19921993
} catch {
19931994
return false
19941995
}
19951996
}
19961997

1998+
function recipeJsonArgForPolicy(raw: string, recipeDirectory?: string): string {
1999+
if (!raw.startsWith("@")) return raw
2000+
if (!recipeDirectory) return ""
2001+
return readFileSync(resolve(recipeDirectory, raw.slice(1)), "utf8")
2002+
}
2003+
19972004
function normalizeBrowserScenarioStepsForValidation(value: unknown): unknown[] {
19982005
if (!Array.isArray(value)) {
19992006
return []

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

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,8 @@ async function browserActionsRunPlanFromArgs(args: string[], artifactRoot: strin
699699
capture.add("screenshot")
700700
capture.add("dom-snapshot")
701701
}
702+
const argumentEnvironment = await browserEnvironmentFromArgs(args)
703+
const adaptiveExplorationPlan = browserAdaptiveExplorationFromArgs(args, argumentEnvironment)
702704
return {
703705
initialUrl: argValue(args, "url")?.trim(),
704706
steps: await browserInteractionStepsFromArgs(args),
@@ -707,12 +709,12 @@ async function browserActionsRunPlanFromArgs(args: string[], artifactRoot: strin
707709
totalTimeoutMs: durationArg(args, "timeout", BROWSER_SCRIPT_DEFAULT_TIMEOUT_MS),
708710
networkSettleTimeoutMs: durationArg(args, "network-settle-timeout", browserCommandLivenessPolicy().networkSettleTimeoutMs),
709711
requestedViewport: viewportArg(args, "viewport"),
710-
requestedEnvironment: await browserEnvironmentFromArgs(args),
712+
requestedEnvironment: adaptiveExplorationPlan.requestedEnvironment,
711713
authRequest: browserAuthRequest(args),
712714
storageStateImport: await browserStorageStateImportFromArgs(args, "wordpress.browser-actions", artifactRoot),
713715
maxDomSnapshotElements: positiveIntegerArg(args, "max-dom-snapshot-elements", 160),
714716
actionCorpus: browserActionCorpusFromArgs(args),
715-
adaptiveExploration: browserAdaptiveExplorationFromArgs(args),
717+
adaptiveExploration: adaptiveExplorationPlan.contract,
716718
}
717719
}
718720

@@ -774,11 +776,23 @@ function browserActionCorpusFromArgs(args: string[]): BrowserActionCorpusContrac
774776
return browserActionCorpusContract(parsed)
775777
}
776778

777-
function browserAdaptiveExplorationFromArgs(args: string[]): BrowserAdaptiveExplorationContract | undefined {
779+
function browserAdaptiveExplorationFromArgs(args: string[], argumentEnvironment: BrowserEnvironment): { contract?: BrowserAdaptiveExplorationContract; requestedEnvironment: BrowserEnvironment } {
778780
const raw = argValue(args, "adaptive-exploration-json")
779-
if (typeof raw !== "string" || raw.trim().length === 0) return undefined
781+
if (typeof raw !== "string" || raw.trim().length === 0) return { requestedEnvironment: argumentEnvironment }
780782
const parsed = JSON.parse(raw) as Record<string, unknown>
781-
return browserAdaptiveExplorationContract(parsed)
783+
const contract = browserAdaptiveExplorationContract(parsed)
784+
if (Object.prototype.hasOwnProperty.call(parsed, "environment")) {
785+
if (browserEnvironmentArgsDeclared(args)) {
786+
throw new Error("wordpress.browser-actions adaptive-exploration-json environment cannot be combined with outer browser environment arguments")
787+
}
788+
return { contract, requestedEnvironment: contract.environment }
789+
}
790+
return { contract, requestedEnvironment: argumentEnvironment }
791+
}
792+
793+
function browserEnvironmentArgsDeclared(args: string[]): boolean {
794+
const names = new Set(["browser-environment-json", "viewport", "device", "user-agent", "permissions", "locale", "timezone", "device-scale-factor", "is-mobile", "has-touch", "geolocation-latitude", "geolocation-longitude", "geolocation-accuracy", "geolocation-permission"])
795+
return args.some((arg) => names.has(arg.slice(0, arg.indexOf("="))))
782796
}
783797

784798
async function captureBrowserActionDomSnapshot({
@@ -1103,7 +1117,7 @@ function browserScenarioCaptures(scenario: BrowserScenarioInput, args: string[])
11031117

11041118
async function browserScenarioRunPlan(scenario: BrowserScenarioInput, args: string[], url: string, artifactRoot: string, requestedEnvironment: BrowserEnvironment): Promise<BrowserRunPlan> {
11051119
const captures = browserScenarioCaptures(scenario, args)
1106-
const steps = browserScenarioSteps(scenario, args)
1120+
const steps = await browserScenarioSteps(scenario, args)
11071121
const assertions = browserScenarioAssertions(scenario)
11081122
const actionSteps = [...steps, ...assertions]
11091123
const requestedViewport = requestedEnvironment.viewport
@@ -1200,8 +1214,8 @@ function browserScenarioActionCaptures(captures: string[]): string[] {
12001214
return selected.length > 0 ? selected : ["steps", "console", "errors", "html", "network", "screenshot", "dom-snapshot"]
12011215
}
12021216

1203-
function browserScenarioSteps(scenario: BrowserScenarioInput, args: string[]): Array<Record<string, unknown>> {
1204-
const raw = scenario.steps ?? parseInlineJsonArrayArg(args, "steps-json")
1217+
async function browserScenarioSteps(scenario: BrowserScenarioInput, args: string[]): Promise<Array<Record<string, unknown>>> {
1218+
const raw = scenario.steps ?? await parseJsonArrayArg(args, "steps-json")
12051219
return (raw ?? []).map((step) => normalizeBrowserScenarioStep(step))
12061220
}
12071221

@@ -1239,12 +1253,11 @@ function normalizeBrowserScenarioAssertion(assertion: Record<string, unknown>):
12391253
return assertion
12401254
}
12411255

1242-
function parseInlineJsonArrayArg(args: string[], name: string): Array<Record<string, unknown>> | undefined {
1256+
async function parseJsonArrayArg(args: string[], name: string): Promise<Array<Record<string, unknown>> | undefined> {
12431257
const raw = argValue(args, name)
1244-
if (!raw || raw.startsWith("@")) {
1245-
return undefined
1246-
}
1247-
const parsed = JSON.parse(raw) as unknown
1258+
if (!raw) return undefined
1259+
const text = raw.startsWith("@") ? await readFile(resolveCommandPath(raw.slice(1)), "utf8") : raw
1260+
const parsed = JSON.parse(text) as unknown
12481261
if (!Array.isArray(parsed)) {
12491262
throw new Error(`wordpress.browser-scenario ${name} must be a JSON array`)
12501263
}

0 commit comments

Comments
 (0)