CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
web-fetch.test.ts438 linesDownload Raw Back to tools
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import { describe, it, expect, vi, beforeEach } from 'vitest';8import { WebFetchTool } from './web-fetch.js';9import type { Config } from '../config/config.js';10import { ApprovalMode } from '../config/config.js';11import { ToolConfirmationOutcome } from './tools.js';12import { ToolErrorType } from './tool-error.js';13import * as fetchUtils from '../utils/fetch.js';14 15// Mocks the underlying call BaseLlmClient.generateText makes; web-fetch's16// `runSideQuery` text-mode path lands on this mock.17const mockGenerateContent = vi.fn();18const mockGetBaseLlmClient = vi.fn(() => ({19  generateText: mockGenerateContent,20}));21 22vi.mock('../utils/fetch.js', async (importOriginal) => {23  const actual = await importOriginal<typeof fetchUtils>();24  return {25    ...actual,26    fetchWithTimeout: vi.fn(),27    isPrivateIp: vi.fn(),28  };29});30 31describe('WebFetchTool', () => {32  let mockConfig: Config;33 34  beforeEach(() => {35    vi.resetAllMocks();36    mockConfig = {37      getApprovalMode: vi.fn(),38      setApprovalMode: vi.fn(),39      getProxy: vi.fn(),40      getBaseLlmClient: mockGetBaseLlmClient,41      getFastModel: vi.fn(() => undefined),42      getSessionId: vi.fn(() => 'test-session-id'),43      getModel: vi.fn(() => 'qwen-coder'),44    } as unknown as Config;45  });46 47  describe('execute', () => {48    it('should throw validation error when url parameter is missing', async () => {49      const tool = new WebFetchTool(mockConfig);50      const params = { prompt: 'no url here' };51      /* @ts-expect-error - we are testing validation */52      expect(() => tool.build(params)).toThrow(53        "params must have required property 'url'",54      );55    });56 57    it.each(['HTTPS://example.com', 'Http://example.com'])(58      'should accept uppercase http url schemes: %s',59      (url) => {60        const tool = new WebFetchTool(mockConfig);61        expect(() =>62          tool.build({ url, prompt: 'summarize this' }),63        ).not.toThrow();64      },65    );66 67    it.each([68      [69        'ftp://example.com',70        "The 'url' must be a valid URL starting with http:// or https://.",71      ],72      [73        'http:example.com',74        "The 'url' must be a valid URL starting with http:// or https://.",75      ],76      [77        'http:/example.com',78        "The 'url' must be a valid URL starting with http:// or https://.",79      ],80      ['https://', "The 'url' is malformed and could not be parsed."],81      ['http://[::1', "The 'url' is malformed and could not be parsed."],82    ])(83      'should reject invalid or unsupported urls: %s',84      (url, expectedError) => {85        const tool = new WebFetchTool(mockConfig);86        expect(() => tool.build({ url, prompt: 'summarize this' })).toThrow(87          expectedError,88        );89      },90    );91 92    it.each([93      'https://user:secret@example.com/page',94      'http://user@example.com/page',95      'https://:secret@example.com/page',96      'https://%75ser@example.com/page',97    ])('should reject URLs containing credentials: %s', (url) => {98      const tool = new WebFetchTool(mockConfig);99      expect(() => tool.build({ url, prompt: 'summarize this' })).toThrow(100        "The 'url' must not include credentials.",101      );102    });103 104    it('should return WEB_FETCH_FALLBACK_FAILED on fetch failure', async () => {105      vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true);106      vi.spyOn(fetchUtils, 'fetchWithTimeout').mockRejectedValue(107        new Error('fetch failed'),108      );109      const tool = new WebFetchTool(mockConfig);110      const params = { url: 'https://private.ip', prompt: 'summarize this' };111      const invocation = tool.build(params);112      const result = await invocation.execute(new AbortController().signal);113      expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_FALLBACK_FAILED);114    });115 116    it('should return WEB_FETCH_FALLBACK_FAILED on API processing failure', async () => {117      vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false);118      vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({119        ok: true,120        headers: new Headers({ 'content-type': 'text/html' }),121        text: () => Promise.resolve('<html><body>Test content</body></html>'),122      } as Response);123      mockGenerateContent.mockRejectedValue(new Error('API error'));124      const tool = new WebFetchTool(mockConfig);125      const params = { url: 'https://public.ip', prompt: 'summarize this' };126      const invocation = tool.build(params);127      const result = await invocation.execute(new AbortController().signal);128      expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_FALLBACK_FAILED);129    });130  });131 132  describe('format parameter', () => {133    it('should default to auto format when not specified', async () => {134      const fetchSpy = vi135        .spyOn(fetchUtils, 'fetchWithTimeout')136        .mockResolvedValue({137          ok: true,138          headers: new Headers({ 'content-type': 'text/html' }),139          text: () => Promise.resolve('<html><body>Test content</body></html>'),140        } as Response);141 142      mockGenerateContent.mockResolvedValue({143        response: { text: () => 'Summary' },144      });145 146      const tool = new WebFetchTool(mockConfig);147      const params = { url: 'https://example.com', prompt: 'summarize' };148      const invocation = tool.build(params);149      await invocation.execute(new AbortController().signal);150 151      expect(fetchSpy).toHaveBeenCalledWith(152        'https://example.com',153        expect.any(Number),154        {155          Accept: 'text/markdown, text/html;q=0.9, text/plain;q=0.8, */*;q=0.1',156        },157      );158    });159 160    it('should prefer markdown when format is markdown', async () => {161      const fetchSpy = vi162        .spyOn(fetchUtils, 'fetchWithTimeout')163        .mockResolvedValue({164          ok: true,165          headers: new Headers({ 'content-type': 'text/markdown' }),166          text: () => Promise.resolve('# Test Content'),167        } as Response);168 169      mockGenerateContent.mockResolvedValue({170        response: { text: () => 'Summary' },171      });172 173      const tool = new WebFetchTool(mockConfig);174      const params = {175        url: 'https://example.com',176        prompt: 'summarize',177        format: 'markdown' as const,178      };179      const invocation = tool.build(params);180      await invocation.execute(new AbortController().signal);181 182      expect(fetchSpy).toHaveBeenCalledWith(183        'https://example.com',184        expect.any(Number),185        { Accept: 'text/markdown, */*;q=0.1' },186      );187    });188 189    it('should prefer HTML when format is html', async () => {190      const fetchSpy = vi191        .spyOn(fetchUtils, 'fetchWithTimeout')192        .mockResolvedValue({193          ok: true,194          headers: new Headers({ 'content-type': 'text/html' }),195          text: () => Promise.resolve('<html><body>Test content</body></html>'),196        } as Response);197 198      mockGenerateContent.mockResolvedValue({199        response: { text: () => 'Summary' },200      });201 202      const tool = new WebFetchTool(mockConfig);203      const params = {204        url: 'https://example.com',205        prompt: 'summarize',206        format: 'html' as const,207      };208      const invocation = tool.build(params);209      await invocation.execute(new AbortController().signal);210 211      expect(fetchSpy).toHaveBeenCalledWith(212        'https://example.com',213        expect.any(Number),214        { Accept: 'text/html, */*;q=0.1' },215      );216    });217 218    it('should prefer plain text when format is text', async () => {219      const fetchSpy = vi220        .spyOn(fetchUtils, 'fetchWithTimeout')221        .mockResolvedValue({222          ok: true,223          headers: new Headers({ 'content-type': 'text/plain' }),224          text: () => Promise.resolve('Plain text content'),225        } as Response);226 227      mockGenerateContent.mockResolvedValue({228        response: { text: () => 'Summary' },229      });230 231      const tool = new WebFetchTool(mockConfig);232      const params = {233        url: 'https://example.com',234        prompt: 'summarize',235        format: 'text' as const,236      };237      const invocation = tool.build(params);238      await invocation.execute(new AbortController().signal);239 240      expect(fetchSpy).toHaveBeenCalledWith(241        'https://example.com',242        expect.any(Number),243        { Accept: 'text/plain, */*;q=0.1' },244      );245    });246 247    it('should process JSON content returned by fallback content negotiation', async () => {248      let receivedContent = '';249      vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({250        ok: true,251        headers: new Headers({ 'content-type': 'application/json' }),252        text: () =>253          Promise.resolve(254            JSON.stringify({255              published_at: '2026-01-27T11:50:52Z',256              body: '<p>Release <b>notes</b></p>',257              desc: 'Use &amp; for ampersand',258            }),259          ),260      } as Response);261 262      mockGenerateContent.mockImplementation((options) => {263        receivedContent = options.contents[0].parts[0].text;264        return Promise.resolve({ text: 'Processed', usage: undefined });265      });266 267      const tool = new WebFetchTool(mockConfig);268      const params = {269        url: 'https://api.github.com/repos/openai/codex/releases/tags/rust-v0.92.0',270        prompt: 'report the published date',271      };272      const invocation = tool.build(params);273      await invocation.execute(new AbortController().signal);274 275      expect(receivedContent).toContain('published_at');276      expect(receivedContent).toContain('2026-01-27T11:50:52Z');277      expect(receivedContent).toContain('<p>Release <b>notes</b></p>');278      expect(receivedContent).toContain('Use &amp; for ampersand');279    });280 281    it('should include markdown content in prompt when server returns markdown', async () => {282      let receivedContent = '';283      vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({284        ok: true,285        headers: new Headers({286          'content-type': 'text/markdown; charset=utf-8',287        }),288        text: () =>289          Promise.resolve('# Hello World\n\nThis is markdown content.'),290      } as Response);291 292      mockGenerateContent.mockImplementation((options) => {293        receivedContent = options.contents[0].parts[0].text;294        return Promise.resolve({ text: 'Processed', usage: undefined });295      });296 297      const tool = new WebFetchTool(mockConfig);298      const params = { url: 'https://example.com', prompt: 'summarize' };299      const invocation = tool.build(params);300      await invocation.execute(new AbortController().signal);301 302      expect(receivedContent).toContain('# Hello World');303    });304 305    it('should include plain text content in prompt when server returns plain text', async () => {306      let receivedContent = '';307      vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({308        ok: true,309        headers: new Headers({ 'content-type': 'text/plain' }),310        text: () => Promise.resolve('Plain text content here'),311      } as Response);312 313      mockGenerateContent.mockImplementation((options) => {314        receivedContent = options.contents[0].parts[0].text;315        return Promise.resolve({ text: 'Processed', usage: undefined });316      });317 318      const tool = new WebFetchTool(mockConfig);319      const params = {320        url: 'https://example.com',321        prompt: 'summarize',322        format: 'text' as const,323      };324      const invocation = tool.build(params);325      await invocation.execute(new AbortController().signal);326 327      expect(receivedContent).toContain('Plain text content here');328    });329  });330 331  describe('getConfirmationDetails', () => {332    it('should return confirmation details with the correct prompt and urls', async () => {333      const tool = new WebFetchTool(mockConfig);334      const params = {335        url: 'https://example.com',336        prompt: 'summarize this page',337      };338      const invocation = tool.build(params);339      expect(await invocation.getDefaultPermission()).toBe('ask');340 341      const confirmationDetails = await invocation.getConfirmationDetails(342        new AbortController().signal,343      );344 345      expect(confirmationDetails).toEqual({346        type: 'info',347        title: 'Confirm Web Fetch',348        prompt:349          'Fetch content from https://example.com and process with: summarize this page',350        urls: ['https://example.com'],351        permissionRules: ['WebFetch(example.com)'],352        onConfirm: expect.any(Function),353      });354    });355 356    it('should return github urls as-is in confirmation details', async () => {357      const tool = new WebFetchTool(mockConfig);358      const params = {359        url: 'https://github.com/google/gemini-react/blob/main/README.md',360        prompt: 'summarize the README',361      };362      const invocation = tool.build(params);363      expect(await invocation.getDefaultPermission()).toBe('ask');364 365      const confirmationDetails = await invocation.getConfirmationDetails(366        new AbortController().signal,367      );368 369      expect(confirmationDetails).toEqual({370        type: 'info',371        title: 'Confirm Web Fetch',372        prompt:373          'Fetch content from https://github.com/google/gemini-react/blob/main/README.md and process with: summarize the README',374        urls: ['https://github.com/google/gemini-react/blob/main/README.md'],375        permissionRules: ['WebFetch(github.com)'],376        onConfirm: expect.any(Function),377      });378    });379 380    it('should return ask even if approval mode is AUTO_EDIT (approval mode handled by scheduler)', async () => {381      const tool = new WebFetchTool({382        ...mockConfig,383        getApprovalMode: () => ApprovalMode.AUTO_EDIT,384      } as unknown as Config);385      const params = {386        url: 'https://example.com',387        prompt: 'summarize this page',388      };389      const invocation = tool.build(params);390      expect(await invocation.getDefaultPermission()).toBe('ask');391 392      const confirmationDetails = await invocation.getConfirmationDetails(393        new AbortController().signal,394      );395 396      expect(confirmationDetails).toEqual({397        type: 'info',398        title: 'Confirm Web Fetch',399        prompt:400          'Fetch content from https://example.com and process with: summarize this page',401        urls: ['https://example.com'],402        permissionRules: ['WebFetch(example.com)'],403        onConfirm: expect.any(Function),404      });405    });406 407    it('should have onConfirm as a no-op (approval mode handled by scheduler)', async () => {408      const setApprovalMode = vi.fn();409      const testConfig = {410        ...mockConfig,411        setApprovalMode,412      } as unknown as Config;413      const tool = new WebFetchTool(testConfig);414      const params = {415        url: 'https://example.com',416        prompt: 'summarize this page',417      };418      const invocation = tool.build(params);419      const confirmationDetails = await invocation.getConfirmationDetails(420        new AbortController().signal,421      );422 423      if (424        confirmationDetails &&425        typeof confirmationDetails === 'object' &&426        'onConfirm' in confirmationDetails427      ) {428        await confirmationDetails.onConfirm(429          ToolConfirmationOutcome.ProceedAlways,430        );431      }432 433      // setApprovalMode should NOT be called — onConfirm is a no-op434      expect(setApprovalMode).not.toHaveBeenCalled();435    });436  });437});438 
basant307/AI_Governance_Project · CoolFace