UNPKG

supapup

Version:

⚡ Lightning-fast MCP browser dev tool. Navigate → Get instant structured data. No screenshots needed! Puppeteer: 📸 → CSS selectors → JS eval. Supapup: semantic IDs ready to use. 10x faster, 90% fewer tokens.

452 lines (451 loc) 20.2 kB
/** * Viewport and device emulation tools for Supapup * Provides Chrome DevTools-like responsive design testing */ export class ViewportTools { page; presets = { 'iPhone 12': { name: 'iPhone 12', width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true }, 'iPhone SE': { name: 'iPhone SE', width: 375, height: 667, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, 'iPad': { name: 'iPad', width: 768, height: 1024, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, 'iPad Pro': { name: 'iPad Pro', width: 1024, height: 1366, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, 'Desktop HD': { name: 'Desktop HD', width: 1920, height: 1080, deviceScaleFactor: 1 }, 'Desktop': { name: 'Desktop', width: 1366, height: 768, deviceScaleFactor: 1 }, 'Laptop': { name: 'Laptop', width: 1440, height: 900, deviceScaleFactor: 1 } }; constructor(page) { this.page = page; } /** * Set viewport with advanced options like DevTools */ async setViewport(params) { try { let viewportSettings; // Use preset if provided if (params.preset && this.presets[params.preset]) { viewportSettings = { ...this.presets[params.preset] }; } else { viewportSettings = { width: params.width, height: params.height, deviceScaleFactor: params.deviceScaleFactor || 1, isMobile: params.isMobile || false, hasTouch: params.hasTouch || false, isLandscape: params.isLandscape || false }; } // Apply viewport settings await this.page.setViewport(viewportSettings); // For now, return simple success without CDP operations return { success: true, viewport: { width: viewportSettings.width, height: viewportSettings.height, deviceScaleFactor: viewportSettings.deviceScaleFactor, zoom: params.zoom || 100, isMobile: viewportSettings.isMobile, hasTouch: viewportSettings.hasTouch } }; } catch (error) { throw new Error(`Failed to set viewport: ${error}`); } } /** * Toggle device emulation mode (responsive design mode) */ async toggleDeviceMode(enabled) { try { const client = await this.page.target().createCDPSession(); if (enabled) { // Enable touch emulation await client.send('Emulation.setTouchEmulationEnabled', { enabled: true }); // Show device metrics overlay await client.send('Overlay.setShowViewportSizeOnResize', { show: true }); } else { // Disable device emulation await client.send('Emulation.clearDeviceMetricsOverride'); await client.send('Emulation.setTouchEmulationEnabled', { enabled: false }); } await client.detach(); return { success: true, deviceModeEnabled: enabled }; } catch (error) { throw new Error(`Failed to toggle device mode: ${error}`); } } /** * Get available viewport presets */ getPresets() { return Object.values(this.presets); } /** * Test responsive design at multiple breakpoints (original method) */ async testResponsiveBreakpoints(breakpoints) { const defaultBreakpoints = [320, 768, 1024, 1440, 1920]; const testPoints = breakpoints || defaultBreakpoints; const results = []; for (const width of testPoints) { await this.page.setViewport({ width, height: 800, deviceScaleFactor: 1 }); // Wait for any responsive changes await new Promise(resolve => setTimeout(resolve, 300)); // Check responsive issues const issues = await this.page.evaluate(() => { const problems = []; // Check horizontal scroll if (document.documentElement.scrollWidth > window.innerWidth) { problems.push('Horizontal scroll detected'); } // Check viewport meta tag const viewport = document.querySelector('meta[name="viewport"]'); if (!viewport) { problems.push('Missing viewport meta tag'); } // Check for elements overflowing const elements = document.querySelectorAll('*'); let overflowCount = 0; elements.forEach(el => { const rect = el.getBoundingClientRect(); if (rect.right > window.innerWidth || rect.left < 0) { overflowCount++; } }); if (overflowCount > 0) { problems.push(`${overflowCount} elements overflow viewport`); } return problems; }); results.push({ width, issues: issues.length > 0 ? issues : ['No issues detected'], timestamp: new Date() }); } return results; } /** * Emulate network conditions (for responsive + slow network testing) */ async setNetworkConditions(preset) { const client = await this.page.target().createCDPSession(); const conditions = { '3G': { offline: false, downloadThroughput: 1.6 * 1024 * 1024 / 8, uploadThroughput: 750 * 1024 / 8, latency: 300 }, '4G': { offline: false, downloadThroughput: 4 * 1024 * 1024 / 8, uploadThroughput: 3 * 1024 * 1024 / 8, latency: 20 }, 'offline': { offline: true, downloadThroughput: 0, uploadThroughput: 0, latency: 0 }, 'online': { offline: false, downloadThroughput: -1, uploadThroughput: -1, latency: 0 } }; await client.send('Network.emulateNetworkConditions', conditions[preset]); await client.detach(); return { success: true, networkCondition: preset }; } // Responsive section testing storage sectionsCache = []; sectionScreenshots = new Map(); /** * Test responsive design by analyzing page sections */ async testResponsive(url) { try { // Navigate to URL if provided if (url && url !== this.page.url()) { await this.page.goto(url, { waitUntil: 'networkidle2' }); } // Analyze page structure to identify sections const sections = await this.page.evaluate(() => { const sections = []; let sectionId = 1; // Common section selectors in order of priority const sectionSelectors = [ 'header, [role="banner"]', 'nav, [role="navigation"]', 'main, [role="main"], .main-content, #main', 'aside, [role="complementary"], .sidebar', 'footer, [role="contentinfo"]', 'section', 'article', '.container, .wrapper, .content' ]; const foundElements = new Set(); sectionSelectors.forEach(selector => { const elements = document.querySelectorAll(selector); elements.forEach((element) => { if (!foundElements.has(element)) { foundElements.add(element); const rect = element.getBoundingClientRect(); if (rect.height > 50 && rect.width > 100) { // Filter out tiny elements const tagName = element.tagName.toLowerCase(); const className = element.className || ''; const id = element.id || ''; let description = tagName; if (id) description += `#${id}`; if (className) description += `.${className.split(' ')[0]}`; // Try to get a meaningful description let sectionType = 'content'; if (tagName === 'header' || element.getAttribute('role') === 'banner') sectionType = 'header'; else if (tagName === 'nav' || element.getAttribute('role') === 'navigation') sectionType = 'navigation'; else if (tagName === 'main' || element.getAttribute('role') === 'main') sectionType = 'main content'; else if (tagName === 'aside' || element.getAttribute('role') === 'complementary') sectionType = 'sidebar'; else if (tagName === 'footer' || element.getAttribute('role') === 'contentinfo') sectionType = 'footer'; else if (tagName === 'section') sectionType = 'section'; else if (tagName === 'article') sectionType = 'article'; // Generate unique selector let selector = tagName; if (id) { selector = `#${id}`; } else if (className) { selector = `${tagName}.${className.split(' ')[0]}`; } else { // Use nth-child if no ID or class const parent = element.parentElement; if (parent) { const siblings = Array.from(parent.children).filter((child) => child.tagName === element.tagName); const index = siblings.indexOf(element) + 1; selector = `${parent.tagName.toLowerCase()} > ${tagName}:nth-child(${index})`; } } sections.push({ id: sectionId++, selector: selector, type: sectionType, description: description, bounds: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) } }); } } }); }); return sections.slice(0, 20); // Limit to 20 sections max }); // If no sections found, add a default "whole page" section if (sections.length === 0) { const pageMetrics = await this.page.evaluate(() => { return { width: Math.round(window.innerWidth), height: Math.round(document.documentElement.scrollHeight) }; }); sections.push({ id: 1, selector: 'body', type: 'whole page', description: 'body (full page)', bounds: { x: 0, y: 0, width: pageMetrics.width, height: pageMetrics.height } }); } // Store sections for later use this.sectionsCache = sections; // Take full page screenshot at standard desktop size await this.page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); const fullPageScreenshot = await this.page.screenshot({ fullPage: true, type: 'png', encoding: 'base64' }); return { url: this.page.url(), sectionsFound: sections.length, sections: sections.map(s => ({ id: s.id, type: s.type, description: s.description, bounds: s.bounds })), fullPagePreview: fullPageScreenshot, message: `Found ${sections.length} testable sections. Use test_responsive_section(sectionId) to capture responsive screenshots for each section.` }; } catch (error) { throw new Error(`Failed to analyze responsive sections: ${error.message}`); } } /** * Test specific section across multiple device sizes */ async testResponsiveSection(sectionId) { try { if (!this.sectionsCache.length) { throw new Error('No sections cached. Run test_responsive(url) first.'); } const section = this.sectionsCache.find(s => s.id === sectionId); if (!section) { throw new Error(`Section ${sectionId} not found. Available sections: ${this.sectionsCache.map(s => s.id).join(', ')}`); } // Define test devices const testDevices = [ { name: 'Large Desktop', preset: 'Desktop HD', width: 1920, height: 1080 }, { name: 'Medium Desktop', preset: 'Desktop', width: 1440, height: 900 }, { name: 'Tablet', preset: 'iPad', width: 768, height: 1024 }, { name: 'Large Phone', preset: 'iPhone 12', width: 390, height: 844 }, { name: 'Small Phone', preset: 'iPhone SE', width: 375, height: 667 } ]; const screenshots = []; for (const device of testDevices) { // Set viewport for device await this.page.setViewport({ width: device.width, height: device.height, deviceScaleFactor: 1 }); // Wait for responsive changes await new Promise(resolve => setTimeout(resolve, 500)); // Scroll to section and capture it const sectionScreenshot = await this.page.evaluate((sectionSelector) => { const element = document.querySelector(sectionSelector); if (!element) return null; // Scroll element into view element.scrollIntoView({ behavior: 'instant', block: 'start' }); // Get element bounds after scroll const rect = element.getBoundingClientRect(); return { x: Math.max(0, rect.x), y: Math.max(0, rect.y), width: Math.min(rect.width, window.innerWidth), height: Math.min(rect.height, window.innerHeight) }; }, section.selector); if (sectionScreenshot) { // Capture screenshot of just the section const screenshot = await this.page.screenshot({ type: 'png', encoding: 'base64', clip: { x: sectionScreenshot.x, y: sectionScreenshot.y, width: sectionScreenshot.width, height: sectionScreenshot.height } }); screenshots.push({ device: device.name, preset: device.preset, viewport: { width: device.width, height: device.height }, screenshot: screenshot, bounds: sectionScreenshot }); } } // Check if screenshots are too large for response const totalSize = screenshots.reduce((sum, s) => sum + (s.screenshot?.length || 0), 0); const maxResponseSize = 45000; // Conservative limit if (totalSize > maxResponseSize) { // Store screenshots and return paginated response const cacheKey = `section-${sectionId}-${Date.now()}`; this.sectionScreenshots.set(cacheKey, screenshots); return { sectionId, sectionType: section.type, sectionDescription: section.description, devicesTestedCount: screenshots.length, screenshotsCacheId: cacheKey, message: `Screenshots captured for ${screenshots.length} devices. Use test_responsive_section_get_screenshot(cacheId, deviceIndex) to retrieve individual screenshots.`, devices: screenshots.map((s, index) => ({ index, device: s.device, preset: s.preset, viewport: s.viewport })) }; } else { // Return all screenshots directly return { sectionId, sectionType: section.type, sectionDescription: section.description, devicesTestedCount: screenshots.length, screenshots: screenshots.map(s => ({ device: s.device, preset: s.preset, viewport: s.viewport, screenshot: s.screenshot })) }; } } catch (error) { throw new Error(`Failed to test section ${sectionId}: ${error.message}`); } } /** * Get cached screenshot for specific device */ async getResponsiveSectionScreenshot(cacheId, deviceIndex) { const screenshots = this.sectionScreenshots.get(cacheId); if (!screenshots) { throw new Error(`Screenshot cache ${cacheId} not found or expired.`); } if (deviceIndex < 0 || deviceIndex >= screenshots.length) { throw new Error(`Device index ${deviceIndex} out of range. Available: 0-${screenshots.length - 1}`); } const screenshot = screenshots[deviceIndex]; return { device: screenshot.device, preset: screenshot.preset, viewport: screenshot.viewport, screenshot: screenshot.screenshot, deviceIndex, totalDevices: screenshots.length }; } }