forked from colin/resume
Add Playwright and Lighthouse testing infrastructure
This commit is contained in:
parent
39caf88782
commit
a10eea979f
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "resume",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Colin Knapp's professional resume website",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"serve": "node tests/serve.js",
|
||||||
|
"test": "npm run test:lighthouse && npm run test:playwright",
|
||||||
|
"test:playwright": "npx playwright test",
|
||||||
|
"test:lighthouse": "node tests/lighthouse.js",
|
||||||
|
"setup": "npx playwright install"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git@git.nixc.us:colin/resume.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"resume",
|
||||||
|
"portfolio",
|
||||||
|
"accessibility"
|
||||||
|
],
|
||||||
|
"author": "Colin Knapp",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.42.1",
|
||||||
|
"lighthouse": "^11.4.0",
|
||||||
|
"puppeteer": "^22.4.1"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,98 @@
|
||||||
|
const lighthouse = require('lighthouse');
|
||||||
|
const puppeteer = require('puppeteer');
|
||||||
|
const { URL } = require('url');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Configuration for Lighthouse
|
||||||
|
const LIGHTHOUSE_CONFIG = {
|
||||||
|
extends: 'lighthouse:default',
|
||||||
|
settings: {
|
||||||
|
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
|
||||||
|
formFactor: 'desktop',
|
||||||
|
throttlingMethod: 'simulate',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set threshold values for each category
|
||||||
|
const THRESHOLDS = {
|
||||||
|
performance: 90,
|
||||||
|
accessibility: 90,
|
||||||
|
'best-practices': 90,
|
||||||
|
seo: 90,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runLighthouse(url) {
|
||||||
|
// Launch a headless browser
|
||||||
|
const browser = await puppeteer.launch({ headless: true });
|
||||||
|
|
||||||
|
// Create a new Lighthouse runner
|
||||||
|
const { lhr } = await lighthouse(url, {
|
||||||
|
port: (new URL(browser.wsEndpoint())).port,
|
||||||
|
output: 'json',
|
||||||
|
logLevel: 'info',
|
||||||
|
}, LIGHTHOUSE_CONFIG);
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
// Create directory for reports if it doesn't exist
|
||||||
|
const reportsDir = path.join(__dirname, 'reports');
|
||||||
|
if (!fs.existsSync(reportsDir)) {
|
||||||
|
fs.mkdirSync(reportsDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the report
|
||||||
|
const reportPath = path.join(reportsDir, `lighthouse-report-${Date.now()}.json`);
|
||||||
|
fs.writeFileSync(reportPath, JSON.stringify(lhr, null, 2));
|
||||||
|
|
||||||
|
console.log('\nLighthouse Results:');
|
||||||
|
console.log('--------------------');
|
||||||
|
|
||||||
|
// Process and display results
|
||||||
|
let allPassed = true;
|
||||||
|
Object.keys(THRESHOLDS).forEach(category => {
|
||||||
|
const score = Math.round(lhr.categories[category].score * 100);
|
||||||
|
const threshold = THRESHOLDS[category];
|
||||||
|
const passed = score >= threshold;
|
||||||
|
|
||||||
|
if (!passed) {
|
||||||
|
allPassed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${category}: ${score}/100 - ${passed ? 'PASS' : 'FAIL'} (Threshold: ${threshold})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Display audits that failed
|
||||||
|
console.log('\nFailed Audits:');
|
||||||
|
console.log('--------------');
|
||||||
|
let hasFailedAudits = false;
|
||||||
|
|
||||||
|
Object.values(lhr.audits).forEach(audit => {
|
||||||
|
if (audit.score !== null && audit.score < 0.9) {
|
||||||
|
hasFailedAudits = true;
|
||||||
|
console.log(`- ${audit.title}: ${Math.round(audit.score * 100)}/100`);
|
||||||
|
console.log(` ${audit.description}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasFailedAudits) {
|
||||||
|
console.log('No significant audit failures!');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDetailed report saved to: ${reportPath}`);
|
||||||
|
|
||||||
|
return allPassed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL to test - this should be your local server address
|
||||||
|
const url = process.argv[2] || 'http://localhost:8080';
|
||||||
|
|
||||||
|
runLighthouse(url)
|
||||||
|
.then(passed => {
|
||||||
|
console.log(`\nOverall: ${passed ? 'PASSED' : 'FAILED'}`);
|
||||||
|
process.exit(passed ? 0 : 1);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error running Lighthouse:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
|
@ -0,0 +1,73 @@
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const url = require('url');
|
||||||
|
|
||||||
|
const PORT = 8080;
|
||||||
|
const RESUME_DIR = path.join(__dirname, '..', 'docker', 'resume');
|
||||||
|
|
||||||
|
// MIME types for common file extensions
|
||||||
|
const MIME_TYPES = {
|
||||||
|
'.html': 'text/html',
|
||||||
|
'.css': 'text/css',
|
||||||
|
'.js': 'text/javascript',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.txt': 'text/plain',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a simple HTTP server
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
// Parse the request URL
|
||||||
|
const parsedUrl = url.parse(req.url);
|
||||||
|
let pathname = parsedUrl.pathname;
|
||||||
|
|
||||||
|
// Set default file to index.html
|
||||||
|
if (pathname === '/') {
|
||||||
|
pathname = '/index.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the file path
|
||||||
|
const filePath = path.join(RESUME_DIR, pathname);
|
||||||
|
|
||||||
|
// Get the file extension
|
||||||
|
const ext = path.extname(filePath);
|
||||||
|
|
||||||
|
// Set the content type based on the file extension
|
||||||
|
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
// Read the file and serve it
|
||||||
|
fs.readFile(filePath, (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
// If the file doesn't exist, return 404
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('404 Not Found');
|
||||||
|
console.log(`404: ${pathname}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other errors, return 500
|
||||||
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('500 Internal Server Error');
|
||||||
|
console.error(`Error serving ${pathname}:`, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If file is found, serve it with the correct content type
|
||||||
|
res.writeHead(200, { 'Content-Type': contentType });
|
||||||
|
res.end(data);
|
||||||
|
console.log(`200: ${pathname}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Server running at http://localhost:${PORT}/`);
|
||||||
|
console.log(`Serving files from: ${RESUME_DIR}`);
|
||||||
|
});
|
|
@ -0,0 +1,62 @@
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('Theme Toggle Tests', () => {
|
||||||
|
test('theme toggle should cycle through modes', async ({ page }) => {
|
||||||
|
// Serve the site locally for testing
|
||||||
|
await page.goto('http://localhost:8080');
|
||||||
|
|
||||||
|
// Get the theme toggle button
|
||||||
|
const themeToggle = await page.locator('#themeToggle');
|
||||||
|
|
||||||
|
// Verify initial state (should be auto)
|
||||||
|
let ariaLabel = await themeToggle.getAttribute('aria-label');
|
||||||
|
expect(ariaLabel).toBe('Theme mode: Auto');
|
||||||
|
|
||||||
|
// Click to change to light mode
|
||||||
|
await themeToggle.click();
|
||||||
|
ariaLabel = await themeToggle.getAttribute('aria-label');
|
||||||
|
expect(ariaLabel).toBe('Theme mode: Light');
|
||||||
|
|
||||||
|
// Verify data-theme attribute is set to light
|
||||||
|
const dataTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||||
|
expect(dataTheme).toBe('light');
|
||||||
|
|
||||||
|
// Click to change to dark mode
|
||||||
|
await themeToggle.click();
|
||||||
|
ariaLabel = await themeToggle.getAttribute('aria-label');
|
||||||
|
expect(ariaLabel).toBe('Theme mode: Dark');
|
||||||
|
|
||||||
|
// Verify data-theme attribute is set to dark
|
||||||
|
const darkDataTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||||
|
expect(darkDataTheme).toBe('dark');
|
||||||
|
|
||||||
|
// Click to change back to auto mode
|
||||||
|
await themeToggle.click();
|
||||||
|
ariaLabel = await themeToggle.getAttribute('aria-label');
|
||||||
|
expect(ariaLabel).toBe('Theme mode: Auto');
|
||||||
|
|
||||||
|
// Verify data-theme attribute is removed
|
||||||
|
const autoDataTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||||
|
expect(autoDataTheme).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('theme toggle should be keyboard accessible', async ({ page }) => {
|
||||||
|
await page.goto('http://localhost:8080');
|
||||||
|
|
||||||
|
// Focus the theme toggle button using Tab
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
|
// Verify the button is focused
|
||||||
|
const isFocused = await page.evaluate(() => {
|
||||||
|
return document.activeElement.id === 'themeToggle';
|
||||||
|
});
|
||||||
|
expect(isFocused).toBeTruthy();
|
||||||
|
|
||||||
|
// Activate with space key
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
|
||||||
|
// Verify it changes to light mode
|
||||||
|
const ariaLabel = await page.locator('#themeToggle').getAttribute('aria-label');
|
||||||
|
expect(ariaLabel).toBe('Theme mode: Light');
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue