forked from colin/resume
Add Cal.com calendar meeting links
This commit is contained in:
parent
cd94db9c03
commit
10e340c341
|
@ -23,7 +23,8 @@
|
||||||
<div class="container-fluid" role="main">
|
<div class="container-fluid" role="main">
|
||||||
<h1>Colin Knapp</h1>
|
<h1>Colin Knapp</h1>
|
||||||
<p><strong>Location:</strong> Kitchener-Waterloo, Ontario, Canada<br>
|
<p><strong>Location:</strong> Kitchener-Waterloo, Ontario, Canada<br>
|
||||||
<strong>Contact:</strong> <a href="mailto:recruitme2025@colinknapp.com">recruitme2025@colinknapp.com</a> | <a href="https://colinknapp.com">colinknapp.com</a></p>
|
<strong>Contact:</strong> <a href="mailto:recruitme2025@colinknapp.com">recruitme2025@colinknapp.com</a> | <a href="https://colinknapp.com">colinknapp.com</a><br>
|
||||||
|
<strong>Schedule a Meeting:</strong> <a href="https://cal.com/colin-/30min" target="_blank">30 Minute Meeting</a> | <a href="https://cal.com/colin-/60-min-meeting" target="_blank">60 Minute Meeting</a></p>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
|
|
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
|
@ -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=');
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue