/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { WebFetchTool } from './web-fetch.js'; import type { Config } from '../config/config.js'; import { ApprovalMode } from '../config/config.js'; import { ToolConfirmationOutcome } from './tools.js'; import { ToolErrorType } from './tool-error.js'; import * as fetchUtils from '../utils/fetch.js'; import { logWebFetchFallbackAttempt, WebFetchFallbackAttemptEvent, } from '../telemetry/index.js'; const mockGenerateContent = vi.fn(); const mockGetGeminiClient = vi.fn(() => ({ generateContent: mockGenerateContent, })); vi.mock('../telemetry/index.js', () => ({ logWebFetchFallbackAttempt: vi.fn(), WebFetchFallbackAttemptEvent: vi.fn(), })); vi.mock('../utils/fetch.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, fetchWithTimeout: vi.fn(), isPrivateIp: vi.fn(), }; }); describe('WebFetchTool', () => { let mockConfig: Config; beforeEach(() => { vi.resetAllMocks(); mockConfig = { getApprovalMode: vi.fn(), setApprovalMode: vi.fn(), getProxy: vi.fn(), getGeminiClient: mockGetGeminiClient, } as unknown as Config; }); describe('execute', () => { it('should return WEB_FETCH_NO_URL_IN_PROMPT when no URL is in the prompt for fallback', async () => { vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true); const tool = new WebFetchTool(mockConfig); const params = { prompt: 'no url here' }; expect(() => tool.build(params)).toThrow( "The 'prompt' must contain at least one valid URL (starting with http:// or https://).", ); }); it('should return WEB_FETCH_FALLBACK_FAILED on fallback fetch failure', async () => { vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true); vi.spyOn(fetchUtils, 'fetchWithTimeout').mockRejectedValue( new Error('fetch failed'), ); const tool = new WebFetchTool(mockConfig); const params = { prompt: 'fetch https://private.ip' }; const invocation = tool.build(params); const result = await invocation.execute(new AbortController().signal); expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_FALLBACK_FAILED); }); it('should return WEB_FETCH_PROCESSING_ERROR on general processing failure', async () => { vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false); mockGenerateContent.mockRejectedValue(new Error('API error')); const tool = new WebFetchTool(mockConfig); const params = { prompt: 'fetch https://public.ip' }; const invocation = tool.build(params); const result = await invocation.execute(new AbortController().signal); expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_PROCESSING_ERROR); }); it('should log telemetry when falling back due to private IP', async () => { vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true); // Mock fetchWithTimeout to succeed so fallback proceeds vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({ ok: true, text: () => Promise.resolve('some content'), } as Response); mockGenerateContent.mockResolvedValue({ candidates: [{ content: { parts: [{ text: 'fallback response' }] } }], }); const tool = new WebFetchTool(mockConfig); const params = { prompt: 'fetch https://private.ip' }; const invocation = tool.build(params); await invocation.execute(new AbortController().signal); expect(logWebFetchFallbackAttempt).toHaveBeenCalledWith( mockConfig, expect.any(WebFetchFallbackAttemptEvent), ); expect(WebFetchFallbackAttemptEvent).toHaveBeenCalledWith('private_ip'); }); it('should log telemetry when falling back due to primary fetch failure', async () => { vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false); // Mock primary fetch to return empty response, triggering fallback mockGenerateContent.mockResolvedValueOnce({ candidates: [], }); // Mock fetchWithTimeout to succeed so fallback proceeds vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({ ok: true, text: () => Promise.resolve('some content'), } as Response); // Mock fallback LLM call mockGenerateContent.mockResolvedValueOnce({ candidates: [{ content: { parts: [{ text: 'fallback response' }] } }], }); const tool = new WebFetchTool(mockConfig); const params = { prompt: 'fetch https://public.ip' }; const invocation = tool.build(params); await invocation.execute(new AbortController().signal); expect(logWebFetchFallbackAttempt).toHaveBeenCalledWith( mockConfig, expect.any(WebFetchFallbackAttemptEvent), ); expect(WebFetchFallbackAttemptEvent).toHaveBeenCalledWith( 'primary_failed', ); }); }); describe('shouldConfirmExecute', () => { it('should return confirmation details with the correct prompt and urls', async () => { const tool = new WebFetchTool(mockConfig); const params = { prompt: 'fetch https://example.com' }; const invocation = tool.build(params); const confirmationDetails = await invocation.shouldConfirmExecute( new AbortController().signal, ); expect(confirmationDetails).toEqual({ type: 'info', title: 'Confirm Web Fetch', prompt: 'fetch https://example.com', urls: ['https://example.com'], onConfirm: expect.any(Function), }); }); it('should convert github urls to raw format', async () => { const tool = new WebFetchTool(mockConfig); const params = { prompt: 'fetch https://github.com/google/gemini-react/blob/main/README.md', }; const invocation = tool.build(params); const confirmationDetails = await invocation.shouldConfirmExecute( new AbortController().signal, ); expect(confirmationDetails).toEqual({ type: 'info', title: 'Confirm Web Fetch', prompt: 'fetch https://github.com/google/gemini-react/blob/main/README.md', urls: [ 'https://raw.githubusercontent.com/google/gemini-react/main/README.md', ], onConfirm: expect.any(Function), }); }); it('should return false if approval mode is AUTO_EDIT', async () => { vi.spyOn(mockConfig, 'getApprovalMode').mockReturnValue( ApprovalMode.AUTO_EDIT, ); const tool = new WebFetchTool(mockConfig); const params = { prompt: 'fetch https://example.com' }; const invocation = tool.build(params); const confirmationDetails = await invocation.shouldConfirmExecute( new AbortController().signal, ); expect(confirmationDetails).toBe(false); }); it('should call setApprovalMode when onConfirm is called with ProceedAlways', async () => { const tool = new WebFetchTool(mockConfig); const params = { prompt: 'fetch https://example.com' }; const invocation = tool.build(params); const confirmationDetails = await invocation.shouldConfirmExecute( new AbortController().signal, ); if ( confirmationDetails && typeof confirmationDetails === 'object' && 'onConfirm' in confirmationDetails ) { await confirmationDetails.onConfirm( ToolConfirmationOutcome.ProceedAlways, ); } expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( ApprovalMode.AUTO_EDIT, ); }); }); });