Files
cv-site/tests/test-verification.mjs
T
2025-11-16 12:48:12 +00:00

579 lines
20 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* COMPREHENSIVE VERIFICATION TEST SUITE
* Tests both HTMX indicators and shortcuts button visibility fixes
*/
import { chromium } from 'playwright';
const BASE_URL = 'http://localhost:1999';
const RESULTS = {
passed: [],
failed: [],
warnings: []
};
function log(status, message) {
const timestamp = new Date().toLocaleTimeString();
const icons = { pass: '✅', fail: '❌', warn: '⚠️', info: '️' };
console.log(`[${timestamp}] ${icons[status] || icons.info} ${message}`);
if (status === 'pass') RESULTS.passed.push(message);
if (status === 'fail') RESULTS.failed.push(message);
if (status === 'warn') RESULTS.warnings.push(message);
}
function measureTime(start) {
return `${Date.now() - start}ms`;
}
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function test1_HTMXLoadingIndicators(page) {
log('info', '═══════════════════════════════════════════════════════');
log('info', 'TEST 1: HTMX Loading Indicators (Feature 003)');
log('info', '═══════════════════════════════════════════════════════');
try {
// Navigate to page
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle');
log('pass', 'Page loaded successfully');
// Test 1.1: Verify indicator elements exist
log('info', 'Test 1.1: Checking indicator elements exist...');
const enIndicator = page.locator('#lang-indicator-en');
const esIndicator = page.locator('#lang-indicator-es');
await enIndicator.waitFor({ state: 'attached', timeout: 5000 });
await esIndicator.waitFor({ state: 'attached', timeout: 5000 });
log('pass', 'Both language indicators found in DOM');
// Test 1.2: Verify initial opacity is 0 (hidden)
log('info', 'Test 1.2: Checking initial indicator opacity...');
const enInitialOpacity = await enIndicator.evaluate(el =>
window.getComputedStyle(el).opacity
);
const esInitialOpacity = await esIndicator.evaluate(el =>
window.getComputedStyle(el).opacity
);
if (enInitialOpacity === '0' && esInitialOpacity === '0') {
log('pass', `Indicators hidden initially (opacity: ${enInitialOpacity})`);
} else {
log('fail', `Indicators should be hidden (EN: ${enInitialOpacity}, ES: ${esInitialOpacity})`);
}
// Test 1.3: Click EN button and verify indicator appears
log('info', 'Test 1.3: Testing EN button loading indicator...');
// Get the ES button (since we're on EN by default)
const esButton = page.locator('button.selector-btn[data-short="ES"]');
await esButton.waitFor({ state: 'visible' });
// Set up monitoring for opacity changes
const opacityPromise = page.evaluate(() => {
return new Promise(resolve => {
const indicator = document.querySelector('#lang-indicator-es');
let maxOpacity = 0;
let opacityChanges = [];
const observer = new MutationObserver(() => {
const currentOpacity = parseFloat(window.getComputedStyle(indicator).opacity);
opacityChanges.push(currentOpacity);
maxOpacity = Math.max(maxOpacity, currentOpacity);
});
observer.observe(indicator.parentElement, {
attributes: true,
attributeFilter: ['class'],
subtree: true
});
// Check opacity every 10ms for 2 seconds
let checks = 0;
const interval = setInterval(() => {
const currentOpacity = parseFloat(window.getComputedStyle(indicator).opacity);
opacityChanges.push(currentOpacity);
maxOpacity = Math.max(maxOpacity, currentOpacity);
checks++;
if (checks > 200) { // 2 seconds
clearInterval(interval);
observer.disconnect();
resolve({ maxOpacity, opacityChanges: opacityChanges.filter(o => o > 0) });
}
}, 10);
});
});
// Click the button
const clickTime = Date.now();
await esButton.click();
// Wait for the opacity monitoring to complete
const opacityData = await opacityPromise;
const responseTime = measureTime(clickTime);
log('info', `Request completed in ${responseTime}`);
log('info', `Max indicator opacity: ${opacityData.maxOpacity}`);
log('info', `Opacity changes detected: ${opacityData.opacityChanges.length}`);
if (opacityData.maxOpacity >= 0.9) {
log('pass', `Indicator became visible (max opacity: ${opacityData.maxOpacity})`);
} else if (opacityData.maxOpacity > 0) {
log('warn', `Indicator partially visible but not fully (max: ${opacityData.maxOpacity})`);
} else {
log('warn', 'Indicator not visible on fast request (expected on localhost - will verify with throttled test)');
}
// Test 1.4: Verify indicator faded out after request
await sleep(500);
const finalOpacity = await esIndicator.evaluate(el =>
window.getComputedStyle(el).opacity
);
if (finalOpacity === '0') {
log('pass', `Indicator hidden after request (opacity: ${finalOpacity})`);
} else {
log('warn', `Indicator may not have faded out (opacity: ${finalOpacity})`);
}
// Test 1.5: Take screenshot during loading
log('info', 'Test 1.5: Capturing screenshot during loading...');
// Click back to EN to trigger another loading state
await sleep(500);
const enButton = page.locator('button.selector-btn[data-short="EN"]');
// Start click and immediately capture
const screenshotPromise = page.screenshot({
path: '/Users/txeo/Git/yo/cv/test-screenshots/htmx-indicator-loading.png',
fullPage: false
});
await enButton.click();
await screenshotPromise;
log('pass', 'Screenshot captured: test-screenshots/htmx-indicator-loading.png');
// Test 1.6: Network throttling test
log('info', 'Test 1.6: Testing with slow 3G network...');
// Slow 3G preset - only delay the specific endpoint
let requestIntercepted = false;
await page.route('**/switch-language**', async route => {
if (!requestIntercepted) {
requestIntercepted = true;
await sleep(800); // Simulate 800ms delay
}
await route.continue();
});
await sleep(500);
const slowClickTime = Date.now();
// Click and monitor
const slowOpacityPromise = page.evaluate(() => {
return new Promise(resolve => {
const indicator = document.querySelector('#lang-indicator-en');
let maxOpacity = 0;
const interval = setInterval(() => {
const opacity = parseFloat(window.getComputedStyle(indicator).opacity);
maxOpacity = Math.max(maxOpacity, opacity);
}, 10);
setTimeout(() => {
clearInterval(interval);
resolve(maxOpacity);
}, 1000);
});
});
await enButton.click();
const slowOpacity = await slowOpacityPromise;
await page.waitForLoadState('networkidle');
const slowResponseTime = measureTime(slowClickTime);
log('info', `Slow request completed in ${slowResponseTime}`);
log('info', `Mid-request opacity: ${slowOpacity}`);
if (slowOpacity >= 0.9) {
log('pass', `Indicator visible during slow request (opacity: ${slowOpacity})`);
} else {
log('fail', `Indicator not visible during slow request (opacity: ${slowOpacity})`);
}
// Unroute to restore normal speed
await page.unroute('**/switch-language**');
} catch (error) {
log('fail', `Test 1 error: ${error.message}`);
console.error(error);
}
}
async function test2_ShortcutsButtonVisibility(page) {
log('info', '═══════════════════════════════════════════════════════');
log('info', 'TEST 2: Shortcuts Button Visibility (Feature 001)');
log('info', '═══════════════════════════════════════════════════════');
try {
// Ensure we're on the page
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle');
// Test 2.1: Verify button exists and is visible
log('info', 'Test 2.1: Checking shortcuts button exists...');
const shortcutsBtn = page.locator('.shortcuts-btn');
await shortcutsBtn.waitFor({ state: 'visible', timeout: 5000 });
log('pass', 'Shortcuts button found and visible');
// Test 2.2: Measure initial opacity
log('info', 'Test 2.2: Measuring button opacity...');
const opacity = await shortcutsBtn.evaluate(el =>
window.getComputedStyle(el).opacity
);
const opacityNum = parseFloat(opacity);
log('info', `Button opacity: ${opacity}`);
if (opacityNum === 0.6) {
log('pass', `Button opacity is exactly 0.6 as expected`);
} else if (opacityNum >= 0.5 && opacityNum <= 0.7) {
log('warn', `Button opacity close to target (${opacity} vs 0.6)`);
} else {
log('fail', `Button opacity incorrect (${opacity}, expected 0.6)`);
}
// Test 2.3: Verify button is actually visible to users
log('info', 'Test 2.3: Verifying visual discoverability...');
const boundingBox = await shortcutsBtn.boundingBox();
if (boundingBox) {
log('pass', `Button has dimensions: ${boundingBox.width}x${boundingBox.height}px`);
log('info', `Position: (${boundingBox.x}, ${boundingBox.y})`);
} else {
log('fail', 'Button has no bounding box (may not be rendered)');
}
// Test 2.4: Test hover state
log('info', 'Test 2.4: Testing hover state...');
await shortcutsBtn.hover();
await sleep(500); // Wait for transition
const hoverOpacity = await shortcutsBtn.evaluate(el =>
window.getComputedStyle(el).opacity
);
if (parseFloat(hoverOpacity) === 1.0) {
log('pass', `Hover opacity is 1.0 (full visibility)`);
} else {
log('warn', `Hover opacity: ${hoverOpacity} (expected 1.0)`);
}
// Test 2.5: Take screenshot
log('info', 'Test 2.5: Capturing button screenshot...');
await page.screenshot({
path: '/Users/txeo/Git/yo/cv/test-screenshots/shortcuts-button-visible.png',
fullPage: false
});
log('pass', 'Screenshot captured: test-screenshots/shortcuts-button-visible.png');
// Test 2.6: Verify functionality
log('info', 'Test 2.6: Testing button functionality...');
await shortcutsBtn.click();
await sleep(300);
// Check if modal opened
const modal = page.locator('.shortcuts-modal, [id*="shortcut"], [class*="modal"]');
const modalVisible = await modal.isVisible().catch(() => false);
if (modalVisible) {
log('pass', 'Shortcuts modal opened successfully');
// Test ESC to close
await page.keyboard.press('Escape');
await sleep(300);
const modalClosed = await modal.isVisible().catch(() => false);
if (!modalClosed) {
log('pass', 'Modal closes with ESC key');
} else {
log('warn', 'Modal may not close with ESC');
}
} else {
log('fail', 'Modal did not open on button click');
}
// Test 2.7: Check info button consistency
log('info', 'Test 2.7: Verifying info button has same opacity...');
const infoBtn = page.locator('.info-button');
const infoBtnExists = await infoBtn.count();
if (infoBtnExists > 0) {
const infoOpacity = await infoBtn.evaluate(el =>
window.getComputedStyle(el).opacity
);
if (parseFloat(infoOpacity) === 0.6) {
log('pass', `Info button also has opacity 0.6 (consistency maintained)`);
} else {
log('warn', `Info button opacity: ${infoOpacity} (expected 0.6)`);
}
} else {
log('info', 'Info button not found (may not be on this page)');
}
} catch (error) {
log('fail', `Test 2 error: ${error.message}`);
console.error(error);
}
}
async function test3_RegressionTests(page) {
log('info', '═══════════════════════════════════════════════════════');
log('info', 'TEST 3: Regression Testing (Ensure Nothing Broke)');
log('info', '═══════════════════════════════════════════════════════');
try {
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle');
// Test 3.1: Skeleton loader still works
log('info', 'Test 3.1: Verifying skeleton loader animation...');
const skeletonExists = await page.locator('#skeleton-loader').count();
if (skeletonExists > 0) {
log('pass', 'Skeleton loader element found');
// Trigger language switch
const esButton = page.locator('button.selector-btn[data-short="ES"]');
const skeletonActivated = await page.evaluate(() => {
return new Promise(resolve => {
const skeleton = document.querySelector('#skeleton-loader');
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.target.classList.contains('active')) {
observer.disconnect();
resolve(true);
}
}
});
observer.observe(skeleton, { attributes: true, attributeFilter: ['class'] });
setTimeout(() => {
observer.disconnect();
resolve(false);
}, 2000);
});
});
await esButton.click();
await page.waitForLoadState('networkidle');
if (skeletonActivated) {
log('pass', 'Skeleton loader activated during language switch');
} else {
log('warn', 'Skeleton loader may not be activating');
}
} else {
log('info', 'Skeleton loader not found (may not be used)');
}
// Test 3.2: No console errors
log('info', 'Test 3.2: Checking for console errors...');
const errors = [];
page.on('console', msg => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
});
await page.reload();
await sleep(1000);
if (errors.length === 0) {
log('pass', 'No console errors detected');
} else {
log('fail', `Console errors found: ${errors.join(', ')}`);
}
// Test 3.3: No layout shifts
log('info', 'Test 3.3: Measuring Cumulative Layout Shift...');
const cls = await page.evaluate(() => {
return new Promise(resolve => {
let clsValue = 0;
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
setTimeout(() => {
observer.disconnect();
resolve(clsValue);
}, 2000);
} catch (e) {
resolve(0);
}
});
});
log('info', `CLS Score: ${cls.toFixed(3)}`);
if (cls < 0.1) {
log('pass', 'Excellent CLS score (< 0.1)');
} else if (cls < 0.25) {
log('warn', `CLS needs improvement (${cls.toFixed(3)})`);
} else {
log('fail', `Poor CLS score (${cls.toFixed(3)})`);
}
// Test 3.4: Page load performance
log('info', 'Test 3.4: Measuring page load performance...');
const perfMetrics = await page.evaluate(() => {
const perf = performance.getEntriesByType('navigation')[0];
return {
loadTime: perf.loadEventEnd - perf.fetchStart,
domContentLoaded: perf.domContentLoadedEventEnd - perf.fetchStart,
firstPaint: performance.getEntriesByType('paint')[0]?.startTime || 0
};
});
log('info', `Load time: ${perfMetrics.loadTime.toFixed(0)}ms`);
log('info', `DOMContentLoaded: ${perfMetrics.domContentLoaded.toFixed(0)}ms`);
log('info', `First Paint: ${perfMetrics.firstPaint.toFixed(0)}ms`);
if (perfMetrics.loadTime < 3000) {
log('pass', 'Page loads in under 3 seconds');
} else {
log('warn', `Page load time: ${perfMetrics.loadTime.toFixed(0)}ms`);
}
} catch (error) {
log('fail', `Test 3 error: ${error.message}`);
console.error(error);
}
}
async function generateReport() {
log('info', '═══════════════════════════════════════════════════════');
log('info', 'FINAL TEST REPORT');
log('info', '═══════════════════════════════════════════════════════');
console.log('\n📊 SUMMARY:');
console.log(` ✅ Passed: ${RESULTS.passed.length}`);
console.log(` ❌ Failed: ${RESULTS.failed.length}`);
console.log(` ⚠️ Warnings: ${RESULTS.warnings.length}`);
if (RESULTS.failed.length > 0) {
console.log('\n❌ FAILURES:');
RESULTS.failed.forEach((msg, i) => console.log(` ${i + 1}. ${msg}`));
}
if (RESULTS.warnings.length > 0) {
console.log('\n⚠️ WARNINGS:');
RESULTS.warnings.forEach((msg, i) => console.log(` ${i + 1}. ${msg}`));
}
console.log('\n📈 FEATURE GRADES:');
// Feature 003: HTMX Indicators
const indicatorTests = RESULTS.passed.filter(m =>
m.includes('indicator') || m.includes('Indicator')
).length;
const indicatorFails = RESULTS.failed.filter(m =>
m.includes('indicator') || m.includes('Indicator')
).length;
let feature003Grade = 'F';
if (indicatorFails === 0 && indicatorTests >= 5) feature003Grade = 'A';
else if (indicatorFails === 0 && indicatorTests >= 3) feature003Grade = 'B';
else if (indicatorFails <= 1) feature003Grade = 'C';
else if (indicatorFails <= 2) feature003Grade = 'D';
console.log(` Feature 003 (HTMX Indicators): ${feature003Grade} (${indicatorTests} tests passed, ${indicatorFails} failed)`);
// Feature 001: Shortcuts Button
const buttonTests = RESULTS.passed.filter(m =>
m.includes('Button') || m.includes('button') || m.includes('opacity')
).length;
const buttonFails = RESULTS.failed.filter(m =>
m.includes('Button') || m.includes('button') || m.includes('opacity')
).length;
let feature001Grade = 'A-';
if (buttonFails === 0 && buttonTests >= 6) feature001Grade = 'A';
else if (buttonFails === 0 && buttonTests >= 4) feature001Grade = 'A-';
else if (buttonFails <= 1) feature001Grade = 'B+';
else if (buttonFails <= 2) feature001Grade = 'B';
console.log(` Feature 001 (Shortcuts Button): ${feature001Grade} (${buttonTests} tests passed, ${buttonFails} failed)`);
console.log('\n📸 SCREENSHOTS:');
console.log(' - test-screenshots/htmx-indicator-loading.png');
console.log(' - test-screenshots/shortcuts-button-visible.png');
const overallSuccess = RESULTS.failed.length === 0;
console.log(`\n${overallSuccess ? '✅ ALL TESTS PASSED' : '❌ SOME TESTS FAILED'}\n`);
return overallSuccess;
}
async function main() {
console.log('🧪 COMPREHENSIVE VERIFICATION TEST SUITE');
console.log('Testing HTMX Indicators + Shortcuts Button Fixes\n');
// Create screenshots directory
const { mkdir } = await import('fs/promises');
await mkdir('/Users/txeo/Git/yo/cv/test-screenshots', { recursive: true });
const browser = await chromium.launch({
headless: true,
args: ['--disable-dev-shm-usage']
});
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
});
const page = await context.newPage();
try {
// Run all test suites
await test1_HTMXLoadingIndicators(page);
await test2_ShortcutsButtonVisibility(page);
await test3_RegressionTests(page);
// Generate report
const success = await generateReport();
await browser.close();
process.exit(success ? 0 : 1);
} catch (error) {
console.error('Fatal error:', error);
await browser.close();
process.exit(1);
}
}
main();