diff --git a/src/firefox/cache.ts b/src/firefox/cache.ts new file mode 100644 index 0000000..9283c4c --- /dev/null +++ b/src/firefox/cache.ts @@ -0,0 +1,41 @@ +/** + * Network cache behaviour (WebDriver BiDi network.setCacheBehavior) + */ + +export type BiDiCommandFn = (method: string, params: Record) => Promise; + +/** + * WebDriver BiDi network.CacheBehavior. + * - "default": normal HTTP cache behaviour + * - "bypass": skip the cache, so every request goes to the network + */ +export const CACHE_BEHAVIORS = ['default', 'bypass'] as const; + +export type CacheBehavior = (typeof CACHE_BEHAVIORS)[number]; + +export function isCacheBehavior(value: unknown): value is CacheBehavior { + return CACHE_BEHAVIORS.includes(value as CacheBehavior); +} + +export class CacheManagement { + constructor( + private getCurrentContextId: () => string | null, + private sendBiDiCommand: BiDiCommandFn + ) {} + + async setCacheBehavior(behavior: CacheBehavior, options?: { global?: boolean }): Promise { + const params: Record = { cacheBehavior: behavior }; + + // Omitting `contexts` applies the behaviour globally; passing the current + // context scopes it to the selected tab, which is the default. + if (!options?.global) { + const contextId = this.getCurrentContextId(); + if (!contextId) { + throw new Error('Cannot set cache behavior: no browsing context ID'); + } + params.contexts = [contextId]; + } + + await this.sendBiDiCommand('network.setCacheBehavior', params); + } +} diff --git a/src/firefox/index.ts b/src/firefox/index.ts index c539282..fe2ae0b 100644 --- a/src/firefox/index.ts +++ b/src/firefox/index.ts @@ -12,8 +12,13 @@ import { ConsoleEvents, NetworkEvents, DebuggingEvents, DownloadEvents } from '. import type { NetworkBodyResult } from './events/network.js'; import { DomInteractions } from './dom.js'; import { PageManagement, type ReadinessState } from './pages.js'; +import { CacheManagement, CACHE_BEHAVIORS, isCacheBehavior, type CacheBehavior } from './cache.js'; import { SnapshotManager, type Snapshot, type SnapshotOptions } from './snapshot/index.js'; +// Re-exported for backward compatibility: src/tools/network.ts imports +// these cache types from firefox/index.js rather than firefox/cache.js. +export { CACHE_BEHAVIORS, isCacheBehavior, type CacheBehavior }; + /** * Main Firefox Client facade * Delegates to modular components for clean separation of concerns @@ -27,6 +32,7 @@ export class FirefoxClient { private downloadEvents: DownloadEvents | null = null; private dom: DomInteractions | null = null; private pages: PageManagement | null = null; + private cache: CacheManagement | null = null; private snapshot: SnapshotManager | null = null; constructor(options: FirefoxLaunchOptions) { @@ -101,6 +107,11 @@ export class FirefoxClient { (id: string) => this.core.setCurrentContextId(id), (method: string, params: Record) => this.getBidi().sendCommand(method, params) ); + + this.cache = new CacheManagement( + () => this.core.getCurrentContextId(), + (method: string, params: Record) => this.getBidi().sendCommand(method, params) + ); } // ============================================================================ @@ -320,6 +331,13 @@ export class FirefoxClient { // Network // ============================================================================ + async setCacheBehavior(behavior: CacheBehavior, options?: { global?: boolean }): Promise { + if (!this.cache) { + throw new Error('Not connected'); + } + await this.cache.setCacheBehavior(behavior, options); + } + async startNetworkMonitoring(): Promise { if (!this.networkEvents) { throw new Error( @@ -587,6 +605,7 @@ export class FirefoxClient { this.downloadEvents = null; this.dom = null; this.pages = null; + this.cache = null; this.snapshot = null; } } diff --git a/src/tools/network.ts b/src/tools/network.ts index 7aeece1..2bdb641 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -17,6 +17,7 @@ import { saveOutput } from '../utils/save-output.js'; import { defineModule, defineToolHandler, type ToolDefinition } from './module.js'; import type { McpToolResponse } from '../types/common.js'; import type { NetworkBodyResult } from '../firefox/events/network.js'; +import { CACHE_BEHAVIORS, isCacheBehavior } from '../firefox/index.js'; // Tool definitions export const listNetworkRequestsTool = { @@ -131,6 +132,31 @@ export const getNetworkRequestTool = { }, } satisfies ToolDefinition; +export const setNetworkCacheTool = { + name: 'set_network_cache', + description: + "Control the HTTP cache. Use behavior='bypass' so every request goes to the network — useful for performance measurement and for verifying a change that a cached asset would otherwise hide. Applies to the selected tab unless scope='global'. Persists until set back to 'default'.", + annotations: { + readOnlyHint: false, + }, + inputSchema: { + type: 'object', + properties: { + behavior: { + type: 'string', + enum: [...CACHE_BEHAVIORS], + description: "'bypass' to skip the cache, 'default' to restore normal caching", + }, + scope: { + type: 'string', + enum: ['tab', 'global'], + description: "'tab' (default) applies to the selected tab; 'global' applies browser-wide", + }, + }, + required: ['behavior'], + }, +} satisfies ToolDefinition; + /** * Fetch a body without letting a missing facade method or transport error fail * the whole tool call. Absent support degrades to an 'unsupported' marker. @@ -576,11 +602,43 @@ export const handleGetNetworkRequest = defineToolHandler( } ); +export async function handleSetNetworkCache(args: unknown): Promise { + try { + const { behavior, scope } = (args as { behavior?: unknown; scope?: unknown }) || {}; + + // An unrecognised value is rejected rather than defaulting: silently caching + // when the caller asked to bypass would quietly invalidate their measurement. + if (!isCacheBehavior(behavior)) { + return errorResponse( + `behavior must be one of ${CACHE_BEHAVIORS.join(', ')} (got ${JSON.stringify(behavior)})` + ); + } + if (scope !== undefined && scope !== 'tab' && scope !== 'global') { + return errorResponse(`scope must be 'tab' or 'global' (got ${JSON.stringify(scope)})`); + } + + const isGlobal = scope === 'global'; + + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + + await firefox.setCacheBehavior(behavior, { global: isGlobal }); + + return successResponse( + `cache ${behavior} (${isGlobal ? 'all tabs' : 'selected tab'})` + + (behavior === 'bypass' ? " — set behavior='default' to restore caching" : '') + ); + } catch (error) { + return errorResponse(error instanceof Error ? error : new Error(String(error))); + } +} + export const module = defineModule({ name: 'network', - description: 'List and inspect network requests.', + description: 'List and inspect network requests, and control the HTTP cache.', tools: [ [listNetworkRequestsTool, handleListNetworkRequests], [getNetworkRequestTool, handleGetNetworkRequest], + [setNetworkCacheTool, handleSetNetworkCache], ], }); diff --git a/tests/tools/network.test.ts b/tests/tools/network.test.ts index 38040ba..3ca4c0d 100644 --- a/tests/tools/network.test.ts +++ b/tests/tools/network.test.ts @@ -6,7 +6,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { existsSync, readFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { listNetworkRequestsTool, getNetworkRequestTool } from '../../src/tools/network.js'; +import { + listNetworkRequestsTool, + getNetworkRequestTool, + setNetworkCacheTool, +} from '../../src/tools/network.js'; describe('Network Tools', () => { describe('Tool Definitions', () => { @@ -26,6 +30,77 @@ describe('Network Tools', () => { }); }); + describe('set_network_cache', () => { + let setCacheBehavior: ReturnType; + + beforeEach(() => { + setCacheBehavior = vi.fn().mockResolvedValue(undefined); + vi.doMock('../../src/index.js', () => ({ + args: {}, + getFirefox: vi.fn().mockResolvedValue({ setCacheBehavior }), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + const textOf = (r: { content: Array<{ type: string; text?: string }> }) => + (r.content[0] as { type: 'text'; text: string }).text; + + it('exposes a behavior enum and an optional scope', () => { + expect(setNetworkCacheTool.name).toBe('set_network_cache'); + const { properties, required } = setNetworkCacheTool.inputSchema; + expect(properties?.behavior.enum).toEqual(['default', 'bypass']); + expect(properties?.scope.enum).toEqual(['tab', 'global']); + expect(required).toEqual(['behavior']); + expect(required).not.toContain('scope'); + }); + + it('scopes to the selected tab by default', async () => { + const { handleSetNetworkCache } = await import('../../src/tools/network.js'); + const result = await handleSetNetworkCache({ behavior: 'bypass' }); + + expect(setCacheBehavior).toHaveBeenCalledWith('bypass', { global: false }); + expect(textOf(result)).toContain('selected tab'); + }); + + it('applies browser-wide when scope is global', async () => { + const { handleSetNetworkCache } = await import('../../src/tools/network.js'); + const result = await handleSetNetworkCache({ behavior: 'bypass', scope: 'global' }); + + expect(setCacheBehavior).toHaveBeenCalledWith('bypass', { global: true }); + expect(textOf(result)).toContain('all tabs'); + }); + + it('tells the caller how to restore caching after a bypass', async () => { + const { handleSetNetworkCache } = await import('../../src/tools/network.js'); + const bypass = await handleSetNetworkCache({ behavior: 'bypass' }); + expect(textOf(bypass)).toContain("behavior='default'"); + + const restore = await handleSetNetworkCache({ behavior: 'default' }); + expect(setCacheBehavior).toHaveBeenLastCalledWith('default', { global: false }); + expect(textOf(restore)).not.toContain("behavior='default' to restore"); + }); + + it('rejects an unknown behavior instead of defaulting to cached', async () => { + const { handleSetNetworkCache } = await import('../../src/tools/network.js'); + const result = await handleSetNetworkCache({ behavior: 'disabled' }); + + expect(textOf(result)).toContain('behavior must be one of default, bypass'); + expect(setCacheBehavior).not.toHaveBeenCalled(); + }); + + it('rejects an unknown scope instead of silently using the tab', async () => { + const { handleSetNetworkCache } = await import('../../src/tools/network.js'); + const result = await handleSetNetworkCache({ behavior: 'bypass', scope: 'everything' }); + + expect(textOf(result)).toContain("scope must be 'tab' or 'global'"); + expect(setCacheBehavior).not.toHaveBeenCalled(); + }); + }); + describe('Schema Properties', () => { it('listNetworkRequestsTool should have filtering options', () => { const { properties } = listNetworkRequestsTool.inputSchema;