Skip to content

Commit 8cb506f

Browse files
authored
Merge pull request #2085 from Automattic/fix/2084-browser-geolocation
Add deterministic browser geolocation contexts
2 parents cf2547a + 354d4dc commit 8cb506f

13 files changed

Lines changed: 303 additions & 19 deletions

packages/cli/src/recipe-validation.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1549,6 +1549,19 @@ async function validateRecipeStepArgs(step: WorkspaceRecipe["workflow"]["steps"]
15491549
}
15501550

15511551
if (step.command === "wordpress.browser-probe") {
1552+
const latitude = recipeStepArgValue(step.args ?? [], "geolocation-latitude")
1553+
const longitude = recipeStepArgValue(step.args ?? [], "geolocation-longitude")
1554+
const accuracy = recipeStepArgValue(step.args ?? [], "geolocation-accuracy")
1555+
const permission = recipeStepArgValue(step.args ?? [], "geolocation-permission")
1556+
if (Boolean(latitude) !== Boolean(longitude)) {
1557+
addIssue("incomplete-geolocation", `${path}.args`, "wordpress.browser-probe geolocation requires both geolocation-latitude and geolocation-longitude.")
1558+
}
1559+
if (accuracy && (!latitude || !longitude)) {
1560+
addIssue("incomplete-geolocation", `${path}.args`, "wordpress.browser-probe geolocation-accuracy requires geolocation-latitude and geolocation-longitude.")
1561+
}
1562+
if (permission && (!latitude || !longitude)) {
1563+
addIssue("incomplete-geolocation", `${path}.args`, "wordpress.browser-probe geolocation-permission requires geolocation-latitude and geolocation-longitude.")
1564+
}
15521565
for (const assertion of (step.args ?? []).filter((arg) => arg.startsWith("assert=")).map((arg) => arg.slice("assert=".length).trim())) {
15531566
const rawNormalized = assertion.startsWith("advisory:") ? assertion.slice("advisory:".length).trim() : assertion
15541567
const frameSeparator = rawNormalized.startsWith("frame:") || rawNormalized.startsWith("frame-url:") ? rawNormalized.indexOf("|") : -1
@@ -1774,6 +1787,14 @@ function validateRecipeStepDescriptorArgRule(args: string[], rule: CommandArgVal
17741787
return
17751788
}
17761789

1790+
if (rule.kind === "number") {
1791+
const value = Number(raw)
1792+
if (!Number.isFinite(value) || value < rule.minimum || value > rule.maximum) {
1793+
addIssue(rule.code, issuePath, rule.message)
1794+
}
1795+
return
1796+
}
1797+
17771798
if (rule.kind === "enum") {
17781799
if (!(rule.values as readonly string[]).includes(raw) && !(rule.prefixes ?? []).some((prefix) => raw.startsWith(prefix))) {
17791800
addIssue(rule.code, issuePath, descriptorValueMessage(rule, raw))

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createHash } from "node:crypto"
22

33
import { stableJson, stripUndefined } from "./object-utils.js"
4+
import { browserGeolocation, type BrowserGeolocation } from "./browser-probe-contract.js"
45

56
export const BROWSER_ENVIRONMENT_MATRIX_SCHEMA = "wp-codebox/browser-environment-matrix/v1" as const
67
export const BROWSER_ENVIRONMENT_MATRIX_REPORT_SCHEMA = "wp-codebox/browser-environment-matrix-report/v1" as const
@@ -27,6 +28,7 @@ export interface BrowserEnvironment {
2728
cpuProfile?: string
2829
online?: boolean
2930
clock?: { mode: "realtime" | "fixed"; at?: string }
31+
geolocation?: BrowserGeolocation
3032
capabilities?: Record<string, boolean | string | number>
3133
}
3234

@@ -301,7 +303,7 @@ function normalizeDimension(dimension: BrowserEnvironmentDimension): BrowserEnvi
301303
const values = dimension.values.map((value) => {
302304
if (!safeId(value.id)) throw new Error(`Invalid browser environment value id in ${dimension.id}: ${value.id}`)
303305
validateEnvironment(value.environment)
304-
return { id: value.id, environment: canonicalEnvironment(value.environment), requiredCapabilities: uniqueSorted(value.requiredCapabilities ?? []), optionalCapabilities: uniqueSorted(value.optionalCapabilities ?? []) }
306+
return { id: value.id, environment: canonicalEnvironment(value.environment), requiredCapabilities: uniqueSorted([...(value.requiredCapabilities ?? []), ...(value.environment.geolocation ? ["browser.environment.geolocation"] : [])]), optionalCapabilities: uniqueSorted(value.optionalCapabilities ?? []) }
305307
}).sort((left, right) => left.id.localeCompare(right.id) || stableJson(left.environment).localeCompare(stableJson(right.environment)))
306308
if (new Set(values.map(({ id }) => id)).size !== values.length) throw new Error(`Browser environment value ids must be unique in ${dimension.id}.`)
307309
return { id: dimension.id, values }
@@ -325,7 +327,7 @@ function validateEnvironment(environment: BrowserEnvironment): void {
325327
}
326328

327329
function canonicalEnvironment(environment: BrowserEnvironment): BrowserEnvironment {
328-
return JSON.parse(stableJson(JSON.parse(JSON.stringify(environment)))) as BrowserEnvironment
330+
return JSON.parse(stableJson(JSON.parse(JSON.stringify({ ...environment, ...(environment.geolocation ? { geolocation: browserGeolocation(environment.geolocation) } : {}) })))) as BrowserEnvironment
329331
}
330332

331333
function mergeEnvironment(left: BrowserEnvironment, right: BrowserEnvironment): BrowserEnvironment {

packages/runtime-core/src/browser-probe-contract.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,37 @@ export const BROWSER_PROBE_BROWSER_VALUES = ["chromium"] as const
1717
export const BROWSER_PROBE_CAPTURE_VALUES = ["console", "errors", "html", "network", "websocket", "performance", "memory", "screenshot"] as const
1818
export const BROWSER_PROBE_CHROMIUM_PROFILE_IDS = ["desktop-chrome", "mobile-chrome", "low-end-mobile-slow-4g"] as const
1919
export const BROWSER_PROBE_THROTTLE_PROFILE_IDS = ["low-end-mobile-slow-4g"] as const
20+
export const BROWSER_GEOLOCATION_PERMISSION_STATES = ["granted", "denied", "prompt"] as const
21+
export const BROWSER_GEOLOCATION_PERMISSION_ALIASES = ["default"] as const
22+
export const BROWSER_GEOLOCATION_MAX_ACCURACY_METERS = 1_000_000
23+
24+
export type BrowserGeolocationPermissionState = (typeof BROWSER_GEOLOCATION_PERMISSION_STATES)[number]
25+
26+
export interface BrowserGeolocation {
27+
latitude: number
28+
longitude: number
29+
accuracy?: number
30+
permission: BrowserGeolocationPermissionState
31+
}
32+
33+
export function normalizeBrowserGeolocationPermissionState(value: string): BrowserGeolocationPermissionState {
34+
const normalized = value.trim().toLowerCase()
35+
if (normalized === "default") return "prompt"
36+
if ((BROWSER_GEOLOCATION_PERMISSION_STATES as readonly string[]).includes(normalized)) return normalized as BrowserGeolocationPermissionState
37+
throw new Error(`Browser geolocation permission must be ${[...BROWSER_GEOLOCATION_PERMISSION_STATES, ...BROWSER_GEOLOCATION_PERMISSION_ALIASES].join(", ")}: ${value}`)
38+
}
39+
40+
export function browserGeolocation(input: Omit<BrowserGeolocation, "permission"> & { permission?: BrowserGeolocationPermissionState | "default" }): BrowserGeolocation {
41+
if (!Number.isFinite(input.latitude) || input.latitude < -90 || input.latitude > 90) throw new Error("Browser geolocation latitude must be a finite number between -90 and 90.")
42+
if (!Number.isFinite(input.longitude) || input.longitude < -180 || input.longitude > 180) throw new Error("Browser geolocation longitude must be a finite number between -180 and 180.")
43+
if (input.accuracy !== undefined && (!Number.isFinite(input.accuracy) || input.accuracy < 0 || input.accuracy > BROWSER_GEOLOCATION_MAX_ACCURACY_METERS)) throw new Error(`Browser geolocation accuracy must be a finite number between 0 and ${BROWSER_GEOLOCATION_MAX_ACCURACY_METERS}.`)
44+
return {
45+
latitude: input.latitude,
46+
longitude: input.longitude,
47+
...(input.accuracy !== undefined ? { accuracy: input.accuracy } : {}),
48+
permission: normalizeBrowserGeolocationPermissionState(input.permission ?? "prompt"),
49+
}
50+
}
2051

2152
export const BROWSER_PROBE_PROFILES: Record<(typeof BROWSER_PROBE_CHROMIUM_PROFILE_IDS)[number], BrowserProbeProfileDefinition> = {
2253
"desktop-chrome": {
@@ -49,6 +80,10 @@ export const BROWSER_PROBE_ACCEPTED_ARGS: BrowserProbeAcceptedArg[] = [
4980
{ name: "timezone", description: "Optional browser context timezone.", format: "IANA timezone, e.g. America/New_York" },
5081
{ name: "user-agent", description: "Optional browser context user agent override.", format: "string" },
5182
{ name: "permissions", description: "Comma-separated browser permissions to grant to the context.", format: "comma-separated permission names" },
83+
{ name: "geolocation-latitude", description: "Browser context geolocation latitude.", format: "finite number from -90 to 90" },
84+
{ name: "geolocation-longitude", description: "Browser context geolocation longitude.", format: "finite number from -180 to 180" },
85+
{ name: "geolocation-accuracy", description: "Optional browser context geolocation accuracy in meters.", format: `finite number from 0 to ${BROWSER_GEOLOCATION_MAX_ACCURACY_METERS}` },
86+
{ name: "geolocation-permission", description: "Explicit browser context geolocation permission state. default is normalized to prompt.", format: [...BROWSER_GEOLOCATION_PERMISSION_STATES, ...BROWSER_GEOLOCATION_PERMISSION_ALIASES].join("|") },
5287
{ name: "throttle", description: "Optional Chromium/CDP throttle profile, or none.", format: `none|${BROWSER_PROBE_THROTTLE_PROFILE_IDS.join("|")}` },
5388
{ name: "auth", description: "Optional in-memory browser authentication mode. Use wordpress-admin to bootstrap WordPress admin cookies from PHP without writing token-bearing storage-state artifacts.", format: "wordpress-admin" },
5489
{ name: "auth-user-id", description: "WordPress user ID used with auth=wordpress-admin; defaults to 1.", format: "positive integer" },

packages/runtime-core/src/command-registry.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BROWSER_PROBE_ACCEPTED_ARGS, BROWSER_PROBE_BROWSER_VALUES, BROWSER_PROBE_CAPTURE_VALUES, BROWSER_PROBE_CHROMIUM_PROFILE_IDS, BROWSER_PROBE_THROTTLE_PROFILE_IDS } from "./browser-probe-contract.js"
1+
import { BROWSER_GEOLOCATION_MAX_ACCURACY_METERS, BROWSER_GEOLOCATION_PERMISSION_ALIASES, BROWSER_GEOLOCATION_PERMISSION_STATES, BROWSER_PROBE_ACCEPTED_ARGS, BROWSER_PROBE_BROWSER_VALUES, BROWSER_PROBE_CAPTURE_VALUES, BROWSER_PROBE_CHROMIUM_PROFILE_IDS, BROWSER_PROBE_THROTTLE_PROFILE_IDS } from "./browser-probe-contract.js"
22
import { WORDPRESS_PAGE_LOAD_RESULT_JSON_SCHEMA, WORDPRESS_PAGE_LOAD_RESULT_SCHEMA } from "./wordpress-page-load-contracts.js"
33
import { WORDPRESS_DB_RESULT_JSON_SCHEMA, WORDPRESS_DB_RESULT_SCHEMA } from "./wordpress-db-contracts.js"
44
import { WORDPRESS_CRUD_RESULT_JSON_SCHEMA, WORDPRESS_CRUD_RESULT_SCHEMA } from "./wordpress-crud-contracts.js"
@@ -67,6 +67,7 @@ export type CommandArgValidationDescriptor =
6767
| { name: string; kind: "duration"; code: string; message: string }
6868
| { name: string; kind: "positive-integer"; code: string; message: string }
6969
| { name: string; kind: "viewport"; code: string; message: string }
70+
| { name: string; kind: "number"; minimum: number; maximum: number; code: string; message: string }
7071
| { name: string; kind: "enum"; values: readonly string[]; prefixes?: readonly string[]; code: string; message: string }
7172
| { name: string; kind: "comma-list-enum"; values: readonly string[]; code: string; message: string }
7273

@@ -127,6 +128,10 @@ const browserProbeValidation: CommandValidationDescriptor = {
127128
{ name: "throttle", kind: "enum", values: ["none", ...BROWSER_PROBE_THROTTLE_PROFILE_IDS], code: "invalid-throttle", message: "wordpress.browser-probe throttle is unsupported" },
128129
{ name: "browser", kind: "enum", values: BROWSER_PROBE_BROWSER_VALUES, code: "invalid-browser", message: `wordpress.browser-probe browser must be ${BROWSER_PROBE_BROWSER_VALUES.join(" or ")}.` },
129130
{ name: "viewport", kind: "viewport", code: "invalid-viewport", message: "wordpress.browser-probe viewport must use <width>x<height>, for example 390x844." },
131+
{ name: "geolocation-latitude", kind: "number", minimum: -90, maximum: 90, code: "invalid-geolocation-latitude", message: "wordpress.browser-probe geolocation-latitude must be a finite number from -90 to 90." },
132+
{ name: "geolocation-longitude", kind: "number", minimum: -180, maximum: 180, code: "invalid-geolocation-longitude", message: "wordpress.browser-probe geolocation-longitude must be a finite number from -180 to 180." },
133+
{ name: "geolocation-accuracy", kind: "number", minimum: 0, maximum: BROWSER_GEOLOCATION_MAX_ACCURACY_METERS, code: "invalid-geolocation-accuracy", message: `wordpress.browser-probe geolocation-accuracy must be a finite number from 0 to ${BROWSER_GEOLOCATION_MAX_ACCURACY_METERS}.` },
134+
{ name: "geolocation-permission", kind: "enum", values: [...BROWSER_GEOLOCATION_PERMISSION_STATES, ...BROWSER_GEOLOCATION_PERMISSION_ALIASES], code: "invalid-geolocation-permission", message: "wordpress.browser-probe geolocation-permission must be granted, denied, prompt, or default." },
130135
{ name: "capture", kind: "comma-list-enum", values: BROWSER_PROBE_CAPTURE_VALUES, code: "invalid-capture", message: "wordpress.browser-probe capture does not support" },
131136
],
132137
}

packages/runtime-playground/src/browser-artifacts.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { join } from "node:path"
2-
import { artifactManifestFile, type ArtifactManifestFile, type ArtifactManifestFileOptions, type ArtifactReviewBrowserSummary } from "@automattic/wp-codebox-core"
2+
import { artifactManifestFile, type ArtifactManifestFile, type ArtifactManifestFileOptions, type ArtifactReviewBrowserSummary, type BrowserGeolocation } from "@automattic/wp-codebox-core"
33
import type { PlaygroundPreviewProxyDiagnostics } from "./preview-server.js"
44
import type { Request } from "playwright"
55

@@ -353,6 +353,8 @@ export interface BrowserProbeReviewSummary {
353353
throttle: string | null
354354
waitFor: string
355355
durationMs: number
356+
context?: BrowserProbeContextDetails
357+
capabilities?: BrowserProbeCapabilityDiagnostics
356358
}
357359
timings: {
358360
startedAt: string
@@ -461,6 +463,21 @@ export interface BrowserProbeCapabilityDiagnostics {
461463
available: boolean
462464
}
463465
permissions: Record<string, BrowserProbePermissionState>
466+
geolocation?: {
467+
requested: BrowserGeolocation
468+
effective: {
469+
latitude: number
470+
longitude: number
471+
accuracy?: number
472+
permission: BrowserProbePermissionState["state"]
473+
}
474+
support: {
475+
coordinates: "supported" | "unsupported"
476+
permission: "supported" | "unsupported"
477+
unavailable: "unsupported"
478+
reason: string
479+
}
480+
}
464481
}
465482

466483
export interface BrowserProbePermissionState {
@@ -508,6 +525,7 @@ export interface BrowserProbeContextDetails {
508525
device?: string
509526
locale?: string
510527
permissions?: string[]
528+
geolocation?: BrowserGeolocation
511529
profile?: string
512530
throttle?: string
513531
timezone?: string
@@ -522,6 +540,7 @@ export interface BrowserProbeContextDetails {
522540
device?: string
523541
locale?: string
524542
permissions?: string[]
543+
geolocation?: BrowserGeolocation
525544
profile?: string
526545
throttle?: string
527546
timezone?: string

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const PLAYWRIGHT_BROWSER_ENVIRONMENT_CAPABILITIES = [
3131
"browser.environment.cpu-profile",
3232
"browser.environment.online-state",
3333
"browser.environment.clock",
34+
"browser.environment.geolocation",
3435
"browser.environment.capability-state",
3536
] as const
3637

@@ -83,6 +84,11 @@ export async function resolvePlaywrightBrowserEnvironment(cell: BrowserEnvironme
8384
if (requested.timezone) exact("browser.environment.timezone")
8485
if (requested.online !== undefined) exact("browser.environment.online-state")
8586
if (requested.clock) exact("browser.environment.clock")
87+
if (requested.geolocation) {
88+
requested.geolocation.permission !== "denied" || browser.browserType().name() === "chromium"
89+
? exact("browser.environment.geolocation")
90+
: unsupported("browser.environment.geolocation", "This provider cannot express an explicit denied geolocation permission without Chromium browser permission controls.")
91+
}
8692
if (requested.capabilities) exact("browser.environment.capability-state")
8793
if (requested.zoom !== undefined) emulated("browser.environment.zoom", "Applied as page scale through the browser debugging protocol; OS-level zoom and browser chrome are outside the page context.")
8894
if (requested.networkProfile) options.networkProfiles?.[requested.networkProfile] ? emulated("browser.environment.network-profile", "Latency and throughput are applied through the browser debugging protocol.") : unsupported("browser.environment.network-profile", `Unknown network profile: ${requested.networkProfile}`)
@@ -114,11 +120,22 @@ export async function createPlaywrightBrowserEnvironmentContext(browser: Browser
114120
...(environment.forcedColors ? { forcedColors: environment.forcedColors } : {}),
115121
...(environment.contrast ? { contrast: environment.contrast } : {}),
116122
...(environment.online !== undefined ? { offline: !environment.online } : {}),
123+
...(environment.geolocation ? { geolocation: { latitude: environment.geolocation.latitude, longitude: environment.geolocation.longitude, ...(environment.geolocation.accuracy !== undefined ? { accuracy: environment.geolocation.accuracy } : {}) } } : {}),
124+
...(environment.geolocation?.permission === "granted" ? { permissions: ["geolocation"] } : {}),
117125
}
118126
const context = await browser.newContext(contextOptions)
119127
const page = await context.newPage()
128+
const geolocationPermissionCleanup = environment.geolocation?.permission === "denied" ? await applyPlaywrightGeolocationPermission(page, "denied") : undefined
120129
await applyPlaywrightPageEnvironment(page, environment, options)
121-
return { context, page, close: () => context.close() }
130+
return { context, page, close: async () => { await geolocationPermissionCleanup?.(); await context.close() } }
131+
}
132+
133+
export async function applyPlaywrightGeolocationPermission(page: Page, state: "denied"): Promise<() => Promise<void>> {
134+
if (page.context().browser()?.browserType().name() !== "chromium") throw new Error("Explicit denied geolocation permission is unsupported by this browser provider.")
135+
const session = await page.context().newCDPSession(page)
136+
const { targetInfo } = await session.send("Target.getTargetInfo")
137+
await session.send("Browser.setPermission", { permission: { name: "geolocation" }, setting: state, browserContextId: targetInfo.browserContextId })
138+
return () => session.detach().catch(() => undefined)
122139
}
123140

124141
export async function applyPlaywrightPageEnvironment(page: Page, environment: BrowserEnvironment, options: PlaywrightBrowserEnvironmentOptions = {}): Promise<void> {

0 commit comments

Comments
 (0)