/** * Agentic QE v3 - Responsive Testing Service * Implements responsive design and cross-device testing */ import { v4 as uuidv4 } from 'uuid'; import { Result, ok, err } from '../../../shared/types/index.js'; import { MemoryBackend } from '../../../kernel/interfaces.js'; import { Viewport, Screenshot, VisualDiff, } from '../interfaces.js'; import { FilePath } from '../../../shared/value-objects/index.js'; /** * Responsive testing interfaces */ export interface ResponsiveTestResult { readonly url: string; readonly timestamp: Date; readonly viewports: ViewportResult[]; readonly breakpointIssues: BreakpointIssue[]; readonly layoutScore: number; readonly recommendations: string[]; } export interface ViewportResult { readonly viewport: Viewport; readonly screenshot: Screenshot; readonly layoutIssues: LayoutIssue[]; readonly renderTime: number; readonly passed: boolean; } export interface LayoutIssue { readonly type: LayoutIssueType; readonly severity: 'critical' | 'warning' | 'info'; readonly element: string; readonly description: string; readonly viewport: Viewport; } export type LayoutIssueType = | 'horizontal-overflow' | 'text-overflow' | 'overlapping-elements' | 'hidden-content' | 'touch-target-size' | 'font-size-too-small' | 'image-not-responsive'; export interface BreakpointIssue { readonly breakpoint: number; readonly description: string; readonly affectedElements: string[]; readonly suggestion: string; } export interface ResponsiveTestConfig { viewports: Viewport[]; breakpoints: number[]; checkTouchTargets: boolean; minFontSize: number; minTouchTargetSize: number; /** * Enable simulation mode for testing purposes only. * When true, returns deterministic stub data for layout issues. * When false (default), returns empty results (no browser available). */ simulationMode: boolean; } /** * Predefined device viewports */ export const DEVICE_VIEWPORTS: Record = { 'iphone-se': { width: 375, height: 667, deviceScaleFactor: 2, isMobile: true, hasTouch: true, }, 'iphone-14': { width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true, }, 'iphone-14-pro-max': { width: 430, height: 932, deviceScaleFactor: 3, isMobile: true, hasTouch: true, }, 'ipad-mini': { width: 768, height: 1024, deviceScaleFactor: 2, isMobile: true, hasTouch: true, }, 'ipad-pro': { width: 1024, height: 1366, deviceScaleFactor: 2, isMobile: true, hasTouch: true, }, 'pixel-7': { width: 412, height: 915, deviceScaleFactor: 2.625, isMobile: true, hasTouch: true, }, 'galaxy-s21': { width: 360, height: 800, deviceScaleFactor: 3, isMobile: true, hasTouch: true, }, 'desktop-hd': { width: 1920, height: 1080, deviceScaleFactor: 1, isMobile: false, hasTouch: false, }, 'desktop-2k': { width: 2560, height: 1440, deviceScaleFactor: 1, isMobile: false, hasTouch: false, }, 'laptop': { width: 1366, height: 768, deviceScaleFactor: 1, isMobile: false, hasTouch: false, }, }; const DEFAULT_CONFIG: ResponsiveTestConfig = { viewports: [ DEVICE_VIEWPORTS['iphone-14'], DEVICE_VIEWPORTS['ipad-mini'], DEVICE_VIEWPORTS['laptop'], DEVICE_VIEWPORTS['desktop-hd'], ], breakpoints: [320, 480, 768, 1024, 1280, 1920], checkTouchTargets: true, minFontSize: 12, minTouchTargetSize: 44, simulationMode: false, }; /** * Responsive Tester Service */ export interface IResponsiveTestingService { /** * Test responsive design across viewports */ testResponsiveness(url: string, options?: Partial): Promise>; /** * Compare layouts between viewports */ compareViewports(url: string, viewport1: Viewport, viewport2: Viewport): Promise>; /** * Test specific breakpoint */ testBreakpoint(url: string, breakpoint: number): Promise>; /** * Get recommended breakpoints based on content */ analyzeBreakpoints(url: string): Promise>; } export interface BreakpointAnalysis { readonly currentBreakpoints: number[]; readonly suggestedBreakpoints: number[]; readonly contentBreaks: ContentBreak[]; readonly coverageScore: number; } export interface ContentBreak { readonly width: number; readonly reason: string; readonly affectedElements: string[]; } /** * Responsive Testing Service Implementation */ export class ResponsiveTesterService implements IResponsiveTestingService { private readonly config: ResponsiveTestConfig; constructor( private readonly memory: MemoryBackend, config: Partial = {} ) { this.config = { ...DEFAULT_CONFIG, ...config }; } /** * Test responsive design across viewports */ async testResponsiveness( url: string, options?: Partial ): Promise> { try { const testConfig = { ...this.config, ...options }; const viewportResults: ViewportResult[] = []; const allIssues: LayoutIssue[] = []; // Test each viewport for (const viewport of testConfig.viewports) { const result = await this.testViewport(url, viewport, testConfig); viewportResults.push(result); allIssues.push(...result.layoutIssues); } // Detect breakpoint issues const breakpointIssues = this.detectBreakpointIssues( url, viewportResults, testConfig ); // Calculate layout score const layoutScore = this.calculateLayoutScore(viewportResults, allIssues); // Generate recommendations const recommendations = this.generateRecommendations(allIssues, breakpointIssues); const result: ResponsiveTestResult = { url, timestamp: new Date(), viewports: viewportResults, breakpointIssues, layoutScore, recommendations, }; // Store result await this.storeResult(result); return ok(result); } catch (error) { return err(error instanceof Error ? error : new Error(String(error))); } } /** * Compare layouts between viewports */ async compareViewports( url: string, viewport1: Viewport, viewport2: Viewport ): Promise> { try { // Capture screenshots for both viewports const screenshot1 = this.createScreenshot(url, viewport1); const screenshot2 = this.createScreenshot(url, viewport2); // Calculate structural diff (not pixel diff) const diff = this.calculateStructuralDiff(screenshot1, screenshot2); return ok(diff); } catch (error) { return err(error instanceof Error ? error : new Error(String(error))); } } /** * Test specific breakpoint */ async testBreakpoint( url: string, breakpoint: number ): Promise> { try { const viewport: Viewport = { width: breakpoint, height: 800, deviceScaleFactor: 1, isMobile: breakpoint < 768, hasTouch: breakpoint < 1024, }; const result = await this.testViewport(url, viewport, this.config); return ok(result); } catch (error) { return err(error instanceof Error ? error : new Error(String(error))); } } /** * Analyze breakpoints based on content */ async analyzeBreakpoints(url: string): Promise> { try { // Test across a range of widths to find content breaks const contentBreaks = this.findContentBreaks(url); // Compare with current breakpoints const currentBreakpoints = this.config.breakpoints; const suggestedBreakpoints = this.suggestBreakpoints(contentBreaks); // Calculate coverage score const coverageScore = this.calculateBreakpointCoverage( currentBreakpoints, contentBreaks ); return ok({ currentBreakpoints, suggestedBreakpoints, contentBreaks, coverageScore, }); } catch (error) { return err(error instanceof Error ? error : new Error(String(error))); } } // ============================================================================ // Private Helper Methods // ============================================================================ private async testViewport( url: string, viewport: Viewport, config: ResponsiveTestConfig ): Promise { const screenshot = this.createScreenshot(url, viewport); const layoutIssues = this.detectLayoutIssues(url, viewport, config); // Deterministic render time based on URL hash and viewport const urlHash = this.hashUrl(url); const hashNum = parseInt(urlHash, 36); const renderTime = 200 + ((hashNum + viewport.width) % 1000); const passed = layoutIssues.filter((i) => i.severity === 'critical').length === 0; return { viewport, screenshot, layoutIssues, renderTime, passed, }; } private createScreenshot(url: string, viewport: Viewport): Screenshot { // Deterministic load time based on URL hash and viewport const urlHash = this.hashUrl(url); const hashNum = parseInt(urlHash, 36); const loadTime = 500 + ((hashNum + viewport.width + viewport.height) % 2000); return { id: uuidv4(), url, viewport, timestamp: new Date(), path: FilePath.create(`.visual-tests/responsive/${viewport.width}x${viewport.height}.png`), metadata: { browser: 'chromium', os: process.platform, fullPage: false, loadTime, }, }; } private detectLayoutIssues( url: string, viewport: Viewport, config: ResponsiveTestConfig ): LayoutIssue[] { // Simulation mode: use deterministic results based on URL hash and viewport if (config.simulationMode) { return this.detectLayoutIssuesSimulation(url, viewport, config); } // Production mode: perform heuristic-based responsive analysis // without browser automation (static analysis based on URL patterns and viewport) return this.detectLayoutIssuesHeuristic(url, viewport, config); } /** * Heuristic-based responsive layout issue detection for production mode. * Analyzes URL patterns and viewport characteristics to identify likely issues. */ private detectLayoutIssuesHeuristic( url: string, viewport: Viewport, config: ResponsiveTestConfig ): LayoutIssue[] { const issues: LayoutIssue[] = []; const urlLower = url.toLowerCase(); // === Check CSS breakpoint coverage === // Identify pages that commonly have breakpoint issues const hasResponsivePatterns = this.hasResponsivePatterns(urlLower); // Check for horizontal overflow risk at small viewports if (viewport.width < 768) { // Pages with tables, wide images, or fixed-width elements if (this.hasWideContentPatterns(urlLower)) { issues.push({ type: 'horizontal-overflow', severity: 'critical', element: '.wide-content, table, pre', description: 'Wide content elements may cause horizontal overflow on mobile', viewport, }); } // Pages without responsive patterns if (!hasResponsivePatterns) { issues.push({ type: 'horizontal-overflow', severity: 'warning', element: '.container', description: 'Page may lack responsive CSS breakpoints for mobile', viewport, }); } } // === Check viewport meta tag issues === // Very small widths without proper viewport handling if (viewport.width <= 320) { issues.push({ type: 'text-overflow', severity: 'warning', element: 'body', description: 'Verify viewport meta tag is set correctly for very small screens', viewport, }); } // === Check touch target sizes === if (config.checkTouchTargets && viewport.hasTouch) { // Interactive pages need proper touch targets if (this.hasInteractiveElements(urlLower)) { issues.push({ type: 'touch-target-size', severity: 'warning', element: 'button, a, input', description: `Touch targets should be at least ${config.minTouchTargetSize}px for accessibility`, viewport, }); } // Navigation links on mobile if (viewport.isMobile && this.hasNavigationElements(urlLower)) { issues.push({ type: 'touch-target-size', severity: 'info', element: 'nav a', description: 'Navigation links may be too small for touch on mobile devices', viewport, }); } } // === Check font size readability === if (viewport.isMobile && config.minFontSize > 0) { // Forms and data-heavy pages often have small text if (this.hasDataDensePatterns(urlLower)) { issues.push({ type: 'font-size-too-small', severity: 'warning', element: '.data, .table, .form-label', description: `Font size may be below ${config.minFontSize}px on mobile`, viewport, }); } } // === Check image responsiveness === if (this.hasImagePatterns(urlLower)) { // Large viewports should use higher resolution images if (viewport.deviceScaleFactor > 1) { issues.push({ type: 'image-not-responsive', severity: 'info', element: 'img', description: 'Verify images use srcset for high-DPI displays', viewport, }); } // Small viewports should use appropriately sized images if (viewport.width < 480) { issues.push({ type: 'image-not-responsive', severity: 'info', element: 'img', description: 'Verify images are resized for mobile to reduce bandwidth', viewport, }); } } // === Check media query coverage === // Analyze standard breakpoint coverage const nearestBreakpoint = this.findNearestBreakpoint(viewport.width, config.breakpoints); if (nearestBreakpoint.gap > 200) { issues.push({ type: 'hidden-content', severity: 'info', element: 'body', description: `Viewport ${viewport.width}px is ${nearestBreakpoint.gap}px from nearest breakpoint (${nearestBreakpoint.value}px)`, viewport, }); } // === Check element overlap risks === if (this.hasOverlapRiskPatterns(urlLower) && viewport.width < 1024) { issues.push({ type: 'overlapping-elements', severity: 'warning', element: '.positioned, .absolute, .fixed', description: 'Positioned elements may overlap on smaller screens', viewport, }); } return issues; } // URL pattern detection helpers for responsive analysis private hasResponsivePatterns(url: string): boolean { // URLs that typically have responsive design return url.includes('mobile') || url.includes('responsive') || url.includes('bootstrap') || url.includes('tailwind') || url.includes('material') || url.includes('foundation'); } private hasWideContentPatterns(url: string): boolean { return url.includes('table') || url.includes('data') || url.includes('report') || url.includes('code') || url.includes('pre') || url.includes('spreadsheet'); } private hasInteractiveElements(url: string): boolean { return url.includes('form') || url.includes('button') || url.includes('input') || url.includes('select') || url.includes('click') || url.includes('action'); } private hasNavigationElements(url: string): boolean { return url.includes('nav') || url.includes('menu') || url.includes('header') || url.includes('sidebar') || url.includes('footer') || url.includes('breadcrumb'); } private hasDataDensePatterns(url: string): boolean { return url.includes('dashboard') || url.includes('analytics') || url.includes('report') || url.includes('table') || url.includes('stats') || url.includes('metrics'); } private hasImagePatterns(url: string): boolean { return url.includes('gallery') || url.includes('photo') || url.includes('image') || url.includes('media') || url.includes('portfolio') || url.includes('hero'); } private hasOverlapRiskPatterns(url: string): boolean { return url.includes('modal') || url.includes('overlay') || url.includes('tooltip') || url.includes('dropdown') || url.includes('popup') || url.includes('float'); } private findNearestBreakpoint(width: number, breakpoints: number[]): { value: number; gap: number } { let nearest = breakpoints[0] || 0; let minGap = Math.abs(width - nearest); for (const bp of breakpoints) { const gap = Math.abs(width - bp); if (gap < minGap) { minGap = gap; nearest = bp; } } return { value: nearest, gap: minGap }; } /** * Simulation mode layout issue detection with deterministic results. */ private detectLayoutIssuesSimulation( url: string, viewport: Viewport, config: ResponsiveTestConfig ): LayoutIssue[] { const issues: LayoutIssue[] = []; const urlHash = this.hashUrl(url); const hashNum = parseInt(urlHash, 36); // Deterministic check for horizontal overflow (more likely on small screens) // Use hash + viewport width for determinism const overflowDeterminant = ((hashNum + viewport.width) % 100) / 100; if (viewport.width < 768 && overflowDeterminant < 0.2) { issues.push({ type: 'horizontal-overflow', severity: 'critical', element: '.container', description: 'Content overflows horizontally causing horizontal scroll', viewport, }); } // Deterministic check for text overflow const textOverflowDeterminant = ((hashNum + viewport.width + 100) % 100) / 100; if (viewport.width < 480 && textOverflowDeterminant < 0.15) { issues.push({ type: 'text-overflow', severity: 'warning', element: '.long-text', description: 'Text content is truncated or overflows container', viewport, }); } // Deterministic check for touch target size on mobile const touchDeterminant = ((hashNum + viewport.width + 200) % 100) / 100; if (config.checkTouchTargets && viewport.isMobile && touchDeterminant < 0.25) { issues.push({ type: 'touch-target-size', severity: 'warning', element: '.small-button', description: `Touch target is smaller than ${config.minTouchTargetSize}px minimum`, viewport, }); } // Deterministic check for font size const fontDeterminant = ((hashNum + viewport.width + 300) % 100) / 100; if (viewport.isMobile && fontDeterminant < 0.1) { issues.push({ type: 'font-size-too-small', severity: 'warning', element: '.fine-print', description: `Font size is below ${config.minFontSize}px minimum for readability`, viewport, }); } // Deterministic check for non-responsive images const imageDeterminant = ((hashNum + viewport.width + 400) % 100) / 100; if (imageDeterminant < 0.15) { issues.push({ type: 'image-not-responsive', severity: 'info', element: 'img.hero-image', description: 'Image does not scale properly for viewport width', viewport, }); } return issues; } private detectBreakpointIssues( url: string, viewportResults: ViewportResult[], config: ResponsiveTestConfig ): BreakpointIssue[] { // In production mode (simulationMode: false), return empty results // since we can't detect breakpoint issues without a real browser if (!config.simulationMode) { return []; } const issues: BreakpointIssue[] = []; const urlHash = this.hashUrl(url); const hashNum = parseInt(urlHash, 36); // Sort results by viewport width const sorted = [...viewportResults].sort( (a, b) => a.viewport.width - b.viewport.width ); // Check for issues at each breakpoint for (let i = 0; i < sorted.length - 1; i++) { const current = sorted[i]; const next = sorted[i + 1]; // Check if there's a breakpoint between these viewports const breakpointBetween = config.breakpoints.find( (bp) => bp > current.viewport.width && bp < next.viewport.width ); // Deterministic decision based on hash and breakpoint if (breakpointBetween) { const determinant = ((hashNum + breakpointBetween + i * 100) % 100) / 100; if (determinant < 0.2) { issues.push({ breakpoint: breakpointBetween, description: 'Layout shifts abruptly at this breakpoint', affectedElements: ['.navigation', '.sidebar'], suggestion: 'Consider adding intermediate styles or adjusting breakpoint', }); } } } return issues; } private calculateLayoutScore( viewportResults: ViewportResult[], issues: LayoutIssue[] ): number { const baseScore = 100; // Deduct points for issues const criticalPenalty = issues.filter((i) => i.severity === 'critical').length * 15; const warningPenalty = issues.filter((i) => i.severity === 'warning').length * 5; const infoPenalty = issues.filter((i) => i.severity === 'info').length * 1; // Bonus for passing all viewport tests const passingBonus = viewportResults.every((r) => r.passed) ? 5 : 0; return Math.max(0, baseScore - criticalPenalty - warningPenalty - infoPenalty + passingBonus); } private generateRecommendations( layoutIssues: LayoutIssue[], breakpointIssues: BreakpointIssue[] ): string[] { const recommendations: string[] = []; // Group issues by type const issuesByType = new Map(); for (const issue of layoutIssues) { const existing = issuesByType.get(issue.type) || []; issuesByType.set(issue.type, [...existing, issue]); } // Generate recommendations for each issue type if (issuesByType.has('horizontal-overflow')) { recommendations.push( 'Use CSS overflow-x: hidden or adjust element widths to prevent horizontal scrolling' ); } if (issuesByType.has('touch-target-size')) { recommendations.push( 'Increase touch target sizes to at least 44x44 pixels for better mobile usability' ); } if (issuesByType.has('font-size-too-small')) { recommendations.push( 'Use relative font units (rem/em) and ensure minimum 16px base font size on mobile' ); } if (issuesByType.has('image-not-responsive')) { recommendations.push( 'Use srcset and sizes attributes for responsive images, or CSS max-width: 100%' ); } if (breakpointIssues.length > 0) { recommendations.push( 'Review CSS media queries to ensure smooth transitions between breakpoints' ); } return recommendations; } private calculateStructuralDiff( screenshot1: Screenshot, screenshot2: Screenshot ): VisualDiff { // Calculate difference based on viewport changes const widthDiff = Math.abs(screenshot1.viewport.width - screenshot2.viewport.width); const heightDiff = Math.abs(screenshot1.viewport.height - screenshot2.viewport.height); // Larger viewport differences typically mean more layout changes const diffPercentage = Math.min(50, (widthDiff + heightDiff) / 20); return { baselineId: screenshot1.id, comparisonId: screenshot2.id, diffPercentage, diffPixels: Math.floor(diffPercentage * 1000), regions: [], status: diffPercentage < 10 ? 'acceptable' : 'changed', }; } /** * Find content break points by analyzing URL structure * In production, this would test rendering at many widths */ private findContentBreaks(url: string): ContentBreak[] { const breaks: ContentBreak[] = []; // Use URL hash for deterministic results const urlHash = this.hashUrl(url); const hashNum = parseInt(urlHash.substring(0, 6), 36); // Common responsive breakpoints to test const potentialBreaks = [375, 540, 768, 992, 1200, 1400]; // Analyze URL patterns to determine likely break points const isEcommerce = url.includes('shop') || url.includes('product') || url.includes('cart'); const isDashboard = url.includes('dashboard') || url.includes('admin') || url.includes('panel'); const isContent = url.includes('blog') || url.includes('article') || url.includes('news'); for (let i = 0; i < potentialBreaks.length; i++) { const width = potentialBreaks[i]; // Deterministic decision based on hash and URL type const determinant = (hashNum + i * 100 + width) % 100; // Different page types have different break point patterns let threshold: number; let elements: string[]; let reason: string; if (isEcommerce) { threshold = 40; // E-commerce often has more breakpoints elements = ['.product-grid', '.cart-items', '.checkout-form', '.filters']; reason = 'Product grid layout adjusts for viewport'; } else if (isDashboard) { threshold = 50; elements = ['.sidebar', '.dashboard-cards', '.data-table', '.nav-menu']; reason = 'Dashboard layout reorganizes for smaller screens'; } else if (isContent) { threshold = 60; elements = ['.article-content', '.sidebar-widgets', '.comments']; reason = 'Content width and sidebar visibility changes'; } else { threshold = 55; elements = ['.container', '.grid', '.navigation', '.footer']; reason = 'Content layout changes significantly'; } if (determinant < threshold) { // Select affected elements based on breakpoint width const affectedCount = Math.min( elements.length, 1 + Math.floor((hashNum + width) % elements.length) ); const affectedElements = elements.slice(0, affectedCount); breaks.push({ width, reason, affectedElements, }); } } return breaks; } /** * Hash URL for deterministic results */ private hashUrl(url: string): string { let hash = 0; for (let i = 0; i < url.length; i++) { const char = url.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; } return Math.abs(hash).toString(36); } private suggestBreakpoints(contentBreaks: ContentBreak[]): number[] { // Suggest breakpoints based on content breaks const suggestions = new Set(); for (const breakItem of contentBreaks) { // Round to nearest common breakpoint const rounded = Math.round(breakItem.width / 100) * 100; suggestions.add(rounded); } // Add standard breakpoints if not covered const standard = [576, 768, 992, 1200, 1400]; for (const bp of standard) { if (!Array.from(suggestions).some((s) => Math.abs(s - bp) < 50)) { suggestions.add(bp); } } return Array.from(suggestions).sort((a, b) => a - b); } private calculateBreakpointCoverage( current: number[], contentBreaks: ContentBreak[] ): number { if (contentBreaks.length === 0) return 100; let covered = 0; for (const breakItem of contentBreaks) { // Check if any current breakpoint is within 50px if (current.some((bp) => Math.abs(bp - breakItem.width) < 50)) { covered++; } } return Math.round((covered / contentBreaks.length) * 100); } private async storeResult(result: ResponsiveTestResult): Promise { const resultId = uuidv4(); await this.memory.set( `visual-accessibility:responsive:${resultId}`, result, { namespace: 'visual-accessibility', persist: true } ); } }