|
| 1 | +import { describe, it, expect } from 'vitest'; |
| 2 | +import { readFileSync, readdirSync, statSync } from 'fs'; |
| 3 | +import { resolve, join } from 'path'; |
| 4 | + |
| 5 | +/** |
| 6 | + * Responsive Layout Compliance Tests |
| 7 | + * |
| 8 | + * Prevents common causes of horizontal overflow at mobile viewports: |
| 9 | + * - Fixed widths that exceed mobile screens (e.g., w-[500px]) |
| 10 | + * - Excessive padding without responsive breakpoints on wrapper divs |
| 11 | + * - Missing overflow protection on containers with inline content |
| 12 | + * |
| 13 | + * These tests complement the visual audit (Sessions 232-233) which verified |
| 14 | + * all 11 pages at 100%/125%/150% zoom × 375/768/1280px with zero overflow. |
| 15 | + */ |
| 16 | + |
| 17 | +const SRC_DIR = resolve(__dirname, '..'); |
| 18 | + |
| 19 | +function findJsxFiles(dir) { |
| 20 | + const files = []; |
| 21 | + for (const entry of readdirSync(dir)) { |
| 22 | + const full = join(dir, entry); |
| 23 | + if (entry === 'node_modules' || entry === 'test') continue; |
| 24 | + const stat = statSync(full); |
| 25 | + if (stat.isDirectory()) { |
| 26 | + files.push(...findJsxFiles(full)); |
| 27 | + } else if (entry.endsWith('.jsx')) { |
| 28 | + files.push(full); |
| 29 | + } |
| 30 | + } |
| 31 | + return files; |
| 32 | +} |
| 33 | + |
| 34 | +const jsxFiles = findJsxFiles(SRC_DIR); |
| 35 | + |
| 36 | +describe('Responsive layout compliance', () => { |
| 37 | + it('finds JSX files to scan', () => { |
| 38 | + expect(jsxFiles.length).toBeGreaterThan(50); |
| 39 | + }); |
| 40 | + |
| 41 | + it('no fixed widths wider than 480px without responsive prefix', () => { |
| 42 | + // Fixed widths like w-[500px] or w-[600px] will overflow 375px mobile. |
| 43 | + // Allowed: w-[NNNpx] with sm:/md:/lg: prefix, or inside min-w-/max-w- context |
| 44 | + const FIXED_WIDTH = /(?<![:\w-])w-\[(\d+)px\]/g; |
| 45 | + const violations = []; |
| 46 | + for (const filePath of jsxFiles) { |
| 47 | + const content = readFileSync(filePath, 'utf-8'); |
| 48 | + const relativePath = filePath.replace(SRC_DIR + '/', ''); |
| 49 | + const lines = content.split('\n'); |
| 50 | + lines.forEach((line, idx) => { |
| 51 | + // Skip comments and strings that aren't className |
| 52 | + if (/^\s*\/\//.test(line) || /^\s*\*/.test(line)) return; |
| 53 | + let match; |
| 54 | + const regex = /(?<![:\w-])w-\[(\d+)px\]/g; |
| 55 | + while ((match = regex.exec(line)) !== null) { |
| 56 | + const width = parseInt(match[1], 10); |
| 57 | + if (width > 480) { |
| 58 | + // Check if it has a responsive prefix (sm: md: lg: xl:) |
| 59 | + const before = line.substring(0, match.index); |
| 60 | + if (/(?:sm|md|lg|xl|2xl):$/.test(before.trim())) continue; |
| 61 | + // Check if it's min-w- or max-w- (not bare w-) |
| 62 | + if (/(?:min-w|max-w)-\[$/.test(before.slice(-6))) continue; |
| 63 | + violations.push(`${relativePath}:${idx + 1}: w-[${width}px] — fixed width exceeds mobile viewport`); |
| 64 | + } |
| 65 | + } |
| 66 | + }); |
| 67 | + } |
| 68 | + expect(violations, `Fixed widths >480px without responsive prefix:\n${violations.join('\n')}`).toEqual([]); |
| 69 | + }); |
| 70 | + |
| 71 | + it('App root has overflow-x-hidden to prevent stray horizontal scroll', () => { |
| 72 | + const appPath = jsxFiles.find(f => f.endsWith('/App.jsx')); |
| 73 | + expect(appPath).toBeTruthy(); |
| 74 | + const content = readFileSync(appPath, 'utf-8'); |
| 75 | + expect(content).toContain('overflow-x-hidden'); |
| 76 | + }); |
| 77 | + |
| 78 | + it('no hardcoded min-width wider than 540px without horizontal scroll container', () => { |
| 79 | + // min-w-[NNN] > 540px on a non-scroll container will break mobile. |
| 80 | + // Exception: elements inside overflow-x-auto or overflow-x-scroll containers |
| 81 | + const MIN_WIDTH = /min-w-\[(\d+)px\]/g; |
| 82 | + const violations = []; |
| 83 | + for (const filePath of jsxFiles) { |
| 84 | + const content = readFileSync(filePath, 'utf-8'); |
| 85 | + const relativePath = filePath.replace(SRC_DIR + '/', ''); |
| 86 | + const lines = content.split('\n'); |
| 87 | + lines.forEach((line, idx) => { |
| 88 | + if (/^\s*\/\//.test(line) || /^\s*\*/.test(line)) return; |
| 89 | + let match; |
| 90 | + const regex = /min-w-\[(\d+)px\]/g; |
| 91 | + while ((match = regex.exec(line)) !== null) { |
| 92 | + const width = parseInt(match[1], 10); |
| 93 | + if (width > 540) { |
| 94 | + // Check if there's an overflow-x-auto/scroll on same line or nearby |
| 95 | + if (/overflow-x-(auto|scroll)/.test(line)) continue; |
| 96 | + // Check 3 lines before for scroll container |
| 97 | + const context = lines.slice(Math.max(0, idx - 3), idx + 1).join('\n'); |
| 98 | + if (/overflow-x-(auto|scroll)/.test(context)) continue; |
| 99 | + violations.push(`${relativePath}:${idx + 1}: min-w-[${width}px] — may overflow mobile without scroll container`); |
| 100 | + } |
| 101 | + } |
| 102 | + }); |
| 103 | + } |
| 104 | + expect(violations, `Large min-width without scroll container:\n${violations.join('\n')}`).toEqual([]); |
| 105 | + }); |
| 106 | +}); |
0 commit comments