Skip to content

Commit 836a4ca

Browse files
committed
feat(emulation): add geolocation emulation tool
1 parent 2c1061b commit 836a4ca

File tree

4 files changed

+173
-2
lines changed

4 files changed

+173
-2
lines changed

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: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,57 @@ export const emulate = defineTool({
7777
}
7878
},
7979
});
80+
81+
export const emulateGeolocation = defineTool({
82+
name: 'emulate_geolocation',
83+
description: `Emulates geolocation on the selected page. Useful for testing location-based features.`,
84+
annotations: {
85+
category: ToolCategory.EMULATION,
86+
readOnlyHint: false,
87+
},
88+
schema: {
89+
latitude: zod
90+
.number()
91+
.min(-90)
92+
.max(90)
93+
.optional()
94+
.describe(
95+
'Latitude between -90 and 90. Omit latitude and longitude to clear the override.',
96+
),
97+
longitude: zod
98+
.number()
99+
.min(-180)
100+
.max(180)
101+
.optional()
102+
.describe(
103+
'Longitude between -180 and 180. Omit latitude and longitude to clear the override.',
104+
),
105+
},
106+
handler: async (request, _response, context) => {
107+
const page = context.getSelectedPage();
108+
const {latitude, longitude} = request.params;
109+
110+
if (latitude === undefined && longitude === undefined) {
111+
// Clear geolocation override
112+
await page.setGeolocation({
113+
latitude: 0,
114+
longitude: 0,
115+
});
116+
context.setGeolocation(null);
117+
} else if (latitude !== undefined && longitude !== undefined) {
118+
// Set geolocation override
119+
await page.setGeolocation({
120+
latitude,
121+
longitude,
122+
});
123+
context.setGeolocation({
124+
latitude,
125+
longitude,
126+
});
127+
} else {
128+
throw new Error(
129+
'Both latitude and longitude must be provided, or both must be omitted to clear the override.',
130+
);
131+
}
132+
},
133+
});

tests/tools/emulation.test.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import assert from 'node:assert';
88
import {describe, it} from 'node:test';
99

10-
import {emulate} from '../../src/tools/emulation.js';
10+
import {emulate, emulateGeolocation} from '../../src/tools/emulation.js';
1111
import {withMcpContext} from '../utils.js';
1212

1313
describe('emulation', () => {
@@ -152,4 +152,100 @@ 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 emulateGeolocation.handler(
160+
{
161+
params: {
162+
latitude: 48.137154,
163+
longitude: 11.576124,
164+
},
165+
},
166+
response,
167+
context,
168+
);
169+
170+
const geolocation = context.getGeolocation();
171+
assert.strictEqual(geolocation?.latitude, 48.137154);
172+
assert.strictEqual(geolocation?.longitude, 11.576124);
173+
});
174+
});
175+
176+
it('clears geolocation override when both params are omitted', async () => {
177+
await withMcpContext(async (response, context) => {
178+
// First set a geolocation
179+
await emulateGeolocation.handler(
180+
{
181+
params: {
182+
latitude: 48.137154,
183+
longitude: 11.576124,
184+
},
185+
},
186+
response,
187+
context,
188+
);
189+
190+
assert.notStrictEqual(context.getGeolocation(), null);
191+
192+
// Then clear it by omitting both params
193+
await emulateGeolocation.handler(
194+
{
195+
params: {},
196+
},
197+
response,
198+
context,
199+
);
200+
201+
assert.strictEqual(context.getGeolocation(), null);
202+
});
203+
});
204+
205+
it('throws error when only latitude is provided', async () => {
206+
await withMcpContext(async (response, context) => {
207+
await assert.rejects(
208+
async () => {
209+
await emulateGeolocation.handler(
210+
{
211+
params: {
212+
latitude: 48.137154,
213+
},
214+
},
215+
response,
216+
context,
217+
);
218+
},
219+
{
220+
message:
221+
'Both latitude and longitude must be provided, or both must be omitted to clear the override.',
222+
},
223+
);
224+
});
225+
});
226+
227+
it('reports correctly for the currently selected page', async () => {
228+
await withMcpContext(async (response, context) => {
229+
await emulateGeolocation.handler(
230+
{
231+
params: {
232+
latitude: 48.137154,
233+
longitude: 11.576124,
234+
},
235+
},
236+
response,
237+
context,
238+
);
239+
240+
const geolocation = context.getGeolocation();
241+
assert.strictEqual(geolocation?.latitude, 48.137154);
242+
assert.strictEqual(geolocation?.longitude, 11.576124);
243+
244+
const page = await context.newPage();
245+
context.selectPage(page);
246+
247+
assert.strictEqual(context.getGeolocation(), null);
248+
});
249+
});
250+
});
155251
});

0 commit comments

Comments
 (0)