Colin Knapp

Location: Kitchener-Waterloo, Ontario, Canada
- Contact: recruitme2025@colinknapp.com | colinknapp.com

+ Contact: recruitme2025@colinknapp.com | colinknapp.com
+ Schedule a Meeting: 30 Minute Meeting | 60 Minute Meeting


diff --git a/tests/accessibility.test.js b/tests/accessibility.test.js new file mode 100644 index 0000000..b895c8e --- /dev/null +++ b/tests/accessibility.test.js @@ -0,0 +1,194 @@ +const { test, expect } = require('@playwright/test'); +const { AxeBuilder } = require('@axe-core/playwright'); + +const PRODUCTION_URL = 'https://colinknapp.com'; +const LOCAL_URL = 'http://localhost:8080'; + +async function getPageUrl(page) { + try { + // Try production first + await page.goto(PRODUCTION_URL, { timeout: 60000 }); + return PRODUCTION_URL; + } catch (error) { + console.log('Production site not available, falling back to local'); + await page.goto(LOCAL_URL, { timeout: 60000 }); + return LOCAL_URL; + } +} + +test.describe('Accessibility Tests', () => { + test('should pass WCAG 2.1 Level AAA standards', async ({ page }) => { + const url = await getPageUrl(page); + console.log(`Running accessibility tests against ${url}`); + + // Run axe accessibility tests + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag2aaa']) + .analyze(); + + // Check for any violations + expect(results.violations).toHaveLength(0); + }); + + test('should have proper ARIA attributes for theme toggle', async ({ page }) => { + const url = await getPageUrl(page); + console.log(`Running ARIA tests against ${url}`); + + // Check theme toggle button + const themeToggle = await page.locator('#themeToggle'); + expect(await themeToggle.getAttribute('aria-label')).toBe('Theme mode: Auto'); + expect(await themeToggle.getAttribute('role')).toBe('switch'); + expect(await themeToggle.getAttribute('aria-checked')).toBe('false'); + expect(await themeToggle.getAttribute('title')).toBe('Toggle between light, dark, and auto theme modes'); + expect(await themeToggle.getAttribute('tabindex')).toBe('0'); + }); + + test('should have proper heading structure', async ({ page }) => { + const url = await getPageUrl(page); + console.log(`Running heading structure tests against ${url}`); + + // Check main content area + const mainContent = await page.locator('.container-fluid'); + expect(await mainContent.getAttribute('role')).toBe('main'); + + // Check heading hierarchy + const h1 = await page.locator('h1'); + expect(await h1.count()).toBe(1); + + const h2s = await page.locator('h2'); + expect(await h2s.count()).toBeGreaterThan(0); + }); + + test('should have working external links', async ({ page, request }) => { + const url = await getPageUrl(page); + console.log(`Running link validation tests against ${url}`); + await page.goto(url, { timeout: 60000 }); + await page.waitForLoadState('networkidle'); + + // Get all external links + const externalLinks = await page.$$('a[href^="http"]:not([href^="http://localhost"])'); + + // Skip test if no external links found + if (externalLinks.length === 0) { + console.log('No external links found, skipping test'); + return; + } + + const brokenLinks = []; + for (const link of externalLinks) { + const href = await link.getAttribute('href'); + if (!href) continue; + + try { + const response = await request.head(href); + if (response.status() >= 400) { + brokenLinks.push({ + href, + status: response.status() + }); + } + } catch (error) { + brokenLinks.push({ + href, + error: error.message + }); + } + } + + if (brokenLinks.length > 0) { + console.log('\nBroken or inaccessible links:'); + brokenLinks.forEach(link => { + if (link.error) { + console.log(`- ${link.href}: ${link.error}`); + } else { + console.log(`- ${link.href}: HTTP ${link.status}`); + } + }); + throw new Error('Some external links are broken or inaccessible'); + } + }); + + test('should have proper color contrast', async ({ page }) => { + const url = await getPageUrl(page); + console.log(`Running color contrast tests against ${url}`); + await page.goto(url, { timeout: 60000 }); + await page.waitForLoadState('networkidle'); + + // Check text color contrast in both light and dark modes + const contrastInfo = await page.evaluate(() => { + const getContrastRatio = (color1, color2) => { + const getLuminance = (r, g, b) => { + const [rs, gs, bs] = [r, g, b].map(c => { + c = c / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }); + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; + }; + + const parseColor = (color) => { + const rgb = color.match(/\d+/g).map(Number); + return rgb.length === 3 ? rgb : [0, 0, 0]; + }; + + const l1 = getLuminance(...parseColor(color1)); + const l2 = getLuminance(...parseColor(color2)); + const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); + return ratio.toFixed(2); + }; + + const style = getComputedStyle(document.body); + const textColor = style.color; + const backgroundColor = style.backgroundColor; + const contrastRatio = getContrastRatio(textColor, backgroundColor); + + return { + textColor, + backgroundColor, + contrastRatio: parseFloat(contrastRatio) + }; + }); + + console.log('Color contrast information:', contrastInfo); + + // WCAG 2.1 Level AAA requires a contrast ratio of at least 7:1 for normal text + expect(contrastInfo.contrastRatio).toBeGreaterThanOrEqual(7); + }); + + test('should have alt text for all images', async ({ page }) => { + const url = await getPageUrl(page); + console.log(`Running image alt text tests against ${url}`); + await page.goto(url, { timeout: 60000 }); + await page.waitForLoadState('networkidle'); + + // Get all images + const images = await page.$$('img'); + + // Skip test if no images found + if (images.length === 0) { + console.log('No images found, skipping test'); + return; + } + + const missingAlt = []; + for (const img of images) { + const alt = await img.getAttribute('alt'); + const src = await img.getAttribute('src'); + + // Skip decorative images (empty alt is fine) + const role = await img.getAttribute('role'); + if (role === 'presentation' || role === 'none') { + continue; + } + + if (!alt) { + missingAlt.push(src); + } + } + + if (missingAlt.length > 0) { + console.log('\nImages missing alt text:'); + missingAlt.forEach(src => console.log(`- ${src}`)); + throw new Error('Some images are missing alt text'); + } + }); +}); \ No newline at end of file diff --git a/tests/headers.test.js b/tests/headers.test.js new file mode 100644 index 0000000..d39812f --- /dev/null +++ b/tests/headers.test.js @@ -0,0 +1,85 @@ +const { test, expect } = require('@playwright/test'); + +const PRODUCTION_URL = 'https://colinknapp.com'; +const LOCAL_URL = 'http://localhost:8080'; + +async function getPageUrl(page) { + try { + // Try production first + await page.goto(PRODUCTION_URL, { timeout: 60000 }); + return PRODUCTION_URL; + } catch (error) { + console.log('Production site not available, falling back to local'); + await page.goto(LOCAL_URL, { timeout: 60000 }); + return LOCAL_URL; + } +} + +test.describe('Security Headers Tests', () => { + test('should have all required security headers', async ({ page, request }) => { + const url = await getPageUrl(page); + console.log(`Running security header tests against ${url}`); + + // Get headers directly from the main page + const response = await request.get(url); + const headers = response.headers(); + + // Check Content Security Policy + const csp = headers['content-security-policy']; + expect(csp).toBeTruthy(); + expect(csp).toContain("default-src 'none'"); + expect(csp).toContain("script-src 'self'"); + expect(csp).toContain("style-src 'self'"); + expect(csp).toContain("img-src 'self' data:"); + expect(csp).toContain("font-src 'self' data:"); + expect(csp).toContain("connect-src 'self'"); + expect(csp).toContain("object-src 'none'"); + expect(csp).toContain("frame-ancestors 'none'"); + expect(csp).toContain("base-uri 'none'"); + expect(csp).toContain("form-action 'none'"); + + // Check other security headers + expect(headers['x-content-type-options']).toBe('nosniff'); + expect(headers['x-frame-options']).toBe('DENY'); + expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin'); + }); + + test('should have correct Subresource Integrity (SRI) attributes', async ({ page }) => { + const url = await getPageUrl(page); + console.log(`Running SRI tests against ${url}`); + await page.goto(url, { timeout: 60000 }); + await page.waitForLoadState('networkidle'); + + // Check stylesheet + const stylesheet = await page.locator('link[rel="stylesheet"]').first(); + const stylesheetIntegrity = await stylesheet.getAttribute('integrity'); + const stylesheetCrossorigin = await stylesheet.getAttribute('crossorigin'); + expect(stylesheetIntegrity).toBeTruthy(); + expect(stylesheetCrossorigin).toBe('anonymous'); + + // Check script + const script = await page.locator('script[src]').first(); + const scriptIntegrity = await script.getAttribute('integrity'); + const scriptCrossorigin = await script.getAttribute('crossorigin'); + expect(scriptIntegrity).toBeTruthy(); + expect(scriptCrossorigin).toBe('anonymous'); + }); + + test('should have correct caching headers for static assets', async ({ request }) => { + const url = await getPageUrl({ goto: async () => {} }); + console.log(`Running caching header tests against ${url}`); + const baseUrl = url.replace(/\/$/, ''); + + // Check styles.css + const stylesResponse = await request.get(`${baseUrl}/styles.css`); + const stylesCacheControl = stylesResponse.headers()['cache-control']; + expect(stylesCacheControl).toContain('public'); + expect(stylesCacheControl).toContain('max-age='); + + // Check theme.js + const scriptResponse = await request.get(`${baseUrl}/theme.js`); + const scriptCacheControl = scriptResponse.headers()['cache-control']; + expect(scriptCacheControl).toContain('public'); + expect(scriptCacheControl).toContain('max-age='); + }); +}); \ No newline at end of file