Skip to main content
Glama
scroll_by.ts18.5 kB
import { BrowserToolBase } from '../base.js'; import { ToolContext, ToolResponse, ToolMetadata, SessionConfig, createSuccessResponse } from '../../common/types.js'; /** * Tool for scrolling a container by a specific number of pixels */ export class ScrollByTool extends BrowserToolBase { static getMetadata(sessionConfig?: SessionConfig): ToolMetadata { return { name: "scroll_by", description: "Scroll a container (or page) by a specific number of pixels. Auto-detects scroll direction when only one is available. Essential for: testing sticky headers/footers, triggering infinite scroll, carousel navigation, precise scroll position testing. Use 'html' or 'body' for page scrolling. Positive pixels = down/right, negative = up/left. Outputs: ✓ success summary with axis position and percent of max scroll; ⚠️ boundary notice when movement is limited; ⚠️ ambiguous-direction guidance when both axes scroll; ⚠️ not-scrollable report with ancestor suggestions; 💡 follow-up tips matching the detected scenario.", inputSchema: { type: "object", properties: { selector: { type: "string", description: "CSS selector of scrollable container (use 'html' or 'body' for page scroll, e.g., 'testid:chat-container', '.scrollable-list', 'html')" }, pixels: { type: "number", description: "Number of pixels to scroll. Positive = down/right, negative = up/left. Example: 500, -200" }, direction: { type: "string", description: "Scroll direction: 'vertical' (default), 'horizontal', or 'auto' (detects available direction). Use 'auto' for smart detection.", enum: ["vertical", "horizontal", "auto"] } }, required: ["selector", "pixels"], }, }; } async execute(args: any, context: ToolContext): Promise<ToolResponse> { this.recordInteraction(); return this.safeExecute(context, async (page) => { const selector = this.normalizeSelector(args.selector); const pixels = args.pixels; const direction = args.direction || 'vertical'; // Default to vertical for backward compatibility // Check if scrolling the page (html/body) or a specific element const isPageScroll = selector === 'html' || selector === 'body'; if (isPageScroll) { // Scroll the page const scrollResult = await page.evaluate(({ scrollAmount, scrollDirection }) => { const maxVertical = document.documentElement.scrollHeight - window.innerHeight; const maxHorizontal = document.documentElement.scrollWidth - window.innerWidth; // Auto-detect direction if needed let actualDirection = scrollDirection; if (scrollDirection === 'auto') { const verticalScrollable = maxVertical > 0; const horizontalScrollable = maxHorizontal > 0; if (verticalScrollable && !horizontalScrollable) { actualDirection = 'vertical'; } else if (horizontalScrollable && !verticalScrollable) { actualDirection = 'horizontal'; } else if (verticalScrollable && horizontalScrollable) { // Both directions scrollable - need explicit direction return { error: 'ambiguous', maxVertical, maxHorizontal }; } else { // Neither direction scrollable return { error: 'not-scrollable', maxVertical: 0, maxHorizontal: 0 }; } } // Perform the scroll const isVertical = actualDirection === 'vertical'; const previousScroll = isVertical ? window.scrollY : window.scrollX; if (isVertical) { window.scrollBy(0, scrollAmount); } else { window.scrollBy(scrollAmount, 0); } const newScroll = isVertical ? window.scrollY : window.scrollX; const actualScrolled = newScroll - previousScroll; return { previous: previousScroll, new: newScroll, actualScrolled, maxScroll: isVertical ? maxVertical : maxHorizontal, direction: actualDirection, maxVertical, maxHorizontal }; }, { scrollAmount: pixels, scrollDirection: direction }); // Handle error cases if ('error' in scrollResult) { if (scrollResult.error === 'ambiguous') { return createSuccessResponse([ `⚠️ Page is scrollable in both directions`, `Vertical: ${scrollResult.maxVertical}px max scroll`, `Horizontal: ${scrollResult.maxHorizontal}px max scroll`, ``, `💡 Specify direction explicitly:`, ` scroll_by({ selector: "html", pixels: ${pixels}, direction: "vertical" })`, ` scroll_by({ selector: "html", pixels: ${pixels}, direction: "horizontal" })` ]); } else if (scrollResult.error === 'not-scrollable') { return createSuccessResponse([ `⚠️ Page is not scrollable in any direction`, `Content fits within viewport (no overflow)`, `Position: unchanged` ]); } } const isVertical = scrollResult.direction === 'vertical'; const directionWord = isVertical ? (pixels > 0 ? 'down' : 'up') : (pixels > 0 ? 'right' : 'left'); const axis = isVertical ? 'y' : 'x'; const messages = [ `✓ Scrolled page ${directionWord} ${Math.abs(pixels)}px (${scrollResult.direction})`, `Position: ${axis}=${scrollResult.new}px (was ${scrollResult.previous}px)` ]; // Add info if we hit the scroll boundary const hitBoundary = Math.abs(scrollResult.actualScrolled) < Math.abs(pixels); if (hitBoundary) { const boundary = pixels > 0 ? (isVertical ? 'bottom' : 'right') : (isVertical ? 'top' : 'left'); messages.push(`⚠️ Reached ${boundary} of page (max scroll: ${scrollResult.maxScroll}px)`); } // Contextual suggestions if (isVertical && pixels > 0 && !hitBoundary) { // Scrolling down on page - suggest sticky header testing messages.push(''); messages.push('💡 Common next step - Test sticky header/footer:'); messages.push(' measure_element({ selector: "header" }) - Check if position stays fixed'); } else if (hitBoundary && pixels > 0) { // Hit boundary - suggest checking for infinite scroll or lazy-loaded content messages.push(''); messages.push('💡 At page boundary - Check for dynamic content:'); messages.push(' element_visibility({ selector: "..." }) - Verify lazy-loaded elements appeared'); messages.push(' inspect_dom() - See if new content was added'); } return createSuccessResponse(messages); } else { // Scroll a specific element const locator = page.locator(selector); // Check if element exists const count = await locator.count(); if (count === 0) { return createSuccessResponse([ `✗ Element not found: ${args.selector}`, ``, `💡 Try:`, ` • Use 'html' or 'body' to scroll the page`, ` • Use inspect_dom() to find scrollable containers`, ` • Use get_test_ids() to see available test IDs` ]); } // Use standard element selection with error on multiple matches const { element } = await this.selectPreferredLocator(locator, { errorOnMultiple: true, originalSelector: args.selector, }); // Scroll the element and collect scrollable ancestor info const scrollResult = await element.evaluate( (el, { scrollAmount, scrollDirection }) => { const maxVertical = el.scrollHeight - el.clientHeight; const maxHorizontal = el.scrollWidth - el.clientWidth; const getClassName = (element: Element) => { const value = (element as any).className as unknown; if (typeof value === 'string') { return value; } if ( value && typeof (value as { baseVal?: unknown }).baseVal === 'string' ) { return (value as { baseVal: string }).baseVal; } return ''; }; // Auto-detect direction if needed let actualDirection = scrollDirection; if (scrollDirection === 'auto') { const verticalScrollable = maxVertical > 0; const horizontalScrollable = maxHorizontal > 0; if (verticalScrollable && !horizontalScrollable) { actualDirection = 'vertical'; } else if (horizontalScrollable && !verticalScrollable) { actualDirection = 'horizontal'; } else if (verticalScrollable && horizontalScrollable) { // Both directions scrollable - need explicit direction return { error: 'ambiguous', maxVertical, maxHorizontal, tagName: el.tagName ? el.tagName.toLowerCase() : 'element', testId: el.getAttribute('data-testid'), id: (el as any).id ?? null, className: getClassName(el) }; } else { // Neither direction scrollable - collect ancestors const scrollableAncestors: Array<{ tagName: string; testId: string | null; id: string | null; className: string; maxScrollVertical: number; maxScrollHorizontal: number; }> = []; let parent = el.parentElement; while (parent && scrollableAncestors.length < 3) { const maxParentVertical = parent.scrollHeight - parent.clientHeight; const maxParentHorizontal = parent.scrollWidth - parent.clientWidth; if (maxParentVertical > 0 || maxParentHorizontal > 0) { scrollableAncestors.push({ tagName: parent.tagName ? parent.tagName.toLowerCase() : 'element', testId: parent.getAttribute('data-testid'), id: (parent as any).id ?? null, className: getClassName(parent), maxScrollVertical: maxParentVertical, maxScrollHorizontal: maxParentHorizontal }); } parent = parent.parentElement; } return { error: 'not-scrollable', maxVertical: 0, maxHorizontal: 0, tagName: el.tagName ? el.tagName.toLowerCase() : 'element', testId: el.getAttribute('data-testid'), id: (el as any).id ?? null, className: getClassName(el), scrollableAncestors }; } } // Perform the scroll const isVertical = actualDirection === 'vertical'; const previousScroll = isVertical ? el.scrollTop : el.scrollLeft; if (isVertical) { el.scrollTop += scrollAmount; } else { el.scrollLeft += scrollAmount; } const newScroll = isVertical ? el.scrollTop : el.scrollLeft; const actualScrolled = newScroll - previousScroll; // Find scrollable ancestors (up to 3) const scrollableAncestors: Array<{ tagName: string; testId: string | null; id: string | null; className: string; maxScrollVertical: number; maxScrollHorizontal: number; }> = []; let parent = el.parentElement; while (parent && scrollableAncestors.length < 3) { const maxParentVertical = parent.scrollHeight - parent.clientHeight; const maxParentHorizontal = parent.scrollWidth - parent.clientWidth; if (maxParentVertical > 0 || maxParentHorizontal > 0) { scrollableAncestors.push({ tagName: parent.tagName ? parent.tagName.toLowerCase() : 'element', testId: parent.getAttribute('data-testid'), id: (parent as any).id ?? null, className: getClassName(parent), maxScrollVertical: maxParentVertical, maxScrollHorizontal: maxParentHorizontal }); } parent = parent.parentElement; } return { previous: previousScroll, new: newScroll, actualScrolled, maxScroll: isVertical ? maxVertical : maxHorizontal, direction: actualDirection, maxVertical, maxHorizontal, tagName: el.tagName ? el.tagName.toLowerCase() : 'element', testId: el.getAttribute('data-testid'), id: (el as any).id ?? null, className: getClassName(el), scrollableAncestors }; }, { scrollAmount: pixels, scrollDirection: direction } ); // Build element description let elementDesc = `<${scrollResult.tagName}`; if (scrollResult.testId) elementDesc += ` data-testid="${scrollResult.testId}"`; else if (scrollResult.id) elementDesc += ` id="${scrollResult.id}"`; else if (scrollResult.className) { const classes = scrollResult.className.split(' ').slice(0, 2).join(' '); if (classes) elementDesc += ` class="${classes}"`; } elementDesc += '>'; // Handle error cases if ('error' in scrollResult) { if (scrollResult.error === 'ambiguous') { return createSuccessResponse([ `⚠️ ${elementDesc} is scrollable in both directions`, `Vertical: ${scrollResult.maxVertical}px max scroll`, `Horizontal: ${scrollResult.maxHorizontal}px max scroll`, ``, `💡 Specify direction explicitly:`, ` scroll_by({ selector: "${args.selector}", pixels: ${pixels}, direction: "vertical" })`, ` scroll_by({ selector: "${args.selector}", pixels: ${pixels}, direction: "horizontal" })` ]); } else if (scrollResult.error === 'not-scrollable') { const messages = [ `⚠️ Container is not scrollable in any direction`, `${elementDesc} has max scroll: 0px vertical, 0px horizontal`, `Position: unchanged` ]; // Suggest scrollable ancestors if found if (scrollResult.scrollableAncestors.length > 0) { messages.push(''); messages.push('💡 Try these scrollable ancestors:'); scrollResult.scrollableAncestors.forEach((ancestor, i) => { // Build selector suggestion (prefer testid > id > class) let suggestion = ''; if (ancestor.testId) { suggestion = `testid:${ancestor.testId}`; } else if (ancestor.id) { suggestion = `#${ancestor.id}`; } else if (ancestor.className) { const firstClass = ancestor.className.split(' ')[0]; suggestion = `.${firstClass}`; } else { suggestion = ancestor.tagName; } const directions = []; if (ancestor.maxScrollVertical > 0) directions.push(`↕️ ${ancestor.maxScrollVertical}px vertical`); if (ancestor.maxScrollHorizontal > 0) directions.push(`↔️ ${ancestor.maxScrollHorizontal}px horizontal`); messages.push(` ${i + 1}. ${suggestion} (${directions.join(', ')})`); }); } else { messages.push(''); messages.push('💡 Suggestions:'); messages.push(` • Use 'html' or 'body' to scroll the page`); messages.push(` • Use inspect_dom() to find scrollable containers`); } return createSuccessResponse(messages); } } // Calculate percentage of max scroll const percentage = scrollResult.maxScroll > 0 ? Math.round((scrollResult.new / scrollResult.maxScroll) * 100) : 0; const isVertical = scrollResult.direction === 'vertical'; const directionWord = isVertical ? (pixels > 0 ? 'down' : 'up') : (pixels > 0 ? 'right' : 'left'); const axis = isVertical ? 'y' : 'x'; const messages = [ `✓ Scrolled ${elementDesc} ${directionWord} ${Math.abs(pixels)}px (${scrollResult.direction})`, `Position: ${axis}=${scrollResult.new}px (was ${scrollResult.previous}px) [${percentage}% of max: ${scrollResult.maxScroll}px]` ]; // Add info if we hit the scroll boundary const hitBoundary = Math.abs(scrollResult.actualScrolled) < Math.abs(pixels); if (hitBoundary) { const boundary = pixels > 0 ? (isVertical ? 'bottom' : 'right') : (isVertical ? 'top' : 'left'); messages.push(`⚠️ Reached ${boundary} of container`); // Suggest checking for lazy-loaded content at container boundary if (pixels > 0) { messages.push(''); messages.push('💡 At container boundary - Check for lazy-loaded content:'); messages.push(` inspect_dom({ selector: "${args.selector}" }) - See if new children appeared`); } } return createSuccessResponse(messages); } }); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/antonzherdev/mcp-web-inspector'

If you have feedback or need assistance with the MCP directory API, please join our Discord server