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
JavaScript
/**
* 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
};
}
}