/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import assert from 'node:assert'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import {afterEach, describe, it} from 'node:test'; import {pathToFileURL} from 'node:url'; import sinon from 'sinon'; import {NetworkFormatter} from '../src/formatters/NetworkFormatter.js'; import {TextSnapshot} from '../src/TextSnapshot.js'; import type {HTTPResponse} from '../src/third_party/index.js'; import type {TraceResult} from '../src/trace-processing/parse.js'; import {getMockRequest, html, withMcpContext} from './utils.js'; describe('McpContext', () => { afterEach(() => { sinon.restore(); }); it('list pages', async () => { await withMcpContext(async (_response, context) => { const page = context.getSelectedMcpPage(); await page.pptrPage.setContent( html` `, ); page.textSnapshot = await TextSnapshot.create(page); assert.ok(await page.getElementByUid('1_1')); page.textSnapshot = await TextSnapshot.create(page); await page.getElementByUid('1_1'); }); }); it('can store and retrieve the latest performance trace', async () => { await withMcpContext(async (_response, context) => { const fakeTrace1 = {} as unknown as TraceResult; const fakeTrace2 = {} as unknown as TraceResult; context.storeTraceRecording(fakeTrace1); context.storeTraceRecording(fakeTrace2); assert.deepEqual(context.recordedTraces(), [fakeTrace2]); }); }); it('should update default timeout when cpu throttling changes', async () => { await withMcpContext(async (_response, context) => { const page = await context.newPage(); const timeoutBefore = page.pptrPage.getDefaultTimeout(); await context.getSelectedMcpPage().emulate({cpuThrottlingRate: 2}); const timeoutAfter = page.pptrPage.getDefaultTimeout(); assert(timeoutBefore < timeoutAfter, 'Timeout was less then expected'); }); }); it('should update default timeout when network conditions changes', async () => { await withMcpContext(async (_response, context) => { const page = await context.newPage(); const timeoutBefore = page.pptrPage.getDefaultNavigationTimeout(); await context .getSelectedMcpPage() .emulate({networkConditions: 'Slow 3G'}); const timeoutAfter = page.pptrPage.getDefaultNavigationTimeout(); assert(timeoutBefore < timeoutAfter, 'Timeout was less then expected'); }); }); it('should call waitForEventsAfterAction with correct multipliers', async () => { await withMcpContext(async (_response, context) => { const page = await context.newPage(); await context.getSelectedMcpPage().emulate({ cpuThrottlingRate: 2, networkConditions: 'Slow 3G', }); const stub = sinon.spy(page, 'createWaitForHelper'); await page.waitForEventsAfterAction(async () => { // trigger the waiting only }); sinon.assert.calledWithExactly(stub, 2, 10); }); }); it('should should detect open DevTools pages', async () => { await withMcpContext( async (_response, context) => { const page = await context.newPage(); await context.createPagesSnapshot(); assert.ok(page.devToolsPage); // A devtools page is tracked but excluded from the listing, so its id // must not resolve through `getPageById()` (which backs `select_page` // and every other pageId tool). A second snapshot guarantees the // devtools page is enrolled. await context.createPagesSnapshot(); const listed = context.getPages(); assert.ok( listed.every( mcpPage => !mcpPage.pptrPage.url().startsWith('devtools://'), ), 'listing should exclude devtools pages', ); const listedIds = new Set(listed.map(mcpPage => mcpPage.id)); for (let id = 1; id < 30; id++) { if (listedIds.has(id)) { continue; } assert.throws(() => context.getPageById(id), /No page found/); } }, { autoOpenDevTools: true, }, ); }); it('resolves uid from a non-selected page snapshot', async () => { await withMcpContext(async (_response, context) => { // Page 1: set content and snapshot const page1 = context.getSelectedMcpPage(); await page1.pptrPage.setContent(html``); page1.textSnapshot = await TextSnapshot.create(page1, { verbose: false, }); // Capture a uid from page1's snapshot (snapshotId=1, button is node 1) const page1Uid = '1_1'; const page1Node = page1.getAXNodeByUid(page1Uid); assert.ok(page1Node, 'uid should resolve from page1 snapshot'); // Page 2: new page, set content, snapshot const page2 = await context.newPage(); context.selectPage(page2); await page2.pptrPage.setContent(html``); page2.textSnapshot = await TextSnapshot.create(page2, { verbose: false, }); // Page 2 is now selected. Page 1's uid should still resolve. const node = page1.getAXNodeByUid(page1Uid); assert.ok(node, 'page1 uid should still resolve after page2 snapshot'); assert.strictEqual(node?.name, 'Page1 Button'); // The element should also be retrievable when the target page is provided. const element = await page1.getElementByUid(page1Uid); assert.ok(element, 'should get element handle from page1 snapshot uid'); }); }); it('reports the fallback when the selected page is closed', async () => { await withMcpContext(async (_response, context) => { const page = await context.newPage(); assert.ok(context.isPageSelected(page)); await page.pptrPage.close(); await context.createPagesSnapshot(); const [firstPage] = context.getPages(); assert.ok(firstPage); assert.ok(context.isPageSelected(firstPage)); const fallback = context.getSelectedPageFallback(); assert.ok(fallback, 'fallback should be reported'); assert.strictEqual(fallback.wasClosed, true); }); }); it('clears the fallback on the next snapshot with a valid selection', async () => { await withMcpContext(async (_response, context) => { const page = await context.newPage(); await page.pptrPage.close(); await context.createPagesSnapshot(); assert.ok(context.getSelectedPageFallback()); // A later snapshot keeps a valid selection (e.g. the one taken before the // next response, or after an explicit select), so the note is not repeated. await context.createPagesSnapshot(); assert.strictEqual(context.getSelectedPageFallback(), undefined); }); }); it('does not report a fallback for a regular selection', async () => { await withMcpContext(async (_response, context) => { await context.newPage(); await context.createPagesSnapshot(); assert.strictEqual(context.getSelectedPageFallback(), undefined); }); }); it('keeps a still-open selected page that is missing from the list', async () => { await withMcpContext(async (_response, context) => { const page = await context.newPage(); assert.ok(context.isPageSelected(page)); // A live page that is temporarily missing from the pages list must keep // its selection — only a genuinely closed page is replaced. const pages = await context.browser.pages(); const stub = sinon .stub(context.browser, 'pages') .resolves(pages.filter(otherPage => otherPage !== page.pptrPage)); try { await context.createPagesSnapshot(); } finally { stub.restore(); } assert.ok( context.isPageSelected(page), 'a still-open page should keep its selection', ); assert.strictEqual(context.getSelectedPageFallback(), undefined); }); }); it('should include network requests in structured content', async t => { await withMcpContext(async (response, context) => { const mockRequest = getMockRequest({ url: 'http://example.com/api', stableId: 123, }); sinon .stub(context.getSelectedMcpPage(), 'getNetworkRequests') .returns([mockRequest]); sinon .stub(context.getSelectedMcpPage(), 'getNetworkRequestStableId') .returns(123); response.setIncludeNetworkRequests(true); const result = await response.handle('test', context); t.assert.snapshot(JSON.stringify(result.structuredContent, null, 2)); }); }); it('should include detailed network request in structured content', async t => { await withMcpContext(async (response, context) => { const mockRequest = getMockRequest({ url: 'http://example.com/detail', stableId: 456, }); sinon .stub(context.getSelectedMcpPage(), 'getNetworkRequestById') .returns(mockRequest); sinon .stub(context.getSelectedMcpPage(), 'getNetworkRequestStableId') .returns(456); response.attachNetworkRequest(456); const result = await response.handle('test', context); t.assert.snapshot(JSON.stringify(result.structuredContent, null, 2)); }); }); it('should include file paths in structured content when saving to file', async t => { await withMcpContext(async (response, context) => { const mockRequest = getMockRequest({ url: 'http://example.com/file-save', stableId: 789, hasPostData: true, postData: 'some detailed data', response: { status: () => 200, headers: () => ({'content-type': 'text/plain'}), buffer: async () => Buffer.from('some response data'), } as unknown as HTTPResponse, }); sinon .stub(context.getSelectedMcpPage(), 'getNetworkRequestById') .returns(mockRequest); sinon .stub(context.getSelectedMcpPage(), 'getNetworkRequestStableId') .returns(789); // Use os.tmpdir() so validatePath passes on all platforms (macOS tmpdir // is /var/folders/..., not /tmp, so hardcoded /tmp paths are rejected). const reqFilePath = path.join(os.tmpdir(), 'req.txt'); const resFilePath = path.join(os.tmpdir(), 'res.txt'); // We stub NetworkFormatter.from to avoid actual file system writes and verify arguments const fromStub = sinon .stub(NetworkFormatter, 'from') .callsFake(async (_req, opts) => { // Verify we received the platform-correct file paths assert.strictEqual(opts?.requestFilePath, reqFilePath); assert.strictEqual(opts?.responseFilePath, resFilePath); // Return fixed strings in toJSONDetailed so the snapshot is stable // across platforms (os.tmpdir() differs on macOS vs Linux/Windows). return { toStringDetailed: () => 'Detailed string', toJSONDetailed: () => ({ requestBody: '/tmp/req.txt', responseBody: '/tmp/res.txt', }), } as unknown as NetworkFormatter; }); response.attachNetworkRequest(789, { requestFilePath: reqFilePath, responseFilePath: resFilePath, }); const result = await response.handle('test', context); t.assert.snapshot(JSON.stringify(result.structuredContent, null, 2)); fromStub.restore(); }); }); it('can store and retrieve roots', async () => { await withMcpContext(async (_response, context) => { const roots = [{uri: 'file:///test', name: 'test'}]; context.setRoots(roots); const actualRoots = context.roots(); assert.ok( actualRoots?.some(r => r.name === 'test'), 'Should contain the set root', ); assert.ok( actualRoots?.some(r => r.name === 'temp'), 'Should contain the temp root', ); }); }); it('validatePath allows paths within roots', async () => { await withMcpContext(async (_response, context) => { const workspacePath = path.resolve(os.homedir(), 'workspace-test'); await fs.mkdir(workspacePath, {recursive: true}); try { const roots = [ {uri: pathToFileURL(workspacePath).href, name: 'workspace'}, ]; context.setRoots(roots); // Valid path within root await context.validatePath(path.join(workspacePath, 'test.txt')); await context.validatePath(workspacePath); // Invalid path outside root and outside temp dir const outsidePath = path.resolve(os.homedir(), 'outside-test.txt'); await assert.rejects( context.validatePath(outsidePath), /Access denied/, ); } finally { await fs.rm(workspacePath, {recursive: true, force: true}); } }); }); it('validatePath allows non-existent nested paths within roots', async () => { await withMcpContext(async (_response, context) => { const workspacePath = path.resolve(os.homedir(), 'workspace-test-nested'); await fs.mkdir(workspacePath, {recursive: true}); try { const roots = [ {uri: pathToFileURL(workspacePath).href, name: 'workspace'}, ]; context.setRoots(roots); // Valid path within root with non-existent intermediate directories await context.validatePath( path.join(workspacePath, 'dir1', 'dir2', 'test.txt'), ); } finally { await fs.rm(workspacePath, {recursive: true, force: true}); } }); }); it('validatePath allows all paths if roots are undefined and allowUnrestrictedPaths is set', async () => { await withMcpContext( async (_response, context) => { context.setRoots(undefined); await context.validatePath(path.resolve(os.homedir(), 'anywhere.txt')); }, {allowUnrestrictedPaths: true}, ); }); it('validatePath denies paths outside tmpdir if roots are undefined and allowUnrestrictedPaths is not set', async () => { await withMcpContext(async (_response, context) => { // setRoots() never called — simulates a client that skips roots capability. const outsidePath = path.resolve(os.homedir(), 'anywhere.txt'); await assert.rejects(context.validatePath(outsidePath), /Access denied/); // Temp dir must still be reachable. await context.validatePath(path.join(os.tmpdir(), 'test.txt')); }); }); it('validatePath denies paths outside os.tmpdir() if roots list is empty', async () => { await withMcpContext(async (_response, context) => { context.setRoots([]); // Should allow temp dir await context.validatePath(path.join(os.tmpdir(), 'test.txt')); // Should deny outside temp dir await assert.rejects( context.validatePath(path.resolve(os.homedir(), 'anywhere.txt')), /Access denied/, ); }); }); describe('loadResource', () => { describe('file protocol', () => { it('calls validatePath', async () => { await withMcpContext(async (_response, context) => { const validatePathSpy = sinon.spy(context, 'validatePath'); const testFilePath = path.join(os.tmpdir(), 'load-resource-test.txt'); await fs.writeFile(testFilePath, 'test content'); try { const url = pathToFileURL(testFilePath).href; const content = await context.loadResource(url); assert.strictEqual(content, 'test content'); sinon.assert.calledWith(validatePathSpy, testFilePath); } finally { await fs.rm(testFilePath, {force: true}); } }); }); it('is not blocked by allowlist', async () => { const testFilePath = path.join( os.tmpdir(), 'load-resource-test-allow.txt', ); await fs.writeFile(testFilePath, 'test content'); try { await withMcpContext( async (_response, context) => { const url = pathToFileURL(testFilePath).href; const content = await context.loadResource(url); assert.strictEqual(content, 'test content'); }, { allowedUrlPattern: ['https://example.com/allowed*'], }, ); } finally { await fs.rm(testFilePath, {force: true}); } }); }); describe('https protocol', () => { it('respects blocklist by throwing if blocked', async () => { await withMcpContext( async (_response, context) => { await assert.rejects(() => context.loadResource('https://example.com/blocked'), ); }, { blockedUrlPattern: ['https://example.com/blocked*'], }, ); }); it('respects blocklist by not throwing if not blocked', async () => { await withMcpContext( async (_response, context) => { sinon.stub(globalThis, 'fetch').resolves({ ok: true, text: async () => 'mock data', } as Response); const content = await context.loadResource( 'https://example.com/allowed', ); assert.strictEqual(content, 'mock data'); }, { blockedUrlPattern: ['https://example.com/blocked*'], }, ); }); it('respects allowlist by throwing if not allowed', async () => { await withMcpContext( async (_response, context) => { await assert.rejects(() => context.loadResource('https://example.com/blocked'), ); }, { allowedUrlPattern: ['https://example.com/allowed*'], }, ); }); it('respects allowlist by not throwing if allowed', async () => { await withMcpContext( async (_response, context) => { sinon.stub(globalThis, 'fetch').resolves({ ok: true, text: async () => 'mock data', } as Response); const content = await context.loadResource( 'https://example.com/allowed', ); assert.strictEqual(content, 'mock data'); }, { allowedUrlPattern: ['https://example.com/allowed*'], }, ); }); }); }); });