Merge pull request #3233 from rohitsux/feat/geolocation-emulation

feat: navigator.geolocation + Emulation.setGeolocationOverride
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-08-21 14:47:38 +08:00
commit accb34eaa4
10 files changed
+942 -13

No files matched your search

+8 -9
View File
@@ -21,15 +21,16 @@ const lp = @import("lightpanda");
const App = @import("../App.zig");
const CDP = @import("../cdp/CDP.zig");
const Watchdog = @import("../Watchdog.zig");
const Notification = @import("../Notification.zig");
const HttpClient = @import("../network/HttpClient.zig");
const js = @import("js/js.zig");
const Page = @import("Page.zig");
const Watchdog = @import("../Watchdog.zig");
const Session = @import("Session.zig");
const Selector = @import("webapi/selector/Selector.zig");
const Viewport = @import("Viewport.zig");
const HttpClient = @import("../network/HttpClient.zig");
const Selector = @import("webapi/selector/Selector.zig");
const Geolocation = @import("webapi/geolocation/Geolocation.zig");
const PermissionState = @import("webapi/Permissions.zig").State;
const ArenaPool = App.ArenaPool;
@@ -63,14 +64,12 @@ last_reported_js_bytes: usize = 0,
// browser context. Keys are owned by `allocator`; values are enum tags.
permissions: std.StringHashMapUnmanaged(PermissionState) = .empty,
// Runtime viewport override set via Emulation.setDeviceMetricsOverride and
// cleared via clearDeviceMetricsOverride. Null means use the compile-time
// Viewport.default. Scoped to the Browser so it persists across page
// navigations (matching how Chrome scopes the override to the connection).
// Every viewport consumer reads it through Page.getViewport so they all
// observe the same (possibly overridden) value.
// Runtime viewport override
viewport_override: ?Viewport = null,
// Runtime geolocation override
geolocation_override: ?Geolocation.Override = null,
// used by sessions to allocate pages.
page_pool: std.heap.MemoryPool(Page),
+4
View File
@@ -1200,6 +1200,10 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/PluginArray.zig"),
@import("../webapi/MutationObserver.zig"),
@import("../webapi/IntersectionObserver.zig"),
@import("../webapi/geolocation/Geolocation.zig"),
@import("../webapi/geolocation/GeolocationPosition.zig"),
@import("../webapi/geolocation/GeolocationCoordinates.zig"),
@import("../webapi/geolocation/GeolocationPositionError.zig"),
@import("../webapi/CustomElementRegistry.zig"),
@import("../webapi/ResizeObserver.zig"),
@import("../webapi/IdleDeadline.zig"),
+67
View File
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<script src="./testing.js"></script>
<script id=geo_shape>
testing.expectTrue('geolocation' in navigator);
testing.expectTrue(navigator.geolocation === navigator.geolocation);
testing.expectEqual('function', typeof navigator.geolocation.getCurrentPosition);
testing.expectEqual('function', typeof navigator.geolocation.watchPosition);
testing.expectEqual('function', typeof navigator.geolocation.clearWatch);
testing.expectEqual(1, GeolocationPositionError.PERMISSION_DENIED);
testing.expectEqual(2, GeolocationPositionError.POSITION_UNAVAILABLE);
testing.expectEqual(3, GeolocationPositionError.TIMEOUT);
</script>
<script id=geo_no_permission type=module>
{
// nothing granted the permission (only CDP can), so this is the denied path
const state = await testing.async();
let called = false;
navigator.geolocation.getCurrentPosition(
() => { called = true; testing.fail('should not succeed without a grant'); state.resolve(); },
(err) => { called = true; state.resolve(err); }
);
// never delivered on the stack that registered it
testing.expectFalse(called);
state.done((err) => {
testing.expectEqual(1, err.code);
testing.expectEqual('User denied Geolocation', err.message);
});
}
</script>
<script id=geo_watch type=module>
{
const state = await testing.async();
const id = navigator.geolocation.watchPosition(
() => { testing.fail('should not succeed without a grant'); state.resolve(); },
(err) => { state.resolve(err); }
);
testing.expectTrue(id > 0);
state.done((err) => {
testing.expectEqual(1, err.code);
// clearing a watch that already delivered is a no-op, not an error
navigator.geolocation.clearWatch(id);
});
}
</script>
<script id=geo_clear_watch type=module>
{
const state = await testing.async();
let watched = false;
const id = navigator.geolocation.watchPosition(
() => { watched = true; },
() => { watched = true; }
);
navigator.geolocation.clearWatch(id);
// registered after the watch, so it can only run once the watch would have
testing.expectFalse(watched);
navigator.geolocation.getCurrentPosition(
() => { state.resolve(); },
() => { state.resolve(); }
);
state.done(() => { testing.expectFalse(watched); });
}
</script>
+23 -3
View File
@@ -25,14 +25,25 @@ const Execution = js.Execution;
const PluginArray = @import("PluginArray.zig");
const Permissions = @import("Permissions.zig");
const ModelContext = @import("ModelContext.zig");
const StorageManager = @import("StorageManager.zig");
const NavigatorUAData = @import("NavigatorUAData.zig");
const ModelContext = @import("ModelContext.zig");
const Geolocation = @import("geolocation/Geolocation.zig");
const Navigator = @This();
_pad: bool = false,
comptime {
// Ensure we don't cause an identity map conflict. Because _geolocation is
// lazy and, for now, Zig orders the highest-aligned field first, none of
// the other fields land at offset 0.
for ([_][]const u8{ "_plugins", "_permissions", "_storage", "_ua_data" }) |name| {
if (@offsetOf(Navigator, name) == 0) @compileError(name ++ " aliases the Navigator");
}
}
_plugins: PluginArray = .{},
_permissions: Permissions = .{},
_geolocation: ?*Geolocation = null,
_storage: StorageManager = .{},
_ua_data: NavigatorUAData = .{},
@@ -137,6 +148,15 @@ pub fn getPermissions(self: *Navigator) *Permissions {
return &self._permissions;
}
pub fn getGeolocation(self: *Navigator, exec: *Execution) !*Geolocation {
if (self._geolocation) |g| {
return g;
}
const g = try exec._factory.create(Geolocation{});
self._geolocation = g;
return g;
}
pub fn getStorage(self: *Navigator) *StorageManager {
return &self._storage;
}
@@ -224,7 +244,6 @@ pub const JsApi = struct {
pub const name = "Navigator";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
pub const userAgent = bridge.accessor(Navigator.getUserAgent, null, .{});
@@ -253,6 +272,7 @@ pub const JsApi = struct {
// window only
pub const plugins = bridge.accessor(Navigator.getPlugins, null, .{ .exposed = .window });
pub const geolocation = bridge.accessor(Navigator.getGeolocation, null, .{ .exposed = .window });
pub const modelContext = bridge.accessor(Navigator.getModelContext, null, .{ .exposed = .window });
pub const registerProtocolHandler = bridge.function(Navigator.registerProtocolHandler, .{ .exposed = .window });
pub const unregisterProtocolHandler = bridge.function(Navigator.unregisterProtocolHandler, .{ .exposed = .window });
@@ -0,0 +1,243 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const GeolocationPosition = @import("GeolocationPosition.zig");
const GeolocationPositionError = @import("GeolocationPositionError.zig");
const log = lp.log;
// https://developer.mozilla.org/en-US/docs/Web/API/Geolocation
const Geolocation = @This();
pub const Override = struct {
latitude: f64,
longitude: f64,
accuracy: f64,
};
const PositionOptions = struct {
timeout: ?u32 = null,
maximumAge: u32 = 0,
enableHighAccuracy: bool = false,
};
// Last id handed out by watchPosition. 0 is a sentinel indicating that the
// request is in getCurrentPosition
_watch_id: i32 = 0,
// Watches that haven't delivered yet, so clearWatch can cancel them.
_watches: std.ArrayListUnmanaged(*Request) = .empty,
pub fn getCurrentPosition(
self: *Geolocation,
success: js.Function.Global,
error_cb: ?js.Function.Global,
options: ?PositionOptions,
exec: *js.Execution,
) !void {
_ = options; // enableHighAccuracy/timeout/maximumAge don't apply to a fixed position
return self.request(0, success, error_cb, exec);
}
pub fn watchPosition(
self: *Geolocation,
success: js.Function.Global,
error_cb: ?js.Function.Global,
options: ?PositionOptions,
exec: *js.Execution,
) !i32 {
_ = options;
// wrap to 1, 0 is a sentinel
const watch_id = if (self._watch_id == std.math.maxInt(i32)) 1 else self._watch_id + 1;
try self.request(watch_id, success, error_cb, exec);
self._watch_id = watch_id;
return watch_id;
}
pub fn clearWatch(self: *Geolocation, watch_id: i32) void {
for (self._watches.items) |req| {
if (req.watch_id == watch_id) {
req.cleared = true;
return;
}
}
}
fn request(
self: *Geolocation,
watch_id: i32,
success: js.Function.Global,
error_cb: ?js.Function.Global,
exec: *js.Execution,
) !void {
errdefer success.release();
errdefer if (error_cb) |cb| cb.release();
const req = try exec._factory.create(Request{
.exec = exec,
.success = success,
.error_cb = error_cb,
.watch_id = watch_id,
.geolocation = self,
});
errdefer exec._factory.destroy(req);
if (watch_id != 0) {
try self._watches.append(exec.arena, req);
}
errdefer if (watch_id != 0) {
_ = self._watches.pop();
};
try exec.js.scheduler.add(req, Request.run, 0, .{
.name = if (watch_id == 0) "geolocation.getCurrentPosition" else "geolocation.watchPosition",
.finalizer = Request.cancelled,
});
}
fn unwatch(self: *Geolocation, req: *Request) void {
for (self._watches.items, 0..) |candidate, i| {
if (candidate == req) {
_ = self._watches.swapRemove(i);
return;
}
}
}
const Request = struct {
exec: *js.Execution,
cleared: bool = false,
geolocation: *Geolocation,
success: js.Function.Global,
error_cb: ?js.Function.Global,
// 0 for getCurrentPosition, else the watchPosition id
watch_id: i32,
fn deinit(self: *Request) void {
self.success.release();
if (self.error_cb) |cb| {
cb.release();
}
self.exec._factory.destroy(self);
}
fn cancelled(ctx: *anyopaque) void {
const self: *Request = @ptrCast(@alignCast(ctx));
if (self.watch_id != 0) {
self.geolocation.unwatch(self);
}
self.deinit();
}
fn run(ctx: *anyopaque) anyerror!?u32 {
const self: *Request = @ptrCast(@alignCast(ctx));
defer self.deinit();
if (self.watch_id != 0) {
self.geolocation.unwatch(self);
}
if (self.cleared) {
return null;
}
const browser = self.exec.session.browser;
// Headless Chrome can't answer a permission prompt, so anything short of
// an explicit grant is a denial - which is why Puppeteer and Playwright
// both send Browser.grantPermissions alongside setGeolocationOverride.
if (browser.permissions.get("geolocation") orelse .prompt != .granted) {
self.deliverError(.permission_denied);
return null;
}
const override = browser.geolocation_override orelse {
self.deliverError(.position_unavailable);
return null;
};
self.deliverPosition(override);
return null;
}
fn deliverPosition(self: *Request, override: Override) void {
const exec = self.exec;
const position = GeolocationPosition.init(exec, override) catch |err| {
log.err(.js, "geolocation.position", .{ .err = err });
return;
};
// The page can hold on to the position (or its coords), in which case
// the JS wrapper takes its own ref; ours only has to cover the call.
position.acquireRef();
defer position.releaseRef(exec.page);
self.invoke(self.success, position);
}
fn deliverError(self: *Request, code: GeolocationPositionError.Code) void {
const error_cb = self.error_cb orelse return;
const exec = self.exec;
const position_error = GeolocationPositionError.init(exec, code) catch |err| {
log.err(.js, "geolocation.error", .{ .err = err });
return;
};
position_error.acquireRef();
defer position_error.releaseRef(exec.page);
self.invoke(error_cb, position_error);
}
fn invoke(self: *Request, callback: js.Function.Global, arg: anytype) void {
const exec = self.exec;
var ls: js.Local.Scope = undefined;
exec.js.localScope(&ls);
defer ls.deinit();
ls.toLocal(callback).call(void, .{arg}) catch |err| {
exec.page.recordJsError(err);
log.warn(.js, "geolocation", .{ .err = err });
};
ls.local.runMicrotasks();
}
};
pub const JsApi = struct {
pub const bridge = js.Bridge(Geolocation);
pub const Meta = struct {
pub const name = "Geolocation";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const getCurrentPosition = bridge.function(Geolocation.getCurrentPosition, .{});
pub const watchPosition = bridge.function(Geolocation.watchPosition, .{});
pub const clearWatch = bridge.function(Geolocation.clearWatch, .{});
};
const testing = @import("../../../testing.zig");
test "WebApi: Geolocation" {
try testing.htmlRunner("geolocation.html", .{});
}
@@ -0,0 +1,113 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const GeolocationPosition = @import("GeolocationPosition.zig");
// https://developer.mozilla.org/en-US/docs/Web/API/GeolocationCoordinates
const GeolocationCoordinates = @This();
_latitude: f64,
_longitude: f64,
_accuracy: f64,
_position: *GeolocationPosition,
// Nothing emulates these yet
_altitude: ?f64 = null,
_altitude_accuracy: ?f64 = null,
_heading: ?f64 = null,
_speed: ?f64 = null,
pub fn acquireRef(self: *GeolocationCoordinates) void {
// self exists in self._position._arena
self._position.acquireRef();
}
pub fn releaseRef(self: *GeolocationCoordinates, page: *Page) void {
self._position.releaseRef(page);
}
fn getLatitude(self: *const GeolocationCoordinates) f64 {
return self._latitude;
}
fn getLongitude(self: *const GeolocationCoordinates) f64 {
return self._longitude;
}
fn getAccuracy(self: *const GeolocationCoordinates) f64 {
return self._accuracy;
}
fn getAltitude(self: *const GeolocationCoordinates) ?f64 {
return self._altitude;
}
fn getAltitudeAccuracy(self: *const GeolocationCoordinates) ?f64 {
return self._altitude_accuracy;
}
fn getHeading(self: *const GeolocationCoordinates) ?f64 {
return self._heading;
}
fn getSpeed(self: *const GeolocationCoordinates) ?f64 {
return self._speed;
}
pub const Json = struct {
accuracy: f64,
latitude: f64,
longitude: f64,
altitude: ?f64,
altitudeAccuracy: ?f64,
heading: ?f64,
speed: ?f64,
};
pub fn toJSON(self: *const GeolocationCoordinates) Json {
return .{
.accuracy = self._accuracy,
.latitude = self._latitude,
.longitude = self._longitude,
.altitude = self._altitude,
.altitudeAccuracy = self._altitude_accuracy,
.heading = self._heading,
.speed = self._speed,
};
}
pub const JsApi = struct {
pub const bridge = js.Bridge(GeolocationCoordinates);
pub const Meta = struct {
pub const name = "GeolocationCoordinates";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const latitude = bridge.accessor(GeolocationCoordinates.getLatitude, null, .{});
pub const longitude = bridge.accessor(GeolocationCoordinates.getLongitude, null, .{});
pub const accuracy = bridge.accessor(GeolocationCoordinates.getAccuracy, null, .{});
pub const altitude = bridge.accessor(GeolocationCoordinates.getAltitude, null, .{});
pub const altitudeAccuracy = bridge.accessor(GeolocationCoordinates.getAltitudeAccuracy, null, .{});
pub const heading = bridge.accessor(GeolocationCoordinates.getHeading, null, .{});
pub const speed = bridge.accessor(GeolocationCoordinates.getSpeed, null, .{});
pub const toJSON = bridge.function(GeolocationCoordinates.toJSON, .{});
};
@@ -0,0 +1,99 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Geolocation = @import("Geolocation.zig");
const GeolocationCoordinates = @import("GeolocationCoordinates.zig");
const GeolocationPosition = @This();
_rc: lp.RC = .{},
_timestamp: f64,
_arena: *lp.Arena,
_coords: *GeolocationCoordinates,
pub fn init(exec: *js.Execution, override: Geolocation.Override) !*GeolocationPosition {
const arena = try exec.getArena(.tiny, "GeolocationPosition");
errdefer arena.release();
// coords needs it own distinct address from position, else it will collide
// in the identity map
const coords = try arena.create(GeolocationCoordinates);
const position = try arena.create(GeolocationPosition);
position.* = .{
._arena = arena,
._coords = coords,
._timestamp = @floatFromInt(lp.datetime.milliTimestamp(.real)),
};
coords.* = .{
._position = position,
._latitude = override.latitude,
._longitude = override.longitude,
._accuracy = override.accuracy,
};
return position;
}
pub fn deinit(self: *GeolocationPosition, _: *Page) void {
self._arena.release();
}
pub fn acquireRef(self: *GeolocationPosition) void {
self._rc.acquire();
}
pub fn releaseRef(self: *GeolocationPosition, page: *Page) void {
self._rc.release(self, page);
}
fn getCoords(self: *const GeolocationPosition) *GeolocationCoordinates {
return self._coords;
}
fn getTimestamp(self: *const GeolocationPosition) f64 {
return self._timestamp;
}
pub fn toJSON(self: *const GeolocationPosition) struct {
coords: GeolocationCoordinates.Json,
timestamp: f64,
} {
return .{
.coords = self._coords.toJSON(),
.timestamp = self._timestamp,
};
}
pub const JsApi = struct {
pub const bridge = js.Bridge(GeolocationPosition);
pub const Meta = struct {
pub const name = "GeolocationPosition";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const coords = bridge.accessor(GeolocationPosition.getCoords, null, .{});
pub const timestamp = bridge.accessor(GeolocationPosition.getTimestamp, null, .{});
pub const toJSON = bridge.function(GeolocationPosition.toJSON, .{});
};
@@ -0,0 +1,89 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const GeolocationPositionError = @This();
pub const Code = enum(u16) {
permission_denied = 1,
position_unavailable = 2,
timeout = 3,
// Chrome's wording; some pages show it verbatim.
fn message(self: Code) []const u8 {
return switch (self) {
.permission_denied => "User denied Geolocation",
.position_unavailable => "Position unavailable",
.timeout => "Timeout expired",
};
}
};
_rc: lp.RC = .{},
_code: Code,
_arena: *lp.Arena,
pub fn init(exec: *js.Execution, code: Code) !*GeolocationPositionError {
const arena = try exec.getArena(.tiny, "GeolocationPositionError");
errdefer arena.release();
const self = try arena.create(GeolocationPositionError);
self.* = .{ ._arena = arena, ._code = code };
return self;
}
pub fn deinit(self: *GeolocationPositionError, _: *Page) void {
self._arena.release();
}
pub fn acquireRef(self: *GeolocationPositionError) void {
self._rc.acquire();
}
pub fn releaseRef(self: *GeolocationPositionError, page: *Page) void {
self._rc.release(self, page);
}
fn getCode(self: *const GeolocationPositionError) u16 {
return @intFromEnum(self._code);
}
fn getMessage(self: *const GeolocationPositionError) []const u8 {
return self._code.message();
}
pub const JsApi = struct {
pub const bridge = js.Bridge(GeolocationPositionError);
pub const Meta = struct {
pub const name = "GeolocationPositionError";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const code = bridge.accessor(GeolocationPositionError.getCode, null, .{});
pub const message = bridge.accessor(GeolocationPositionError.getMessage, null, .{});
pub const PERMISSION_DENIED = bridge.property(@intFromEnum(Code.permission_denied), .{ .template = true });
pub const POSITION_UNAVAILABLE = bridge.property(@intFromEnum(Code.position_unavailable), .{ .template = true });
pub const TIMEOUT = bridge.property(@intFromEnum(Code.timeout), .{ .template = true });
};
+295
View File
@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
const CDP = @import("../CDP.zig");
const Config = @import("../../Config.zig");
const js = @import("../../browser/js/js.zig");
const log = lp.log;
@@ -32,6 +33,8 @@ pub fn processMessage(cmd: *CDP.Command) !void {
clearDeviceMetricsOverride,
setTouchEmulationEnabled,
setUserAgentOverride,
setGeolocationOverride,
clearGeolocationOverride,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
@@ -41,6 +44,8 @@ pub fn processMessage(cmd: *CDP.Command) !void {
.clearDeviceMetricsOverride => return clearDeviceMetricsOverride(cmd),
.setTouchEmulationEnabled => return setTouchEmulationEnabled(cmd),
.setUserAgentOverride => return setUserAgentOverride(cmd),
.setGeolocationOverride => return setGeolocationOverride(cmd),
.clearGeolocationOverride => return clearGeolocationOverride(cmd),
}
}
@@ -171,6 +176,36 @@ pub fn setUserAgentOverride(cmd: *CDP.Command) !void {
return cmd.sendResult(null, .{});
}
fn setGeolocationOverride(cmd: *CDP.Command) !void {
const Params = struct {
latitude: ?f64 = null,
longitude: ?f64 = null,
accuracy: ?f64 = null,
};
// Absent params emulates "position unavailable" (Chrome semantics), so fall
// back to all-null defaults rather than erroring.
const params = (try cmd.params(Params)) orelse Params{};
const browser = &cmd.cdp.browser;
if (params.latitude) |lat| {
if (params.longitude) |lon| {
browser.geolocation_override = .{
.latitude = lat,
.longitude = lon,
.accuracy = params.accuracy orelse 0,
};
return cmd.sendResult(null, .{});
}
}
browser.geolocation_override = null;
return cmd.sendResult(null, .{});
}
fn clearGeolocationOverride(cmd: *CDP.Command) !void {
cmd.cdp.browser.geolocation_override = null;
return cmd.sendResult(null, .{});
}
const testing = @import("../testing.zig");
test "cdp.Emulation: setUserAgentOverride with valid user agent" {
@@ -315,3 +350,263 @@ test "cdp.Emulation: setDeviceMetricsOverride and clear" {
try testing.expectEqual(1920, page.getViewport().width);
try testing.expectEqual(1080, page.getViewport().height);
}
test "cdp.Emulation: setGeolocationOverride and clear" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-GEO", .url = "cdp/dom1.html" });
const browser = bc.session.browser;
try ctx.processMessage(.{
.id = 1,
.method = "Emulation.setGeolocationOverride",
.params = .{ .latitude = 48.8584, .longitude = 2.2945, .accuracy = 10 },
});
try ctx.expectSentResult(null, .{ .id = 1 });
try testing.expectEqual(48.8584, browser.geolocation_override.?.latitude);
try testing.expectEqual(2.2945, browser.geolocation_override.?.longitude);
// no coordinates => emulate "position unavailable" (stored as null)
try ctx.processMessage(.{ .id = 2, .method = "Emulation.setGeolocationOverride" });
try ctx.expectSentResult(null, .{ .id = 2 });
try testing.expect(browser.geolocation_override == null);
// re-set then clear
try ctx.processMessage(.{
.id = 3,
.method = "Emulation.setGeolocationOverride",
.params = .{ .latitude = 1.0, .longitude = 2.0, .accuracy = 5 },
});
try ctx.expectSentResult(null, .{ .id = 3 });
try ctx.processMessage(.{ .id = 4, .method = "Emulation.clearGeolocationOverride" });
try ctx.expectSentResult(null, .{ .id = 4 });
try testing.expect(browser.geolocation_override == null);
}
test "cdp.Emulation: navigator.geolocation reads the override" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-GEO2", .url = "cdp/dom1.html" });
try ctx.processMessage(.{
.id = 1,
.method = "Browser.grantPermissions",
.params = .{ .permissions = &[_][]const u8{"geolocation"} },
});
try ctx.expectSentResult(null, .{ .id = 1, .session_id = null });
try ctx.processMessage(.{
.id = 2,
.method = "Emulation.setGeolocationOverride",
.params = .{ .latitude = 48.0, .longitude = 2.0, .accuracy = 10 },
});
try ctx.expectSentResult(null, .{ .id = 2 });
const frame = bc.mainFrame() orelse unreachable;
{
// Registers the callback synchronously; getCurrentPosition schedules
// delivery on the calling context's scheduler and returns before it runs.
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
_ = try ls.local.exec(
\\ window.__geo_ok = false;
\\ navigator.geolocation.getCurrentPosition(p => {
\\ window.__geo_ok = (Math.round(p.coords.latitude) === 48 && Math.round(p.coords.longitude) === 2);
\\ // outlives both the callback and the position wrapper
\\ window.__geo_coords = p.coords;
\\ window.__geo_json = JSON.stringify(p);
\\ });
, null);
}
// Drive the session loop so the scheduled Task fires: Runner._tick runs
// browser.runMacrotasks() (which drains frame.js.scheduler) on every tick
// for a loaded page, same primitive Runner.waitForSelector/waitForScript
// use to pump pending scheduler work under a CDP-loaded page.
var runner = bc.session.runner(.{});
_ = try runner.tickForFrame(bc.page_handle.?.frame_id, 1000, .{ .until = .done });
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
const v = try ls.local.exec("window.__geo_ok && Math.round(window.__geo_coords.latitude) === 48", null);
try testing.expect(v.isTrue());
// toJSON nests a plain object, so the coords survive JSON.stringify
const j = try ls.local.exec(
\\ (() => {
\\ const p = JSON.parse(window.__geo_json);
\\ return Math.round(p.coords.latitude) === 48 && p.coords.altitude === null && p.timestamp > 0;
\\ })()
, null);
try testing.expect(j.isTrue());
}
test "cdp.Emulation: navigator.geolocation watchPosition delivers the override" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-GEO4", .url = "cdp/dom1.html" });
try ctx.processMessage(.{
.id = 1,
.method = "Browser.grantPermissions",
.params = .{ .permissions = &[_][]const u8{"geolocation"} },
});
try ctx.expectSentResult(null, .{ .id = 1, .session_id = null });
try ctx.processMessage(.{
.id = 2,
.method = "Emulation.setGeolocationOverride",
.params = .{ .latitude = 48.0, .longitude = 2.0, .accuracy = 10 },
});
try ctx.expectSentResult(null, .{ .id = 2 });
const frame = bc.mainFrame() orelse unreachable;
{
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
// The second watch is cleared before the scheduler gets to run either,
// so only the first one may report.
_ = try ls.local.exec(
\\ window.__geo_watched = 0;
\\ window.__geo_cleared = 0;
\\ navigator.geolocation.watchPosition(p => { window.__geo_watched = p.coords.latitude; });
\\ const id = navigator.geolocation.watchPosition(() => { window.__geo_cleared += 1; });
\\ navigator.geolocation.clearWatch(id);
, null);
}
var runner = bc.session.runner(.{});
_ = try runner.tickForFrame(bc.page_handle.?.frame_id, 1000, .{ .until = .done });
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
const v = try ls.local.exec("window.__geo_watched === 48 && window.__geo_cleared === 0", null);
try testing.expect(v.isTrue());
}
test "cdp.Emulation: navigator.geolocation needs an explicit permission grant" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-GEO5", .url = "cdp/dom1.html" });
// An override with no grant leaves the permission at "prompt", which headless
// Chrome resolves as denied.
try ctx.processMessage(.{
.id = 1,
.method = "Emulation.setGeolocationOverride",
.params = .{ .latitude = 48.0, .longitude = 2.0, .accuracy = 10 },
});
try ctx.expectSentResult(null, .{ .id = 1 });
try testing.expectEqual(1, try errorCodeFor(bc));
}
test "cdp.Emulation: navigator.geolocation granted without an override is POSITION_UNAVAILABLE" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-GEO6", .url = "cdp/dom1.html" });
try ctx.processMessage(.{
.id = 1,
.method = "Browser.grantPermissions",
.params = .{ .permissions = &[_][]const u8{"geolocation"} },
});
try ctx.expectSentResult(null, .{ .id = 1, .session_id = null });
try testing.expectEqual(2, try errorCodeFor(bc));
}
// Runs getCurrentPosition, pumps the scheduler, and returns the code the error
// callback saw (0 if the success callback ran instead).
fn errorCodeFor(bc: *CDP.BrowserContext) !i32 {
const frame = bc.mainFrame() orelse unreachable;
{
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
_ = try ls.local.exec(
\\ window.__geo_code = 0;
\\ navigator.geolocation.getCurrentPosition(
\\ () => { window.__geo_code = 0; },
\\ (err) => { window.__geo_code = err.code; },
\\ );
, null);
}
var runner = bc.session.runner(.{});
_ = try runner.tickForFrame(bc.page_handle.?.frame_id, 1000, .{ .until = .done });
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
return (try ls.local.exec("window.__geo_code", null)).toZig(i32);
}
test "cdp.Emulation: navigator.geolocation errors PERMISSION_DENIED when permission is denied" {
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-GEO3", .url = "cdp/dom1.html" });
try ctx.processMessage(.{
.id = 1,
.method = "Browser.setPermission",
.params = .{ .permission = .{ .name = "geolocation" }, .setting = "denied" },
});
try ctx.expectSentResult(null, .{ .id = 1, .session_id = null });
// Denied is authoritative over the override: even though an override is
// set, the denied permission must still win and produce PERMISSION_DENIED.
try ctx.processMessage(.{
.id = 2,
.method = "Emulation.setGeolocationOverride",
.params = .{ .latitude = 48.0, .longitude = 2.0, .accuracy = 10 },
});
try ctx.expectSentResult(null, .{ .id = 2 });
const frame = bc.mainFrame() orelse unreachable;
{
// Registers the callback synchronously; getCurrentPosition schedules
// delivery on the calling context's scheduler and returns before it runs.
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
_ = try ls.local.exec(
\\ window.__geo_code = 0;
\\ navigator.geolocation.getCurrentPosition(p => {
\\ p;
\\ }, error => {
\\ window.__geo_code = error.code;
\\ });
, null);
}
// Drive the session loop so the scheduled Task fires: Runner._tick runs
// browser.runMacrotasks() (which drains frame.js.scheduler) on every tick
// for a loaded page, same primitive Runner.waitForSelector/waitForScript
// use to pump pending scheduler work under a CDP-loaded page.
var runner = bc.session.runner(.{});
_ = try runner.tickForFrame(bc.page_handle.?.frame_id, 1000, .{ .until = .done });
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
const v = try ls.local.exec("window.__geo_code === 1", null);
try testing.expect(v.isTrue());
}
File diff suppressed because one or more lines are too long.