eb92f64e93
Mobile fixes: - Add click toggle handler for hamburger menu (was hover-only) - Menu now opens/closes on tap and closes when clicking outside - Keep hover support for desktop iPad fixes: - Sidebar content now visible on touch devices (901-1280px) - Added (hover: hover) media query to prevent hide-on-hover on tablets Security improvements: - Replace exec.CommandContext with go-git library for git operations - Add path traversal and command injection prevention - Fix race condition in template hot reload - Add environment-based cookie Secure flag Code quality: - Add constants.go for magic numbers - Remove unused code (ParsePreferenceToggleRequest, DomainError) - Add FOUC prevention with inline critical CSS - Add Makefile dev/run/clean targets - Fix README git clone URL - Add doc/DECISIONS.md for architectural decisions Tests: - Add hamburger menu click toggle tests - Add iPad sidebar visibility tests - Update security tests for go-git implementation - Add cookie Secure flag tests
316 lines
12 KiB
JavaScript
Executable File
316 lines
12 KiB
JavaScript
Executable File
#!/usr/bin/env bun
|
|
/**
|
|
* MOBILE RESPONSIVE TEST
|
|
* =======================
|
|
* Tests mobile viewport rendering and interactions
|
|
* - Mobile viewport sizing (375px, 768px, 1024px)
|
|
* - Touch interactions
|
|
* - Mobile menu functionality
|
|
* - Responsive layout breakpoints
|
|
* - Text readability at small sizes
|
|
*/
|
|
|
|
import { chromium } from 'playwright';
|
|
|
|
const URL = "http://localhost:1999";
|
|
|
|
// Common mobile viewports
|
|
const VIEWPORTS = {
|
|
mobile: { width: 375, height: 667 }, // iPhone SE
|
|
tablet: { width: 768, height: 1024 }, // iPad
|
|
desktop: { width: 1920, height: 1080 } // Desktop baseline
|
|
};
|
|
|
|
async function testMobileResponsive() {
|
|
console.log('📱 MOBILE RESPONSIVE TEST\n');
|
|
console.log('='.repeat(70));
|
|
|
|
const browser = await chromium.launch({ headless: true });
|
|
const errors = [];
|
|
const testResults = [];
|
|
|
|
// ========================================================================
|
|
// TEST 1: Mobile viewport (375px)
|
|
// ========================================================================
|
|
console.log("\n1️⃣ Testing Mobile Viewport (375px)...");
|
|
const mobilePage = await browser.newPage({ viewport: VIEWPORTS.mobile });
|
|
|
|
mobilePage.on('console', msg => {
|
|
if (msg.type() === 'error') {
|
|
errors.push(msg.text());
|
|
console.log(`❌ ERROR: ${msg.text()}`);
|
|
}
|
|
});
|
|
|
|
await mobilePage.goto(URL);
|
|
await mobilePage.waitForTimeout(2000);
|
|
|
|
const mobileTest = await mobilePage.evaluate(() => {
|
|
const paper = document.querySelector('.cv-paper');
|
|
const body = document.body;
|
|
const hamburger = document.querySelector('.hamburger-btn');
|
|
|
|
// Check for horizontal overflow
|
|
const hasHorizontalScroll = document.documentElement.scrollWidth > window.innerWidth;
|
|
|
|
// Check if text is readable (not too small)
|
|
const paragraphs = Array.from(document.querySelectorAll('p, li'));
|
|
const fontSizes = paragraphs.map(p => {
|
|
const size = parseFloat(window.getComputedStyle(p).fontSize);
|
|
return size;
|
|
});
|
|
const minFontSize = Math.min(...fontSizes);
|
|
|
|
// Check if hamburger menu exists (mobile navigation)
|
|
const hasHamburger = !!hamburger;
|
|
const hamburgerVisible = hasHamburger ?
|
|
window.getComputedStyle(hamburger).display !== 'none' : false;
|
|
|
|
return {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
hasHorizontalScroll,
|
|
minFontSize,
|
|
hasHamburger,
|
|
hamburgerVisible,
|
|
paperWidth: paper ? paper.offsetWidth : 0
|
|
};
|
|
});
|
|
|
|
console.log(` Viewport: ${mobileTest.width}x${mobileTest.height}`);
|
|
console.log(` Horizontal scroll: ${mobileTest.hasHorizontalScroll ? '❌ YES (BAD)' : '✅ NO (GOOD)'}`);
|
|
console.log(` Min font size: ${mobileTest.minFontSize.toFixed(1)}px ${mobileTest.minFontSize >= 14 ? '✅' : '⚠️'}`);
|
|
console.log(` Hamburger menu: ${mobileTest.hasHamburger ? '✅ Present' : '⚠️ Not found'}`);
|
|
console.log(` Hamburger visible: ${mobileTest.hamburgerVisible ? '✅ YES' : '⚠️ NO'}`);
|
|
|
|
const mobileViewportPassed = !mobileTest.hasHorizontalScroll && mobileTest.minFontSize >= 14;
|
|
console.log(` ${mobileViewportPassed ? '✅ PASS' : '❌ FAIL'} - Mobile viewport`);
|
|
testResults.push({ test: 'Mobile Viewport (375px)', passed: mobileViewportPassed });
|
|
|
|
// ========================================================================
|
|
// TEST 2: Hamburger Menu Click Toggle (Mobile)
|
|
// ========================================================================
|
|
console.log("\n2️⃣ Testing Hamburger Menu Click Toggle...");
|
|
|
|
const hamburger = await mobilePage.$('.hamburger-btn');
|
|
if (hamburger) {
|
|
// Click hamburger to open menu (simulates mobile tap)
|
|
await hamburger.click();
|
|
await mobilePage.waitForTimeout(300);
|
|
|
|
const menuOpenTest = await mobilePage.evaluate(() => {
|
|
const menu = document.querySelector('.navigation-menu');
|
|
if (!menu) return { found: false };
|
|
|
|
const hasMenuOpen = menu.classList.contains('menu-open');
|
|
const computedStyle = window.getComputedStyle(menu);
|
|
const isVisible = computedStyle.opacity === '1' && computedStyle.maxHeight !== '0px';
|
|
|
|
return {
|
|
found: true,
|
|
hasMenuOpen,
|
|
isVisible,
|
|
maxHeight: computedStyle.maxHeight
|
|
};
|
|
});
|
|
|
|
console.log(` Menu found: ${menuOpenTest.found ? '✅' : '❌'}`);
|
|
console.log(` Has menu-open class: ${menuOpenTest.hasMenuOpen ? '✅' : '❌'}`);
|
|
console.log(` Menu visible: ${menuOpenTest.isVisible ? '✅' : '❌'}`);
|
|
|
|
const openPassed = menuOpenTest.found && menuOpenTest.hasMenuOpen;
|
|
console.log(` ${openPassed ? '✅ PASS' : '❌ FAIL'} - Menu opens on click`);
|
|
testResults.push({ test: 'Menu Opens on Click', passed: openPassed });
|
|
|
|
// TEST 2B: Click again to close
|
|
await hamburger.click();
|
|
await mobilePage.waitForTimeout(300);
|
|
|
|
const menuCloseTest = await mobilePage.evaluate(() => {
|
|
const menu = document.querySelector('.navigation-menu');
|
|
return {
|
|
hasMenuOpen: menu?.classList.contains('menu-open') ?? false
|
|
};
|
|
});
|
|
|
|
const closePassed = !menuCloseTest.hasMenuOpen;
|
|
console.log(` Menu closes on second click: ${closePassed ? '✅' : '❌'}`);
|
|
testResults.push({ test: 'Menu Closes on Click', passed: closePassed });
|
|
|
|
// TEST 2C: Click outside to close
|
|
await hamburger.click(); // Open again
|
|
await mobilePage.waitForTimeout(300);
|
|
await mobilePage.click('body', { position: { x: 300, y: 400 } }); // Click outside
|
|
await mobilePage.waitForTimeout(300);
|
|
|
|
const outsideCloseTest = await mobilePage.evaluate(() => {
|
|
const menu = document.querySelector('.navigation-menu');
|
|
return {
|
|
hasMenuOpen: menu?.classList.contains('menu-open') ?? false
|
|
};
|
|
});
|
|
|
|
const outsidePassed = !outsideCloseTest.hasMenuOpen;
|
|
console.log(` Menu closes on outside click: ${outsidePassed ? '✅' : '❌'}`);
|
|
testResults.push({ test: 'Menu Closes on Outside Click', passed: outsidePassed });
|
|
|
|
} else {
|
|
console.log(` ⚠️ SKIP - Hamburger menu not found`);
|
|
testResults.push({ test: 'Menu Opens on Click', passed: false });
|
|
testResults.push({ test: 'Menu Closes on Click', passed: false });
|
|
testResults.push({ test: 'Menu Closes on Outside Click', passed: false });
|
|
}
|
|
|
|
// ========================================================================
|
|
// TEST 3: Tablet viewport (768px)
|
|
// ========================================================================
|
|
console.log("\n3️⃣ Testing Tablet Viewport (768px)...");
|
|
const tabletPage = await browser.newPage({ viewport: VIEWPORTS.tablet });
|
|
await tabletPage.goto(URL);
|
|
await tabletPage.waitForTimeout(2000);
|
|
|
|
const tabletTest = await tabletPage.evaluate(() => {
|
|
const hasHorizontalScroll = document.documentElement.scrollWidth > window.innerWidth;
|
|
const paper = document.querySelector('.cv-paper');
|
|
const actionBar = document.querySelector('.action-bar, .cv-controls');
|
|
|
|
return {
|
|
width: window.innerWidth,
|
|
hasHorizontalScroll,
|
|
paperWidth: paper ? paper.offsetWidth : 0,
|
|
hasActionBar: !!actionBar,
|
|
actionBarVisible: actionBar ? window.getComputedStyle(actionBar).display !== 'none' : false
|
|
};
|
|
});
|
|
|
|
console.log(` Viewport: ${tabletTest.width}px`);
|
|
console.log(` Horizontal scroll: ${tabletTest.hasHorizontalScroll ? '❌ YES' : '✅ NO'}`);
|
|
console.log(` Action bar: ${tabletTest.hasActionBar ? '✅ Present' : '⚠️ Not found'}`);
|
|
|
|
const tabletViewportPassed = !tabletTest.hasHorizontalScroll;
|
|
console.log(` ${tabletViewportPassed ? '✅ PASS' : '❌ FAIL'} - Tablet viewport`);
|
|
testResults.push({ test: 'Tablet Viewport (768px)', passed: tabletViewportPassed });
|
|
|
|
await tabletPage.close();
|
|
|
|
// ========================================================================
|
|
// TEST 4: Responsive breakpoints
|
|
// ========================================================================
|
|
console.log("\n4️⃣ Testing Responsive Breakpoints...");
|
|
|
|
const breakpoints = [
|
|
{ name: 'Small Mobile', width: 320 },
|
|
{ name: 'Mobile', width: 375 },
|
|
{ name: 'Large Mobile', width: 414 },
|
|
{ name: 'Small Tablet', width: 600 },
|
|
{ name: 'Tablet', width: 768 },
|
|
{ name: 'Large Tablet', width: 1024 },
|
|
{ name: 'Desktop', width: 1920 }
|
|
];
|
|
|
|
const breakpointResults = [];
|
|
|
|
for (const bp of breakpoints) {
|
|
await mobilePage.setViewportSize({ width: bp.width, height: 800 });
|
|
await mobilePage.waitForTimeout(200);
|
|
|
|
const result = await mobilePage.evaluate(() => {
|
|
return {
|
|
hasHorizontalScroll: document.documentElement.scrollWidth > window.innerWidth,
|
|
bodyWidth: document.body.offsetWidth
|
|
};
|
|
});
|
|
|
|
const passed = !result.hasHorizontalScroll;
|
|
breakpointResults.push({ name: bp.name, width: bp.width, passed });
|
|
console.log(` ${bp.name} (${bp.width}px): ${passed ? '✅' : '❌'}`);
|
|
}
|
|
|
|
const allBreakpointsPassed = breakpointResults.every(r => r.passed);
|
|
console.log(` ${allBreakpointsPassed ? '✅ PASS' : '❌ FAIL'} - All breakpoints`);
|
|
testResults.push({ test: 'Responsive Breakpoints', passed: allBreakpointsPassed });
|
|
|
|
// ========================================================================
|
|
// TEST 5: Mobile-specific features
|
|
// ========================================================================
|
|
console.log("\n5️⃣ Testing Mobile-Specific Features...");
|
|
|
|
await mobilePage.setViewportSize(VIEWPORTS.mobile);
|
|
await mobilePage.waitForTimeout(500);
|
|
|
|
const mobileFeatures = await mobilePage.evaluate(() => {
|
|
// Check viewport meta tag
|
|
const viewportMeta = document.querySelector('meta[name="viewport"]');
|
|
const hasViewportMeta = !!viewportMeta;
|
|
const viewportContent = viewportMeta?.getAttribute('content') || '';
|
|
|
|
// Check for touch-friendly button sizes (minimum 44x44px)
|
|
const buttons = Array.from(document.querySelectorAll('button, a[role="button"], .btn, input[type="checkbox"]'));
|
|
const buttonSizes = buttons.map(btn => {
|
|
const rect = btn.getBoundingClientRect();
|
|
return { width: rect.width, height: rect.height };
|
|
});
|
|
const tooSmallButtons = buttonSizes.filter(s => s.width < 44 || s.height < 44).length;
|
|
|
|
// Check for text overflow
|
|
const hasTextOverflow = Array.from(document.querySelectorAll('*')).some(el => {
|
|
return el.scrollWidth > el.clientWidth && window.getComputedStyle(el).overflow === 'visible';
|
|
});
|
|
|
|
return {
|
|
hasViewportMeta,
|
|
viewportContent,
|
|
totalButtons: buttons.length,
|
|
tooSmallButtons,
|
|
hasTextOverflow
|
|
};
|
|
});
|
|
|
|
console.log(` Viewport meta tag: ${mobileFeatures.hasViewportMeta ? '✅' : '❌'}`);
|
|
console.log(` Content: "${mobileFeatures.viewportContent}"`);
|
|
console.log(` Touch-friendly buttons: ${mobileFeatures.totalButtons - mobileFeatures.tooSmallButtons}/${mobileFeatures.totalButtons}`);
|
|
console.log(` Too small (<44px): ${mobileFeatures.tooSmallButtons} ${mobileFeatures.tooSmallButtons === 0 ? '✅' : '⚠️'}`);
|
|
console.log(` Text overflow: ${mobileFeatures.hasTextOverflow ? '⚠️ YES' : '✅ NO'}`);
|
|
|
|
const mobileFeaturesPass = mobileFeatures.hasViewportMeta && !mobileFeatures.hasTextOverflow;
|
|
console.log(` ${mobileFeaturesPass ? '✅ PASS' : '❌ FAIL'} - Mobile features`);
|
|
testResults.push({ test: 'Mobile Features', passed: mobileFeaturesPass });
|
|
|
|
await mobilePage.close();
|
|
|
|
// ========================================================================
|
|
// FINAL SUMMARY
|
|
// ========================================================================
|
|
console.log("\n" + "=".repeat(70));
|
|
console.log("📊 TEST SUMMARY\n");
|
|
|
|
const totalTests = testResults.length;
|
|
const passedTests = testResults.filter(r => r.passed).length;
|
|
const failedTests = totalTests - passedTests;
|
|
|
|
testResults.forEach(result => {
|
|
console.log(` ${result.passed ? '✅' : '❌'} ${result.test}`);
|
|
});
|
|
|
|
console.log(`\n Total: ${passedTests}/${totalTests} tests passed`);
|
|
|
|
if (errors.length === 0) {
|
|
console.log("\n✅ NO CONSOLE ERRORS");
|
|
} else {
|
|
console.log(`\n⚠️ ${errors.length} CONSOLE ERRORS`);
|
|
}
|
|
|
|
console.log("=".repeat(70) + "\n");
|
|
|
|
if (failedTests === 0) {
|
|
console.log("🎉 MOBILE RESPONSIVE VALIDATED!");
|
|
} else {
|
|
console.log("⚠️ SOME TESTS FAILED - See details above");
|
|
}
|
|
|
|
await browser.close();
|
|
process.exit(failedTests === 0 ? 0 : 1);
|
|
}
|
|
|
|
await testMobileResponsive();
|