Skip to content

Commit 3991e4c

Browse files
authored
feat(emulation): add geolocation emulation tool (#634)
Adding emulate_geolocation tool to emulate device location for testing location-based features. Supports setting latitude/longitude or clearing the override. Closing #296
1 parent 2c1061b commit 3991e4c

File tree

5 files changed

+136
-11
lines changed

5 files changed

+136
-11
lines changed

docs/tool-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@
196196
**Parameters:**
197197

198198
- **cpuThrottlingRate** (number) _(optional)_: Represents the CPU slowdown factor. Set the rate to 1 to disable throttling. If omitted, throttling remains unchanged.
199+
- **geolocation** (unknown) _(optional)_: Geolocation to [`emulate`](#emulate). Set to null to clear the geolocation override.
199200
- **networkConditions** (enum: "No emulation", "Offline", "Slow 3G", "Fast 3G", "Slow 4G", "Fast 4G") _(optional)_: Throttle network. Set to "No emulation" to disable. If omitted, conditions remain unchanged.
200201

201202
---

src/McpContext.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ export interface TextSnapshotNode extends SerializedAXNode {
3838
children: TextSnapshotNode[];
3939
}
4040

41+
export interface GeolocationOptions {
42+
latitude: number;
43+
longitude: number;
44+
}
45+
4146
export interface TextSnapshot {
4247
root: TextSnapshotNode;
4348
idToNode: Map<string, TextSnapshotNode>;
@@ -104,6 +109,7 @@ export class McpContext implements Context {
104109
#isRunningTrace = false;
105110
#networkConditionsMap = new WeakMap<Page, string>();
106111
#cpuThrottlingRateMap = new WeakMap<Page, number>();
112+
#geolocationMap = new WeakMap<Page, GeolocationOptions>();
107113
#dialog?: Dialog;
108114

109115
#nextSnapshotId = 1;
@@ -277,6 +283,20 @@ export class McpContext implements Context {
277283
return this.#cpuThrottlingRateMap.get(page) ?? 1;
278284
}
279285

286+
setGeolocation(geolocation: GeolocationOptions | null): void {
287+
const page = this.getSelectedPage();
288+
if (geolocation === null) {
289+
this.#geolocationMap.delete(page);
290+
} else {
291+
this.#geolocationMap.set(page, geolocation);
292+
}
293+
}
294+
295+
getGeolocation(): GeolocationOptions | null {
296+
const page = this.getSelectedPage();
297+
return this.#geolocationMap.get(page) ?? null;
298+
}
299+
280300
setIsRunningPerformanceTrace(x: boolean): void {
281301
this.#isRunningTrace = x;
282302
}

src/tools/ToolDefinition.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import type {TextSnapshotNode} from '../McpContext.js';
7+
import type {TextSnapshotNode, GeolocationOptions} from '../McpContext.js';
88
import {zod} from '../third_party/index.js';
99
import type {Dialog, ElementHandle, Page} from '../third_party/index.js';
1010
import type {TraceResult} from '../trace-processing/parse.js';
@@ -98,6 +98,7 @@ export type Context = Readonly<{
9898
getAXNodeByUid(uid: string): TextSnapshotNode | undefined;
9999
setNetworkConditions(conditions: string | null): void;
100100
setCpuThrottlingRate(rate: number): void;
101+
setGeolocation(geolocation: GeolocationOptions | null): void;
101102
saveTemporaryFile(
102103
data: Uint8Array<ArrayBufferLike>,
103104
mimeType: 'image/png' | 'image/jpeg' | 'image/webp',

src/tools/emulation.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,31 +37,42 @@ export const emulate = defineTool({
3737
.describe(
3838
'Represents the CPU slowdown factor. Set the rate to 1 to disable throttling. If omitted, throttling remains unchanged.',
3939
),
40+
geolocation: zod
41+
.object({
42+
latitude: zod
43+
.number()
44+
.min(-90)
45+
.max(90)
46+
.describe('Latitude between -90 and 90.'),
47+
longitude: zod
48+
.number()
49+
.min(-180)
50+
.max(180)
51+
.describe('Longitude between -180 and 180.'),
52+
})
53+
.nullable()
54+
.optional()
55+
.describe(
56+
'Geolocation to emulate. Set to null to clear the geolocation override.',
57+
),
4058
},
4159
handler: async (request, _response, context) => {
4260
const page = context.getSelectedPage();
43-
const networkConditions = request.params.networkConditions;
44-
const cpuThrottlingRate = request.params.cpuThrottlingRate;
61+
const {networkConditions, cpuThrottlingRate, geolocation} = request.params;
4562

4663
if (networkConditions) {
4764
if (networkConditions === 'No emulation') {
4865
await page.emulateNetworkConditions(null);
4966
context.setNetworkConditions(null);
50-
return;
51-
}
52-
53-
if (networkConditions === 'Offline') {
67+
} else if (networkConditions === 'Offline') {
5468
await page.emulateNetworkConditions({
5569
offline: true,
5670
download: 0,
5771
upload: 0,
5872
latency: 0,
5973
});
6074
context.setNetworkConditions('Offline');
61-
return;
62-
}
63-
64-
if (networkConditions in PredefinedNetworkConditions) {
75+
} else if (networkConditions in PredefinedNetworkConditions) {
6576
const networkCondition =
6677
PredefinedNetworkConditions[
6778
networkConditions as keyof typeof PredefinedNetworkConditions
@@ -75,5 +86,15 @@ export const emulate = defineTool({
7586
await page.emulateCPUThrottling(cpuThrottlingRate);
7687
context.setCpuThrottlingRate(cpuThrottlingRate);
7788
}
89+
90+
if (geolocation !== undefined) {
91+
if (geolocation === null) {
92+
await page.setGeolocation({latitude: 0, longitude: 0});
93+
context.setGeolocation(null);
94+
} else {
95+
await page.setGeolocation(geolocation);
96+
context.setGeolocation(geolocation);
97+
}
98+
}
7899
},
79100
});

tests/tools/emulation.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,86 @@ describe('emulation', () => {
152152
});
153153
});
154154
});
155+
156+
describe('geolocation', () => {
157+
it('emulates geolocation with latitude and longitude', async () => {
158+
await withMcpContext(async (response, context) => {
159+
await emulate.handler(
160+
{
161+
params: {
162+
geolocation: {
163+
latitude: 48.137154,
164+
longitude: 11.576124,
165+
},
166+
},
167+
},
168+
response,
169+
context,
170+
);
171+
172+
const geolocation = context.getGeolocation();
173+
assert.strictEqual(geolocation?.latitude, 48.137154);
174+
assert.strictEqual(geolocation?.longitude, 11.576124);
175+
});
176+
});
177+
178+
it('clears geolocation override when geolocation is set to null', async () => {
179+
await withMcpContext(async (response, context) => {
180+
// First set a geolocation
181+
await emulate.handler(
182+
{
183+
params: {
184+
geolocation: {
185+
latitude: 48.137154,
186+
longitude: 11.576124,
187+
},
188+
},
189+
},
190+
response,
191+
context,
192+
);
193+
194+
assert.notStrictEqual(context.getGeolocation(), null);
195+
196+
// Then clear it by setting geolocation to null
197+
await emulate.handler(
198+
{
199+
params: {
200+
geolocation: null,
201+
},
202+
},
203+
response,
204+
context,
205+
);
206+
207+
assert.strictEqual(context.getGeolocation(), null);
208+
});
209+
});
210+
211+
it('reports correctly for the currently selected page', async () => {
212+
await withMcpContext(async (response, context) => {
213+
await emulate.handler(
214+
{
215+
params: {
216+
geolocation: {
217+
latitude: 48.137154,
218+
longitude: 11.576124,
219+
},
220+
},
221+
},
222+
response,
223+
context,
224+
);
225+
226+
const geolocation = context.getGeolocation();
227+
assert.strictEqual(geolocation?.latitude, 48.137154);
228+
assert.strictEqual(geolocation?.longitude, 11.576124);
229+
230+
const page = await context.newPage();
231+
context.selectPage(page);
232+
233+
assert.strictEqual(context.getGeolocation(), null);
234+
});
235+
});
236+
});
155237
});

0 commit comments

Comments
 (0)