diff --git a/src/McpContext.ts b/src/McpContext.ts index 6184b1a94..626943cba 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -16,7 +16,6 @@ import { } from './DevtoolsUtils.js'; import type {ListenerMap, UncaughtError} from './PageCollector.js'; import {NetworkCollector, ConsoleCollector} from './PageCollector.js'; -import {Locator} from './third_party/index.js'; import type {DevTools} from './third_party/index.js'; import type { Browser, @@ -27,9 +26,10 @@ import type { HTTPRequest, Page, SerializedAXNode, - PredefinedNetworkConditions, Viewport, } from './third_party/index.js'; +import {Locator} from './third_party/index.js'; +import {PredefinedNetworkConditions} from './third_party/index.js'; import {listPages} from './tools/pages.js'; import {takeSnapshot} from './tools/snapshot.js'; import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js'; @@ -64,6 +64,15 @@ export interface TextSnapshot { verbose: boolean; } +interface EmulationSettings { + networkConditions?: string | null; + cpuThrottlingRate?: number | null; + geolocation?: GeolocationOptions | null; + userAgent?: string | null; + colorScheme?: 'dark' | 'light' | null; + viewport?: Viewport | null; +} + interface McpContextOptions { // Whether the DevTools windows are exposed as pages for debugging of DevTools. experimentalDevToolsDebugging: boolean; @@ -121,12 +130,7 @@ export class McpContext implements Context { #extensionRegistry = new ExtensionRegistry(); #isRunningTrace = false; - #networkConditionsMap = new WeakMap(); - #cpuThrottlingRateMap = new WeakMap(); - #geolocationMap = new WeakMap(); - #viewportMap = new WeakMap(); - #userAgentMap = new WeakMap(); - #colorSchemeMap = new WeakMap(); + #emulationSettingsMap = new WeakMap(); #dialog?: Dialog; #pageIdMap = new WeakMap(); @@ -282,86 +286,146 @@ export class McpContext implements Context { return this.#networkCollector.getById(this.getSelectedPage(), reqid); } - setNetworkConditions(conditions: string | null): void { + async emulate(options: { + networkConditions?: string | null; + cpuThrottlingRate?: number | null; + geolocation?: GeolocationOptions | null; + userAgent?: string | null; + colorScheme?: 'dark' | 'light' | 'auto' | null; + viewport?: Viewport | null; + }): Promise { const page = this.getSelectedPage(); - if (conditions === null) { - this.#networkConditionsMap.delete(page); - } else { - this.#networkConditionsMap.set(page, conditions); + const currentSettings = this.#emulationSettingsMap.get(page) ?? {}; + const newSettings: EmulationSettings = {...currentSettings}; + let timeoutsNeedUpdate = false; + + if (options.networkConditions !== undefined) { + timeoutsNeedUpdate = true; + if ( + options.networkConditions === null || + options.networkConditions === 'No emulation' + ) { + await page.emulateNetworkConditions(null); + delete newSettings.networkConditions; + } else if (options.networkConditions === 'Offline') { + await page.emulateNetworkConditions({ + offline: true, + download: 0, + upload: 0, + latency: 0, + }); + newSettings.networkConditions = 'Offline'; + } else if (options.networkConditions in PredefinedNetworkConditions) { + const networkCondition = + PredefinedNetworkConditions[ + options.networkConditions as keyof typeof PredefinedNetworkConditions + ]; + await page.emulateNetworkConditions(networkCondition); + newSettings.networkConditions = options.networkConditions; + } } - this.#updateSelectedPageTimeouts(); - } - getNetworkConditions(): string | null { - const page = this.getSelectedPage(); - return this.#networkConditionsMap.get(page) ?? null; - } + if (options.cpuThrottlingRate !== undefined) { + timeoutsNeedUpdate = true; + if (options.cpuThrottlingRate === null) { + await page.emulateCPUThrottling(1); + delete newSettings.cpuThrottlingRate; + } else { + await page.emulateCPUThrottling(options.cpuThrottlingRate); + newSettings.cpuThrottlingRate = options.cpuThrottlingRate; + } + } - setCpuThrottlingRate(rate: number): void { - const page = this.getSelectedPage(); - this.#cpuThrottlingRateMap.set(page, rate); - this.#updateSelectedPageTimeouts(); - } + if (options.geolocation !== undefined) { + if (options.geolocation === null) { + await page.setGeolocation({latitude: 0, longitude: 0}); + delete newSettings.geolocation; + } else { + await page.setGeolocation(options.geolocation); + newSettings.geolocation = options.geolocation; + } + } - getCpuThrottlingRate(): number { - const page = this.getSelectedPage(); - return this.#cpuThrottlingRateMap.get(page) ?? 1; - } + if (options.userAgent !== undefined) { + if (options.userAgent === null) { + await page.setUserAgent({userAgent: undefined}); + delete newSettings.userAgent; + } else { + await page.setUserAgent({userAgent: options.userAgent}); + newSettings.userAgent = options.userAgent; + } + } - setGeolocation(geolocation: GeolocationOptions | null): void { - const page = this.getSelectedPage(); - if (geolocation === null) { - this.#geolocationMap.delete(page); + if (options.colorScheme !== undefined) { + if (options.colorScheme === null || options.colorScheme === 'auto') { + await page.emulateMediaFeatures([ + {name: 'prefers-color-scheme', value: ''}, + ]); + delete newSettings.colorScheme; + } else { + await page.emulateMediaFeatures([ + {name: 'prefers-color-scheme', value: options.colorScheme}, + ]); + newSettings.colorScheme = options.colorScheme; + } + } + + if (options.viewport !== undefined) { + if (options.viewport === null) { + await page.setViewport(null); + delete newSettings.viewport; + } else { + const defaults = { + deviceScaleFactor: 1, + isMobile: false, + hasTouch: false, + isLandscape: false, + }; + const viewport = {...defaults, ...options.viewport}; + await page.setViewport(viewport); + newSettings.viewport = viewport; + } + } + + if (Object.keys(newSettings).length) { + this.#emulationSettingsMap.set(page, newSettings); } else { - this.#geolocationMap.set(page, geolocation); + this.#emulationSettingsMap.delete(page); } - } - getGeolocation(): GeolocationOptions | null { - const page = this.getSelectedPage(); - return this.#geolocationMap.get(page) ?? null; + if (timeoutsNeedUpdate) { + this.#updateSelectedPageTimeouts(); + } } - setViewport(viewport: Viewport | null): void { + getNetworkConditions(): string | null { const page = this.getSelectedPage(); - if (viewport === null) { - this.#viewportMap.delete(page); - } else { - this.#viewportMap.set(page, viewport); - } + return this.#emulationSettingsMap.get(page)?.networkConditions ?? null; } - getViewport(): Viewport | null { + getCpuThrottlingRate(): number { const page = this.getSelectedPage(); - return this.#viewportMap.get(page) ?? null; + return this.#emulationSettingsMap.get(page)?.cpuThrottlingRate ?? 1; } - setUserAgent(userAgent: string | null): void { + getGeolocation(): GeolocationOptions | null { const page = this.getSelectedPage(); - if (userAgent === null) { - this.#userAgentMap.delete(page); - } else { - this.#userAgentMap.set(page, userAgent); - } + return this.#emulationSettingsMap.get(page)?.geolocation ?? null; } - getUserAgent(): string | null { + getViewport(): Viewport | null { const page = this.getSelectedPage(); - return this.#userAgentMap.get(page) ?? null; + return this.#emulationSettingsMap.get(page)?.viewport ?? null; } - setColorScheme(scheme: 'dark' | 'light' | null): void { + getUserAgent(): string | null { const page = this.getSelectedPage(); - if (scheme === null) { - this.#colorSchemeMap.delete(page); - } else { - this.#colorSchemeMap.set(page, scheme); - } + return this.#emulationSettingsMap.get(page)?.userAgent ?? null; } getColorScheme(): 'dark' | 'light' | null { const page = this.getSelectedPage(); - return this.#colorSchemeMap.get(page) ?? null; + return this.#emulationSettingsMap.get(page)?.colorScheme ?? null; } setIsRunningPerformanceTrace(x: boolean): void { diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index e698591e9..59b0f0057 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -116,14 +116,20 @@ export type Context = Readonly<{ selectPage(page: Page): void; getElementByUid(uid: string): Promise>; getAXNodeByUid(uid: string): TextSnapshotNode | undefined; - setNetworkConditions(conditions: string | null): void; - setCpuThrottlingRate(rate: number): void; - setGeolocation(geolocation: GeolocationOptions | null): void; - setViewport(viewport: Viewport | null): void; + emulate(options: { + networkConditions?: string | null; + cpuThrottlingRate?: number | null; + geolocation?: GeolocationOptions | null; + userAgent?: string | null; + colorScheme?: 'dark' | 'light' | 'auto' | null; + viewport?: Viewport | null; + }): Promise; + getNetworkConditions(): string | null; + getCpuThrottlingRate(): number; + getGeolocation(): GeolocationOptions | null; getViewport(): Viewport | null; - setUserAgent(userAgent: string | null): void; getUserAgent(): string | null; - setColorScheme(scheme: 'dark' | 'light' | null): void; + getColorScheme(): 'dark' | 'light' | null; saveTemporaryFile( data: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp', diff --git a/src/tools/emulation.ts b/src/tools/emulation.ts index 637664e94..ea0538fd1 100644 --- a/src/tools/emulation.ts +++ b/src/tools/emulation.ts @@ -104,97 +104,6 @@ export const emulate = defineTool({ ), }, handler: async (request, _response, context) => { - const page = context.getSelectedPage(); - const { - networkConditions, - cpuThrottlingRate, - geolocation, - userAgent, - viewport, - } = request.params; - - if (networkConditions) { - if (networkConditions === 'No emulation') { - await page.emulateNetworkConditions(null); - context.setNetworkConditions(null); - } else if (networkConditions === 'Offline') { - await page.emulateNetworkConditions({ - offline: true, - download: 0, - upload: 0, - latency: 0, - }); - context.setNetworkConditions('Offline'); - } else if (networkConditions in PredefinedNetworkConditions) { - const networkCondition = - PredefinedNetworkConditions[ - networkConditions as keyof typeof PredefinedNetworkConditions - ]; - await page.emulateNetworkConditions(networkCondition); - context.setNetworkConditions(networkConditions); - } - } - - if (cpuThrottlingRate) { - await page.emulateCPUThrottling(cpuThrottlingRate); - context.setCpuThrottlingRate(cpuThrottlingRate); - } - - if (geolocation !== undefined) { - if (geolocation === null) { - await page.setGeolocation({latitude: 0, longitude: 0}); - context.setGeolocation(null); - } else { - await page.setGeolocation(geolocation); - context.setGeolocation(geolocation); - } - } - - if (userAgent !== undefined) { - if (userAgent === null) { - await page.setUserAgent({ - userAgent: undefined, - }); - context.setUserAgent(null); - } else { - await page.setUserAgent({ - userAgent, - }); - context.setUserAgent(userAgent); - } - } - - if (request.params.colorScheme) { - if (request.params.colorScheme === 'auto') { - await page.emulateMediaFeatures([ - {name: 'prefers-color-scheme', value: ''}, - ]); - context.setColorScheme(null); - } else { - await page.emulateMediaFeatures([ - { - name: 'prefers-color-scheme', - value: request.params.colorScheme, - }, - ]); - context.setColorScheme(request.params.colorScheme); - } - } - - if (viewport !== undefined) { - if (viewport === null) { - await page.setViewport(null); - context.setViewport(null); - } else { - const defaults = { - deviceScaleFactor: 1, - isMobile: false, - hasTouch: false, - isLandscape: false, - }; - await page.setViewport({...defaults, ...viewport}); - context.setViewport({...defaults, ...viewport}); - } - } + await context.emulate(request.params); }, }); diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts index 3783f55f6..03c51351b 100644 --- a/tests/McpContext.test.ts +++ b/tests/McpContext.test.ts @@ -51,7 +51,7 @@ describe('McpContext', () => { await withMcpContext(async (_response, context) => { const page = await context.newPage(); const timeoutBefore = page.getDefaultTimeout(); - context.setCpuThrottlingRate(2); + await context.emulate({cpuThrottlingRate: 2}); const timeoutAfter = page.getDefaultTimeout(); assert(timeoutBefore < timeoutAfter, 'Timeout was less then expected'); }); @@ -61,7 +61,7 @@ describe('McpContext', () => { await withMcpContext(async (_response, context) => { const page = await context.newPage(); const timeoutBefore = page.getDefaultNavigationTimeout(); - context.setNetworkConditions('Slow 3G'); + await context.emulate({networkConditions: 'Slow 3G'}); const timeoutAfter = page.getDefaultNavigationTimeout(); assert(timeoutBefore < timeoutAfter, 'Timeout was less then expected'); }); @@ -71,8 +71,10 @@ describe('McpContext', () => { await withMcpContext(async (_response, context) => { const page = await context.newPage(); - context.setCpuThrottlingRate(2); - context.setNetworkConditions('Slow 3G'); + await context.emulate({ + cpuThrottlingRate: 2, + networkConditions: 'Slow 3G', + }); const stub = sinon.spy(context, 'getWaitForHelper'); await context.waitForEventsAfterAction(async () => { diff --git a/tests/McpResponse.test.js.snapshot b/tests/McpResponse.test.js.snapshot index 8897adc3c..6584accc4 100644 --- a/tests/McpResponse.test.js.snapshot +++ b/tests/McpResponse.test.js.snapshot @@ -263,15 +263,18 @@ exports[`McpResponse > adds userAgent emulation setting when it is set 2`] = ` exports[`McpResponse > adds viewport emulation setting when it is set 1`] = ` # test response ## Viewport emulation -Emulating viewport: {"width":400,"height":400,"deviceScaleFactor":1} +Emulating viewport: {"deviceScaleFactor":1,"isMobile":false,"hasTouch":false,"isLandscape":false,"width":400,"height":400} `; exports[`McpResponse > adds viewport emulation setting when it is set 2`] = ` { "viewport": { + "deviceScaleFactor": 1, + "isMobile": false, + "hasTouch": false, + "isLandscape": false, "width": 400, - "height": 400, - "deviceScaleFactor": 1 + "height": 400 } } `; diff --git a/tests/McpResponse.test.ts b/tests/McpResponse.test.ts index 63b0955b6..71e31fc87 100644 --- a/tests/McpResponse.test.ts +++ b/tests/McpResponse.test.ts @@ -283,7 +283,7 @@ describe('McpResponse', () => { it('adds throttling setting when it is not null', async t => { await withMcpContext(async (response, context) => { - context.setNetworkConditions('Slow 3G'); + await context.emulate({networkConditions: 'Slow 3G'}); const {content, structuredContent} = await response.handle( 'test', context, @@ -302,7 +302,7 @@ describe('McpResponse', () => { 'test', context, ); - context.setNetworkConditions(null); + await context.emulate({networkConditions: null}); assert.equal(content[0].type, 'text'); assert.strictEqual(getTextContent(content[0]), `# test response`); t.assert.snapshot?.( @@ -329,7 +329,7 @@ describe('McpResponse', () => { it('adds cpu throttling setting when it is over 1', async t => { await withMcpContext(async (response, context) => { - context.setCpuThrottlingRate(4); + await context.emulate({cpuThrottlingRate: 4}); const {content, structuredContent} = await response.handle( 'test', context, @@ -343,7 +343,7 @@ describe('McpResponse', () => { it('does not include cpu throttling setting when it is 1', async t => { await withMcpContext(async (response, context) => { - context.setCpuThrottlingRate(1); + await context.emulate({cpuThrottlingRate: 1}); const {content, structuredContent} = await response.handle( 'test', context, @@ -357,7 +357,9 @@ describe('McpResponse', () => { it('adds viewport emulation setting when it is set', async t => { await withMcpContext(async (response, context) => { - context.setViewport({width: 400, height: 400, deviceScaleFactor: 1}); + await context.emulate({ + viewport: {width: 400, height: 400, deviceScaleFactor: 1}, + }); const {content, structuredContent} = await response.handle( 'test', context, @@ -371,7 +373,7 @@ describe('McpResponse', () => { it('adds userAgent emulation setting when it is set', async t => { await withMcpContext(async (response, context) => { - context.setUserAgent('MyUA'); + await context.emulate({userAgent: 'MyUA'}); const {content, structuredContent} = await response.handle( 'test', context, @@ -385,7 +387,7 @@ describe('McpResponse', () => { it('adds color scheme emulation setting when it is set', async t => { await withMcpContext(async (response, context) => { - context.setColorScheme('dark'); + await context.emulate({colorScheme: 'dark'}); const {content, structuredContent} = await response.handle( 'test', context, diff --git a/tests/tools/emulation.test.ts b/tests/tools/emulation.test.ts index f66ea221e..2aa31dad6 100644 --- a/tests/tools/emulation.test.ts +++ b/tests/tools/emulation.test.ts @@ -119,7 +119,9 @@ describe('emulation', () => { it('disables cpu throttling', async () => { await withMcpContext(async (response, context) => { - context.setCpuThrottlingRate(4); // Set it to something first. + await context.emulate({ + cpuThrottlingRate: 4, + }); await emulate.handler( { params: {