Latest Updated Pages
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
Deploy to Production / Build & Verify (push) Failing after 13s
Ping Search Engines / Notify Search Engines (push) Successful in 3s
Deploy to Production / Pre-Deploy Tests (push) Has been skipped
Deploy to Production / Deploy to Railway (push) Has been skipped
Deploy to Production / Deploy to Render (push) Has been skipped
Deploy to Production / Deploy to VPS (PM2) (push) Has been skipped
Deploy to Production / Deploy to Fly.io (push) Has been skipped
Deploy to Production / Post-Deploy Verification (push) Has been skipped
Deploy to Production / Notify on Failure (push) Successful in 1s
E2E Test Suite / Smoke Tests (P0) (push) Failing after 9m36s
E2E Test Suite / Form Interaction Tests (push) Failing after 12m6s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 11m46s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 9m31s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 11m5s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 15m24s
E2E Test Suite / Security Header Tests (push) Failing after 7m55s
E2E Test Suite / Test Report Summary (push) Failing after 6s
E2E Test Suite / Mobile Device Tests (push) Failing after 3h12m28s
Uptime Monitor / Health & Response Time (push) Successful in 5s
Uptime Monitor / SSL Certificate (push) Successful in 3s
Uptime Monitor / Send Alerts (push) Has been skipped
Uptime Monitor / Record Uptime Success (push) Successful in 2s
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
Deploy to Production / Build & Verify (push) Failing after 13s
Ping Search Engines / Notify Search Engines (push) Successful in 3s
Deploy to Production / Pre-Deploy Tests (push) Has been skipped
Deploy to Production / Deploy to Railway (push) Has been skipped
Deploy to Production / Deploy to Render (push) Has been skipped
Deploy to Production / Deploy to VPS (PM2) (push) Has been skipped
Deploy to Production / Deploy to Fly.io (push) Has been skipped
Deploy to Production / Post-Deploy Verification (push) Has been skipped
Deploy to Production / Notify on Failure (push) Successful in 1s
E2E Test Suite / Smoke Tests (P0) (push) Failing after 9m36s
E2E Test Suite / Form Interaction Tests (push) Failing after 12m6s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 11m46s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 9m31s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 11m5s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 15m24s
E2E Test Suite / Security Header Tests (push) Failing after 7m55s
E2E Test Suite / Test Report Summary (push) Failing after 6s
E2E Test Suite / Mobile Device Tests (push) Failing after 3h12m28s
Uptime Monitor / Health & Response Time (push) Successful in 5s
Uptime Monitor / SSL Certificate (push) Successful in 3s
Uptime Monitor / Send Alerts (push) Has been skipped
Uptime Monitor / Record Uptime Success (push) Successful in 2s
This commit is contained in:
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* Bug Fix Verification Tests
|
||||
* Targeted regression tests for 5 fixed bugs.
|
||||
* These tests would have caught the original issues and will catch regressions.
|
||||
*
|
||||
* BUG-1: Duplicate footer on Portfolio page
|
||||
* BUG-2: Blog page theme styling issues
|
||||
* BUG-3: Read Blog post 500 error
|
||||
* BUG-4: CORS issues on Contact/Newsletter forms
|
||||
* BUG-5: Header text black in dark mode
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// ============================================================
|
||||
// BUG-1: Duplicate footer on Portfolio page
|
||||
// ============================================================
|
||||
test.describe('BUG-1: Portfolio page has exactly one footer', () => {
|
||||
test('Portfolio page renders exactly one footer element', async ({ page }) => {
|
||||
await page.goto('/portfolio', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const footers = page.locator('footer');
|
||||
await expect(footers).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Portfolio page footer is visible', async ({ page }) => {
|
||||
await page.goto('/portfolio', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('footer')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Portfolio page has one header and one footer (no duplicates)', async ({ page }) => {
|
||||
await page.goto('/portfolio', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Exactly one header
|
||||
await expect(page.locator('#main-header')).toHaveCount(1);
|
||||
|
||||
// Exactly one footer
|
||||
await expect(page.locator('footer')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Other pages also have exactly one footer (sanity check)', async ({ page }) => {
|
||||
const pages = ['/', '/about', '/services', '/contact', '/blog'];
|
||||
|
||||
for (const url of pages) {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
const count = await page.locator('footer').count();
|
||||
expect(count, `${url} should have exactly 1 footer, found ${count}`).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// BUG-2: Blog page theme styling issues
|
||||
// ============================================================
|
||||
test.describe('BUG-2: Blog page theme styling', () => {
|
||||
test('Blog index page loads without layout errors', async ({ page }) => {
|
||||
const consoleErrors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
||||
});
|
||||
|
||||
const response = await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
||||
expect(response?.status()).toBe(200);
|
||||
|
||||
const critical = consoleErrors.filter(
|
||||
(e) => !e.includes('favicon') && !e.includes('livereload') && !e.includes('manifest')
|
||||
);
|
||||
expect(critical).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Blog page applies dark mode classes when dark mode is active', async ({ page }) => {
|
||||
await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Force dark mode
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
});
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Verify dark class is applied to html element
|
||||
const hasDarkClass = await page.evaluate(() =>
|
||||
document.documentElement.classList.contains('dark')
|
||||
);
|
||||
expect(hasDarkClass).toBe(true);
|
||||
});
|
||||
|
||||
test('Blog page hero section background is not transparent in dark mode', async ({ page }) => {
|
||||
await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
});
|
||||
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Hero section should have a visible background (secondary-900 dark gradient)
|
||||
const heroSection = page.locator('section').first();
|
||||
await expect(heroSection).toBeVisible();
|
||||
|
||||
const bgColor = await heroSection.evaluate((el) =>
|
||||
window.getComputedStyle(el).backgroundColor
|
||||
);
|
||||
|
||||
// Should NOT be pure white (rgb(255, 255, 255)) in dark mode
|
||||
// The actual color depends on Tailwind's secondary-900, but it should be dark
|
||||
expect(bgColor).not.toBe('rgba(0, 0, 0, 0)');
|
||||
});
|
||||
|
||||
test('Blog h1 is visible in both light and dark mode', async ({ page }) => {
|
||||
await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
|
||||
// Switch to dark mode
|
||||
await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// H1 still visible
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// BUG-3: Read Blog post 500 error
|
||||
// ============================================================
|
||||
test.describe('BUG-3: Blog post route never returns 500', () => {
|
||||
test('Blog index page never returns 500', async ({ page }) => {
|
||||
const response = await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
||||
expect(response?.status()).not.toBe(500);
|
||||
expect(response?.status()).toBe(200);
|
||||
});
|
||||
|
||||
test('Blog post with invalid slug redirects (never 500)', async ({ page }) => {
|
||||
// A non-existent slug should return 404 redirect, not 500
|
||||
const response = await page.goto('/blog/this-post-definitely-does-not-exist-xyz', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
|
||||
// Should redirect to /404 (200 for custom error page) or return actual 404
|
||||
// Critical: MUST NOT be 500
|
||||
const status = response?.status();
|
||||
expect(status).not.toBe(500);
|
||||
});
|
||||
|
||||
test('Blog post with empty slug path redirects to /blog (never 500)', async ({ page }) => {
|
||||
// The slug guard redirects empty slug to /blog
|
||||
const response = await page.goto('/blog/', { waitUntil: 'domcontentloaded' });
|
||||
expect(response?.status()).not.toBe(500);
|
||||
});
|
||||
|
||||
test('First blog post (if exists) loads successfully without 500', async ({ page }) => {
|
||||
await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const firstPostLink = page.locator('a[href^="/blog/"]').first();
|
||||
const hasPost = (await firstPostLink.count()) > 0;
|
||||
|
||||
if (!hasPost) {
|
||||
test.skip(); // No posts in content collection — skip gracefully
|
||||
return;
|
||||
}
|
||||
|
||||
const postHref = await firstPostLink.getAttribute('href');
|
||||
if (!postHref) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await page.goto(postHref, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Critical assertion: never a 500
|
||||
expect(response?.status()).not.toBe(500);
|
||||
expect(response?.status()).toBe(200);
|
||||
|
||||
// Has rendered content (markdown rendered to HTML)
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
const bodyText = await page.locator('body').innerText();
|
||||
expect(bodyText.trim().length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test('Blog post SSR renders with BaseLayout (header + footer present)', async ({ page }) => {
|
||||
await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const firstPostLink = page.locator('a[href^="/blog/"]').first();
|
||||
if ((await firstPostLink.count()) === 0) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
await firstPostLink.click();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// BaseLayout provides these — presence confirms SSR completed without crash
|
||||
await expect(page.locator('#main-header')).toBeAttached();
|
||||
await expect(page.locator('footer')).toBeAttached();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// BUG-4: CORS issues on Contact & Newsletter forms
|
||||
// ============================================================
|
||||
test.describe('BUG-4: CORS headers on API endpoints', () => {
|
||||
test('Contact API OPTIONS preflight returns 204 with CORS headers', async ({ page }) => {
|
||||
// Use fetch to send OPTIONS preflight
|
||||
const result = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'http://localhost:10000',
|
||||
'Access-Control-Request-Method': 'POST',
|
||||
'Access-Control-Request-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
return {
|
||||
status: res.status,
|
||||
allowOrigin: res.headers.get('access-control-allow-origin'),
|
||||
allowMethods: res.headers.get('access-control-allow-methods'),
|
||||
allowHeaders: res.headers.get('access-control-allow-headers'),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.status).toBe(204);
|
||||
expect(result.allowOrigin).toBeTruthy();
|
||||
expect(result.allowMethods).toContain('POST');
|
||||
});
|
||||
|
||||
test('Newsletter API OPTIONS preflight returns 204 with CORS headers', async ({ page }) => {
|
||||
const result = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/newsletter', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'http://localhost:10000',
|
||||
'Access-Control-Request-Method': 'POST',
|
||||
'Access-Control-Request-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
return {
|
||||
status: res.status,
|
||||
allowOrigin: res.headers.get('access-control-allow-origin'),
|
||||
allowMethods: res.headers.get('access-control-allow-methods'),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.status).toBe(204);
|
||||
expect(result.allowOrigin).toBeTruthy();
|
||||
expect(result.allowMethods).toContain('POST');
|
||||
});
|
||||
|
||||
test('Contact API POST returns CORS headers (not missing)', async ({ page }) => {
|
||||
const result = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: '', email: '', subject: '', message: '' }),
|
||||
});
|
||||
return {
|
||||
status: res.status,
|
||||
allowOrigin: res.headers.get('access-control-allow-origin'),
|
||||
contentType: res.headers.get('content-type'),
|
||||
};
|
||||
});
|
||||
|
||||
// Status is validation error (422) or rate limit — but CORS headers must be present
|
||||
expect(result.allowOrigin).toBeTruthy();
|
||||
expect(result.contentType).toContain('application/json');
|
||||
// Critical: must NOT be a CORS error (which would give status 0 or throw)
|
||||
expect(result.status).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('Newsletter API POST returns CORS headers with invalid email', async ({ page }) => {
|
||||
const result = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/newsletter', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'not-valid-email' }),
|
||||
});
|
||||
return {
|
||||
status: res.status,
|
||||
allowOrigin: res.headers.get('access-control-allow-origin'),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.status).toBe(422); // Validation error
|
||||
expect(result.allowOrigin).toBeTruthy(); // CORS header present
|
||||
});
|
||||
|
||||
test('Contact form page submits without JS CORS error', async ({ page }) => {
|
||||
const corsErrors: string[] = [];
|
||||
|
||||
// Catch CORS-related console errors
|
||||
page.on('console', (msg) => {
|
||||
if (
|
||||
msg.type() === 'error' &&
|
||||
(msg.text().includes('CORS') || msg.text().includes('Access-Control') || msg.text().includes('cross-origin'))
|
||||
) {
|
||||
corsErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Attempt a form submission with invalid data (will get 422, but no CORS error)
|
||||
await page.fill('#name', 'Test');
|
||||
await page.fill('#email', 'test@example.com');
|
||||
await page.selectOption('#subject', 'web-development');
|
||||
await page.fill('#message', 'This is a test submission for CORS verification');
|
||||
await page.locator('#contact-form button[type="submit"]').click();
|
||||
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
expect(corsErrors, 'CORS errors detected on form submission').toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Newsletter form in footer submits without CORS error', async ({ page }) => {
|
||||
const corsErrors: string[] = [];
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (
|
||||
msg.type() === 'error' &&
|
||||
(msg.text().includes('CORS') || msg.text().includes('Access-Control'))
|
||||
) {
|
||||
corsErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Scroll to footer newsletter form
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const newsletterInput = page.locator('footer input[type="email"]');
|
||||
if (await newsletterInput.count() > 0) {
|
||||
await newsletterInput.fill('test@example.com');
|
||||
const submitBtn = page.locator('footer button[type="submit"]').first();
|
||||
if (await submitBtn.count() > 0) {
|
||||
await submitBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
}
|
||||
|
||||
expect(corsErrors, 'CORS errors on newsletter form').toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// BUG-5: Header text black in dark mode
|
||||
// ============================================================
|
||||
test.describe('BUG-5: Header text readable in dark mode', () => {
|
||||
test('WorkRoot logo text is white (not black) in dark mode', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Activate dark mode
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
});
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Find the WorkRoot logo span in the desktop header
|
||||
const logoSpan = page.locator('#main-header span').filter({ hasText: 'WorkRoot' }).first();
|
||||
await expect(logoSpan).toBeVisible();
|
||||
|
||||
const color = await logoSpan.evaluate((el) =>
|
||||
window.getComputedStyle(el).color
|
||||
);
|
||||
|
||||
// In dark mode, should be white (#ffffff = rgb(255, 255, 255))
|
||||
// Original broken state was rgb(30, 41, 59) = #1e293b (near black)
|
||||
expect(color).not.toBe('rgb(30, 41, 59)'); // NOT the old broken black color
|
||||
expect(color).toBe('rgb(255, 255, 255)'); // IS the fixed white color
|
||||
});
|
||||
|
||||
test('WorkRoot logo text is dark (not white) in light mode', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Ensure light mode
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
});
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const logoSpan = page.locator('#main-header span').filter({ hasText: 'WorkRoot' }).first();
|
||||
await expect(logoSpan).toBeVisible();
|
||||
|
||||
const color = await logoSpan.evaluate((el) =>
|
||||
window.getComputedStyle(el).color
|
||||
);
|
||||
|
||||
// In light mode, should be dark text (text-secondary = #1e293b)
|
||||
expect(color).toBe('rgb(30, 41, 59)'); // Dark color in light mode is correct
|
||||
});
|
||||
|
||||
test('Header logo is readable on all pages in dark mode', async ({ page }) => {
|
||||
const testPages = ['/', '/about', '/services', '/portfolio', '/blog', '/contact'];
|
||||
|
||||
for (const url of testPages) {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
});
|
||||
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const logoSpan = page.locator('#main-header span').filter({ hasText: 'WorkRoot' }).first();
|
||||
await expect(logoSpan).toBeVisible();
|
||||
|
||||
const color = await logoSpan.evaluate((el) =>
|
||||
window.getComputedStyle(el).color
|
||||
);
|
||||
|
||||
// Must NOT be the broken black color
|
||||
expect(
|
||||
color,
|
||||
`Logo text on ${url} in dark mode should be white, not black`
|
||||
).not.toBe('rgb(30, 41, 59)');
|
||||
}
|
||||
});
|
||||
|
||||
test('Mobile menu WorkRoot text is white in dark mode', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Activate dark mode
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
});
|
||||
|
||||
// Open mobile menu
|
||||
await page.locator('#mobile-menu-toggle').click();
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// Mobile menu logo span
|
||||
const mobileLogoSpan = page.locator('#mobile-menu span').filter({ hasText: 'WorkRoot' }).first();
|
||||
|
||||
// May or may not have a WorkRoot span in mobile menu, check gracefully
|
||||
if ((await mobileLogoSpan.count()) > 0) {
|
||||
const color = await mobileLogoSpan.evaluate((el) =>
|
||||
window.getComputedStyle(el).color
|
||||
);
|
||||
expect(color).not.toBe('rgb(30, 41, 59)');
|
||||
}
|
||||
});
|
||||
|
||||
test('Header contrast is WCAG AA compliant in dark mode', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
});
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const header = page.locator('#main-header');
|
||||
const logoSpan = page.locator('#main-header span').filter({ hasText: 'WorkRoot' }).first();
|
||||
|
||||
const headerBg = await header.evaluate((el) =>
|
||||
window.getComputedStyle(el).backgroundColor
|
||||
);
|
||||
const textColor = await logoSpan.evaluate((el) =>
|
||||
window.getComputedStyle(el).color
|
||||
);
|
||||
|
||||
// Both colors are set — not transparent
|
||||
expect(headerBg).not.toBe('rgba(0, 0, 0, 0)');
|
||||
expect(textColor).not.toBe('rgba(0, 0, 0, 0)');
|
||||
|
||||
// White text on dark bg = ~17.5:1 contrast ratio (WAY above 4.5:1 AA)
|
||||
// Just verify it's not the broken identical-to-background color
|
||||
expect(textColor).not.toBe(headerBg);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Cross-cutting: All fixes together — full page smoke
|
||||
// ============================================================
|
||||
test.describe('All Fixes: Full page smoke test', () => {
|
||||
test('Home page loads cleanly with correct layout', async ({ page }) => {
|
||||
const response = await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator('#main-header')).toHaveCount(1);
|
||||
await expect(page.locator('footer')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Portfolio page loads with single footer and working filters', async ({ page }) => {
|
||||
await page.goto('/portfolio', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('footer')).toHaveCount(1);
|
||||
await expect(page.locator('.project-card').first()).toBeVisible();
|
||||
await page.locator('button[data-filter="web"]').click();
|
||||
await page.waitForTimeout(400);
|
||||
expect(await page.locator('.project-card:not(.hidden)').count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('Blog page loads without 500 and with correct theme structure', async ({ page }) => {
|
||||
const response = await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
||||
expect(response?.status()).not.toBe(500);
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
await expect(page.locator('footer')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Contact page has form with working API endpoint (no CORS error)', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
|
||||
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('#contact-form')).toBeVisible();
|
||||
|
||||
// Verify OPTIONS preflight works (simulates browser CORS check)
|
||||
const preflightResult = await page.evaluate(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/contact', { method: 'OPTIONS' });
|
||||
return { ok: true, status: res.status };
|
||||
} catch {
|
||||
return { ok: false, status: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
expect(preflightResult.ok).toBe(true);
|
||||
expect(preflightResult.status).toBe(204);
|
||||
|
||||
const criticalErrors = errors.filter(
|
||||
(e) => !e.includes('favicon') && !e.includes('manifest') && !e.includes('livereload')
|
||||
);
|
||||
expect(criticalErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Dark mode toggle works and header stays readable', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Toggle dark mode via ThemeToggle button
|
||||
const themeToggle = page.locator('#theme-toggle').first();
|
||||
if (await themeToggle.count() > 0) {
|
||||
await themeToggle.click();
|
||||
await page.waitForTimeout(300);
|
||||
} else {
|
||||
// Force dark mode programmatically
|
||||
await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
// Header is visible
|
||||
await expect(page.locator('#main-header')).toBeVisible();
|
||||
|
||||
// Logo text exists and is not black
|
||||
const logoSpan = page.locator('#main-header span').filter({ hasText: 'WorkRoot' }).first();
|
||||
if (await logoSpan.count() > 0) {
|
||||
const color = await logoSpan.evaluate((el) => window.getComputedStyle(el).color);
|
||||
expect(color).not.toBe('rgb(30, 41, 59)');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -818,3 +818,520 @@ test.describe('Header Scroll Behavior', () => {
|
||||
await expect(page.locator('#main-header')).not.toHaveClass(/header-scrolled/);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 11. Redesigned Contact Page — New Elements
|
||||
// ============================================================
|
||||
test.describe('Contact Page: Redesigned Elements', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
|
||||
});
|
||||
|
||||
test('Trust stats strip renders with 4 stat cards', async ({ page }) => {
|
||||
// Hero section contains 4 trust stat cards
|
||||
const statCards = page.locator('section').first().locator('.text-2xl.font-bold.text-white');
|
||||
await expect(statCards).toHaveCount(4);
|
||||
});
|
||||
|
||||
test('Budget range radio chips render and are selectable', async ({ page }) => {
|
||||
const budgetOptions = page.locator('.budget-option');
|
||||
await expect(budgetOptions).toHaveCount(5);
|
||||
|
||||
// Click the first budget chip
|
||||
await budgetOptions.first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// The clicked chip should receive the "selected" class via JS
|
||||
await expect(budgetOptions.first()).toHaveClass(/selected/);
|
||||
|
||||
// Only one chip should be selected at a time
|
||||
const selectedChips = page.locator('.budget-option.selected');
|
||||
await expect(selectedChips).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Budget chips are mutually exclusive (radio behavior)', async ({ page }) => {
|
||||
const budgetOptions = page.locator('.budget-option');
|
||||
|
||||
await budgetOptions.nth(0).click();
|
||||
await page.waitForTimeout(150);
|
||||
await budgetOptions.nth(2).click();
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
// Only the second-clicked chip should be selected
|
||||
await expect(budgetOptions.nth(0)).not.toHaveClass(/selected/);
|
||||
await expect(budgetOptions.nth(2)).toHaveClass(/selected/);
|
||||
});
|
||||
|
||||
test('FAQ accordion sections render (4 questions)', async ({ page }) => {
|
||||
const faqItems = page.locator('.faq-item');
|
||||
await expect(faqItems).toHaveCount(4);
|
||||
|
||||
// All summaries are visible
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await expect(faqItems.nth(i).locator('summary')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('FAQ accordion opens and closes on click', async ({ page }) => {
|
||||
const firstFaq = page.locator('.faq-item').first();
|
||||
const summary = firstFaq.locator('summary');
|
||||
|
||||
// Initially closed
|
||||
const isOpenInitially = await firstFaq.evaluate((el) => (el as HTMLDetailsElement).open);
|
||||
expect(isOpenInitially).toBe(false);
|
||||
|
||||
// Open it
|
||||
await summary.click();
|
||||
await page.waitForTimeout(200);
|
||||
const isOpenAfterClick = await firstFaq.evaluate((el) => (el as HTMLDetailsElement).open);
|
||||
expect(isOpenAfterClick).toBe(true);
|
||||
|
||||
// Content is now visible
|
||||
await expect(firstFaq.locator('p')).toBeVisible();
|
||||
|
||||
// Close it again
|
||||
await summary.click();
|
||||
await page.waitForTimeout(200);
|
||||
const isClosedAgain = await firstFaq.evaluate((el) => (el as HTMLDetailsElement).open);
|
||||
expect(isClosedAgain).toBe(false);
|
||||
});
|
||||
|
||||
test('FAQ accordion is keyboard accessible', async ({ page }) => {
|
||||
const summary = page.locator('.faq-item').first().locator('summary');
|
||||
await summary.focus();
|
||||
|
||||
// Enter key should toggle open
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(200);
|
||||
const isOpen = await page.locator('.faq-item').first().evaluate((el) => (el as HTMLDetailsElement).open);
|
||||
expect(isOpen).toBe(true);
|
||||
});
|
||||
|
||||
test('Social links section renders all 4 platforms', async ({ page }) => {
|
||||
// Social links section contains 4 social link items
|
||||
const socialLinks = page.locator('.bg-secondary-900 a[aria-label]');
|
||||
await expect(socialLinks).toHaveCount(4);
|
||||
|
||||
// Each has an aria-label
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const label = await socialLinks.nth(i).getAttribute('aria-label');
|
||||
expect(label).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('Map placeholder renders with directions link', async ({ page }) => {
|
||||
// Map section has a "Get Directions" link
|
||||
const directionsLink = page.locator('a[aria-label*="Get directions"]');
|
||||
await expect(directionsLink).toBeAttached();
|
||||
await expect(directionsLink).toHaveAttribute('href', /maps\.google\.com/);
|
||||
await expect(directionsLink).toHaveAttribute('target', '_blank');
|
||||
await expect(directionsLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
test('Character counter updates as user types in message', async ({ page }) => {
|
||||
const charCount = page.locator('#char-count');
|
||||
await expect(charCount).toBeVisible();
|
||||
|
||||
const initialText = await charCount.textContent();
|
||||
expect(initialText).toBe('0 / 5000');
|
||||
|
||||
await page.fill('#message', 'Hello world');
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const updatedText = await charCount.textContent();
|
||||
expect(updatedText).toBe('11 / 5000');
|
||||
});
|
||||
|
||||
test('Character counter turns red when approaching limit', async ({ page }) => {
|
||||
// Fill message with 4600 characters (triggers red state at > 4500)
|
||||
const longMsg = 'a'.repeat(4501);
|
||||
await page.fill('#message', longMsg);
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const charCount = page.locator('#char-count');
|
||||
await expect(charCount).toHaveClass(/text-red-500/);
|
||||
});
|
||||
|
||||
test('Success state is hidden by default', async ({ page }) => {
|
||||
await expect(page.locator('#success-state')).toHaveClass(/hidden/);
|
||||
await expect(page.locator('#contact-form')).not.toHaveClass(/hidden/);
|
||||
});
|
||||
|
||||
test('Toast container is present and has live region', async ({ page }) => {
|
||||
const toastContainer = page.locator('#toast-container');
|
||||
await expect(toastContainer).toBeAttached();
|
||||
await expect(toastContainer).toHaveAttribute('aria-live', 'assertive');
|
||||
});
|
||||
|
||||
test('Scroll-reveal elements are present', async ({ page }) => {
|
||||
const revealElements = page.locator('.reveal-on-scroll');
|
||||
const count = await revealElements.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('Contact info cards render 4 cards (location, phone, email, hours)', async ({ page }) => {
|
||||
const contactCards = page.locator('.contact-card');
|
||||
await expect(contactCards).toHaveCount(4);
|
||||
|
||||
// Cards show expected headings
|
||||
const cardTitles = await page.locator('.contact-card h3').allTextContents();
|
||||
expect(cardTitles).toContain('Visit Us');
|
||||
expect(cardTitles).toContain('Call Us');
|
||||
expect(cardTitles).toContain('Email Us');
|
||||
expect(cardTitles).toContain('Business Hours');
|
||||
});
|
||||
|
||||
test('CTA banner "Start a Project" scrolls to form', async ({ page }) => {
|
||||
const ctaBtn = page.locator('a[href="#contact-form"]');
|
||||
await expect(ctaBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('Contact page renders correctly at mobile viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Form is visible and usable
|
||||
await expect(page.locator('#contact-form')).toBeVisible();
|
||||
|
||||
// Budget chips wrap on mobile (they use flex-wrap)
|
||||
const budgetOptions = page.locator('.budget-option');
|
||||
await expect(budgetOptions.first()).toBeVisible();
|
||||
|
||||
// FAQ section is visible
|
||||
await expect(page.locator('.faq-item').first()).toBeVisible();
|
||||
|
||||
// Social links are visible
|
||||
await expect(page.locator('.bg-secondary-900')).toBeVisible();
|
||||
|
||||
await page.screenshot({
|
||||
path: 'tests/screenshots/mobile-contact-redesign.png',
|
||||
fullPage: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('Contact page renders correctly at tablet viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 768, height: 1024 });
|
||||
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await expect(page.locator('#contact-form')).toBeVisible();
|
||||
await expect(page.locator('.contact-card').first()).toBeVisible();
|
||||
|
||||
await page.screenshot({
|
||||
path: 'tests/screenshots/tablet-contact-redesign.png',
|
||||
fullPage: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('Contact page renders correctly at desktop viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 5-column grid: form (3 cols) + sidebar (2 cols)
|
||||
await expect(page.locator('#contact-form')).toBeVisible();
|
||||
await expect(page.locator('.contact-card').first()).toBeVisible();
|
||||
await expect(page.locator('.bg-secondary-900')).toBeVisible();
|
||||
|
||||
await page.screenshot({
|
||||
path: 'tests/screenshots/desktop-contact-redesign.png',
|
||||
fullPage: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 12. Scroll-Reveal Animations (cross-page)
|
||||
// ============================================================
|
||||
test.describe('Scroll-Reveal Animations', () => {
|
||||
const ANIMATION_PAGES = ['/', '/about', '/services', '/contact', '/portfolio'];
|
||||
|
||||
for (const url of ANIMATION_PAGES) {
|
||||
test(`Scroll-reveal elements activate on ${url}`, async ({ page }) => {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const revealElements = page.locator('.reveal-on-scroll');
|
||||
const count = await revealElements.count();
|
||||
|
||||
if (count === 0) return; // Page may not have reveal elements
|
||||
|
||||
// Simulate scrolling to trigger IntersectionObserver
|
||||
await page.evaluate(() => {
|
||||
window.scrollTo(0, document.body.scrollHeight / 2);
|
||||
});
|
||||
await page.waitForTimeout(800); // Allow CSS transitions
|
||||
|
||||
// At least some elements should be revealed
|
||||
const revealedCount = await page.locator('.reveal-on-scroll.revealed').count();
|
||||
expect(revealedCount).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('Reduced motion: reveal elements are immediately visible', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// With reduced motion, CSS transitions are disabled so revealed class may not be added,
|
||||
// but the element should still be visually accessible (opacity: 1, transform: none via CSS)
|
||||
const revealEl = page.locator('.reveal-on-scroll').first();
|
||||
await expect(revealEl).toBeAttached();
|
||||
|
||||
// Verify via computed style — reduced motion CSS sets opacity: 1 directly
|
||||
const opacity = await revealEl.evaluate((el) =>
|
||||
window.getComputedStyle(el).getPropertyValue('opacity')
|
||||
);
|
||||
expect(opacity).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 13. Cross-Browser Visual Snapshots (all redesigned pages)
|
||||
// ============================================================
|
||||
test.describe('Visual Snapshots: Redesigned Pages', () => {
|
||||
const SNAPSHOT_PAGES = [
|
||||
{ name: 'home', url: '/' },
|
||||
{ name: 'about', url: '/about' },
|
||||
{ name: 'services', url: '/services' },
|
||||
{ name: 'portfolio', url: '/portfolio' },
|
||||
{ name: 'contact', url: '/contact' },
|
||||
{ name: 'blog', url: '/blog' },
|
||||
];
|
||||
|
||||
for (const p of SNAPSHOT_PAGES) {
|
||||
test(`Desktop screenshot: ${p.name}`, async ({ page, browserName }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto(p.url, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Trigger scroll-reveal for above-fold content
|
||||
await page.evaluate(() => window.scrollTo(0, 1));
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
|
||||
await page.screenshot({
|
||||
path: `tests/screenshots/${browserName}-${p.name}-desktop.png`,
|
||||
fullPage: false,
|
||||
clip: { x: 0, y: 0, width: 1280, height: 800 },
|
||||
});
|
||||
});
|
||||
|
||||
test(`Mobile screenshot: ${p.name}`, async ({ page, browserName }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto(p.url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await page.screenshot({
|
||||
path: `tests/screenshots/${browserName}-${p.name}-mobile.png`,
|
||||
fullPage: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 14. Cross-Browser Form Validation Behavior
|
||||
// ============================================================
|
||||
test.describe('Cross-Browser: Form Validation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
|
||||
});
|
||||
|
||||
test('Submitting empty form shows validation errors', async ({ page }) => {
|
||||
const submitBtn = page.locator('#contact-form button[type="submit"]');
|
||||
await submitBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// At minimum, name, email, subject, and message errors should show
|
||||
const nameError = page.locator('[data-error="name"]');
|
||||
const emailError = page.locator('[data-error="email"]');
|
||||
const subjectError = page.locator('[data-error="subject"]');
|
||||
const messageError = page.locator('[data-error="message"]');
|
||||
|
||||
// At least one error should be visible after empty submit
|
||||
const errorsVisible = await Promise.all([
|
||||
nameError.isVisible(),
|
||||
emailError.isVisible(),
|
||||
subjectError.isVisible(),
|
||||
messageError.isVisible(),
|
||||
]);
|
||||
expect(errorsVisible.some(Boolean)).toBe(true);
|
||||
});
|
||||
|
||||
test('Valid name field clears error and shows checkmark', async ({ page }) => {
|
||||
// First trigger validation
|
||||
await page.locator('#name').fill('A'); // too short
|
||||
await page.locator('#name').blur();
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
await expect(page.locator('[data-error="name"]')).toBeVisible();
|
||||
await expect(page.locator('#name')).toHaveClass(/is-invalid/);
|
||||
|
||||
// Fix the error
|
||||
await page.locator('#name').fill('John Doe');
|
||||
await page.locator('#name').blur();
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
await expect(page.locator('[data-error="name"]')).toBeHidden();
|
||||
await expect(page.locator('#name')).toHaveClass(/is-valid/);
|
||||
});
|
||||
|
||||
test('Invalid email shows error, valid email clears it', async ({ page }) => {
|
||||
await page.locator('#email').fill('not-an-email');
|
||||
await page.locator('#email').blur();
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
await expect(page.locator('[data-error="email"]')).toBeVisible();
|
||||
await expect(page.locator('#email')).toHaveClass(/is-invalid/);
|
||||
|
||||
await page.locator('#email').fill('valid@example.com');
|
||||
await page.locator('#email').blur();
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
await expect(page.locator('[data-error="email"]')).toBeHidden();
|
||||
await expect(page.locator('#email')).toHaveClass(/is-valid/);
|
||||
});
|
||||
|
||||
test('Empty phone (optional) does not show error', async ({ page }) => {
|
||||
await page.locator('#phone').focus();
|
||||
await page.locator('#phone').blur();
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
// Phone is optional — empty value should not trigger error
|
||||
await expect(page.locator('[data-error="phone"]')).toBeHidden();
|
||||
});
|
||||
|
||||
test('Short message triggers error, adequate message clears it', async ({ page }) => {
|
||||
await page.locator('#message').fill('Hi'); // < 10 chars
|
||||
await page.locator('#message').blur();
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
await expect(page.locator('[data-error="message"]')).toBeVisible();
|
||||
|
||||
await page.locator('#message').fill('This is a valid message with enough content.');
|
||||
await page.locator('#message').blur();
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
await expect(page.locator('[data-error="message"]')).toBeHidden();
|
||||
});
|
||||
|
||||
test('Form resets correctly after "Send another message"', async ({ page }) => {
|
||||
// Pre-fill form
|
||||
await page.fill('#name', 'Test User');
|
||||
await page.fill('#email', 'test@example.com');
|
||||
await page.fill('#message', 'Test message content here.');
|
||||
|
||||
// Manually simulate success state (direct DOM manipulation)
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('contact-form')?.classList.add('hidden');
|
||||
document.getElementById('success-state')?.classList.remove('hidden');
|
||||
});
|
||||
|
||||
await expect(page.locator('#success-state')).not.toHaveClass(/hidden/);
|
||||
|
||||
// Click "Send another message"
|
||||
await page.locator('#send-another').click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Form should be visible again, success state hidden
|
||||
await expect(page.locator('#contact-form')).not.toHaveClass(/hidden/);
|
||||
await expect(page.locator('#success-state')).toHaveClass(/hidden/);
|
||||
|
||||
// Fields should be cleared
|
||||
expect(await page.locator('#name').inputValue()).toBe('');
|
||||
expect(await page.locator('#message').inputValue()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 15. Cross-Browser: Services Page Redesign
|
||||
// ============================================================
|
||||
test.describe('Services Page: Cross-Browser Layout', () => {
|
||||
test('Services page loads with all key sections', async ({ page }) => {
|
||||
await page.goto('/services', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Page loads
|
||||
expect((await page.goto('/services'))?.status()).toBe(200);
|
||||
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
await expect(page.locator('footer')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Services page has no horizontal scroll overflow at mobile', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/services', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const bodyScrollWidth = await page.evaluate(() => document.body.scrollWidth);
|
||||
const viewportWidth = await page.evaluate(() => window.innerWidth);
|
||||
|
||||
expect(bodyScrollWidth).toBeLessThanOrEqual(viewportWidth + 5); // 5px tolerance
|
||||
});
|
||||
|
||||
test('Services page has no horizontal scroll overflow at desktop', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto('/services', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const bodyScrollWidth = await page.evaluate(() => document.body.scrollWidth);
|
||||
const viewportWidth = await page.evaluate(() => window.innerWidth);
|
||||
|
||||
expect(bodyScrollWidth).toBeLessThanOrEqual(viewportWidth + 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 16. Cross-Browser: Portfolio Page Advanced Filtering
|
||||
// ============================================================
|
||||
test.describe('Portfolio Page: Advanced Filtering Cross-Browser', () => {
|
||||
test('Portfolio filter count badge updates when filtering', async ({ page }) => {
|
||||
await page.goto('/portfolio', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.project-card').first()).toBeVisible();
|
||||
|
||||
const initialCount = await page.locator('.project-card:not(.hidden)').count();
|
||||
expect(initialCount).toBeGreaterThan(0);
|
||||
|
||||
// Filter to web
|
||||
await page.locator('button[data-filter="web"]').click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const filteredCount = await page.locator('.project-card:not(.hidden)').count();
|
||||
expect(filteredCount).toBeGreaterThan(0);
|
||||
expect(filteredCount).toBeLessThanOrEqual(initialCount);
|
||||
});
|
||||
|
||||
test('Portfolio gallery modal shows project details', async ({ page }) => {
|
||||
await page.goto('/portfolio', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.view-details-btn').first()).toBeVisible();
|
||||
|
||||
await page.locator('.view-details-btn').first().click();
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const modal = page.locator('#case-study-modal');
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Modal has content
|
||||
await expect(page.locator('#modal-title')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Portfolio page has no horizontal scroll overflow at mobile', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto('/portfolio', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const bodyScrollWidth = await page.evaluate(() => document.body.scrollWidth);
|
||||
const viewportWidth = await page.evaluate(() => window.innerWidth);
|
||||
expect(bodyScrollWidth).toBeLessThanOrEqual(viewportWidth + 5);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 17. Cross-Browser: No Horizontal Overflow (all pages)
|
||||
// ============================================================
|
||||
test.describe('Cross-Browser: No Horizontal Scroll Overflow', () => {
|
||||
const CHECK_PAGES = ['/', '/about', '/services', '/portfolio', '/contact', '/blog'];
|
||||
|
||||
for (const url of CHECK_PAGES) {
|
||||
test(`No horizontal overflow at mobile on ${url}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const bodyScrollWidth = await page.evaluate(() => document.body.scrollWidth);
|
||||
const viewportWidth = await page.evaluate(() => window.innerWidth);
|
||||
expect(bodyScrollWidth).toBeLessThanOrEqual(viewportWidth + 5);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,818 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
// ============================================================
|
||||
// Theme Persistence & Synchronization Tests
|
||||
// Tests: localStorage, system preference, FOUC, multi-browser
|
||||
// ============================================================
|
||||
|
||||
// Helper: get the current theme state from the DOM
|
||||
async function getThemeState(page: Page) {
|
||||
return page.evaluate(() => ({
|
||||
hasDarkClass: document.documentElement.classList.contains('dark'),
|
||||
stored: (() => { try { return localStorage.getItem('theme'); } catch { return null; } })(),
|
||||
ariaChecked: document.querySelector('[data-theme-toggle]')?.getAttribute('aria-checked'),
|
||||
ariaLabel: document.querySelector('[data-theme-toggle]')?.getAttribute('aria-label'),
|
||||
themeColorMetas: Array.from(document.querySelectorAll('meta[name="theme-color"]')).map(m => ({
|
||||
content: m.getAttribute('content'),
|
||||
media: m.getAttribute('media'),
|
||||
})),
|
||||
liveRegion: document.querySelector('[data-theme-live]')?.textContent ?? '',
|
||||
htmlClass: document.documentElement.className,
|
||||
}));
|
||||
}
|
||||
|
||||
// Helper: set localStorage theme before page load (via storageState simulation)
|
||||
async function loadPageWithStoredTheme(page: Page, theme: 'dark' | 'light' | null, url = '/') {
|
||||
// Navigate to page first, then set storage, then reload — simulates returning visitor
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
if (theme !== null) {
|
||||
await page.evaluate((t) => {
|
||||
try { localStorage.setItem('theme', t); } catch { /* noop */ }
|
||||
}, theme);
|
||||
} else {
|
||||
await page.evaluate(() => {
|
||||
try { localStorage.removeItem('theme'); } catch { /* noop */ }
|
||||
});
|
||||
}
|
||||
// Reload to trigger the blocking init script
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
|
||||
// Helper: click the theme toggle
|
||||
async function clickThemeToggle(page: Page) {
|
||||
const toggle = page.locator('[data-theme-toggle]').first();
|
||||
await expect(toggle).toBeVisible();
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(150); // Allow state sync
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 1. localStorage Persistence
|
||||
// ============================================================
|
||||
test.describe('Theme: localStorage Persistence', () => {
|
||||
|
||||
test('Toggling to dark stores "dark" in localStorage', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, null);
|
||||
|
||||
// Start in light mode (no stored preference)
|
||||
const before = await getThemeState(page);
|
||||
// State may be light or dark depending on system — click once and check stored value
|
||||
await clickThemeToggle(page);
|
||||
|
||||
const after = await getThemeState(page);
|
||||
const isDarkNow = after.hasDarkClass;
|
||||
expect(after.stored).toBe(isDarkNow ? 'dark' : 'light');
|
||||
});
|
||||
|
||||
test('Toggling to light stores "light" in localStorage', async ({ page }) => {
|
||||
// Start with dark stored
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
|
||||
const before = await getThemeState(page);
|
||||
expect(before.hasDarkClass).toBe(true);
|
||||
|
||||
await clickThemeToggle(page);
|
||||
const after = await getThemeState(page);
|
||||
|
||||
expect(after.hasDarkClass).toBe(false);
|
||||
expect(after.stored).toBe('light');
|
||||
});
|
||||
|
||||
test('Stored "dark" preference is applied on page load', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
const state = await getThemeState(page);
|
||||
|
||||
expect(state.hasDarkClass).toBe(true);
|
||||
expect(state.stored).toBe('dark');
|
||||
});
|
||||
|
||||
test('Stored "light" preference is applied on page load', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'light');
|
||||
const state = await getThemeState(page);
|
||||
|
||||
expect(state.hasDarkClass).toBe(false);
|
||||
expect(state.stored).toBe('light');
|
||||
});
|
||||
|
||||
test('Preference persists across page navigation (About → Services)', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
|
||||
// Verify dark on home
|
||||
expect((await getThemeState(page)).hasDarkClass).toBe(true);
|
||||
|
||||
// Navigate to About
|
||||
await page.goto('/about', { waitUntil: 'domcontentloaded' });
|
||||
expect((await getThemeState(page)).hasDarkClass).toBe(true);
|
||||
|
||||
// Navigate to Services
|
||||
await page.goto('/services', { waitUntil: 'domcontentloaded' });
|
||||
expect((await getThemeState(page)).hasDarkClass).toBe(true);
|
||||
});
|
||||
|
||||
test('Light preference persists across page navigation', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'light');
|
||||
|
||||
await page.goto('/portfolio', { waitUntil: 'domcontentloaded' });
|
||||
const state = await getThemeState(page);
|
||||
expect(state.hasDarkClass).toBe(false);
|
||||
expect(state.stored).toBe('light');
|
||||
});
|
||||
|
||||
test('Theme toggle persists after navigating away and back', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, null);
|
||||
|
||||
// Toggle to dark
|
||||
await clickThemeToggle(page);
|
||||
const wasSetToDark = (await getThemeState(page)).hasDarkClass;
|
||||
|
||||
// Navigate away
|
||||
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
|
||||
const afterNav = await getThemeState(page);
|
||||
expect(afterNav.hasDarkClass).toBe(wasSetToDark);
|
||||
|
||||
// Navigate back
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
const afterBack = await getThemeState(page);
|
||||
expect(afterBack.hasDarkClass).toBe(wasSetToDark);
|
||||
});
|
||||
|
||||
test('Multiple toggles correctly alternate and store final value', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'light');
|
||||
|
||||
await clickThemeToggle(page); // → dark
|
||||
expect((await getThemeState(page)).stored).toBe('dark');
|
||||
|
||||
await clickThemeToggle(page); // → light
|
||||
expect((await getThemeState(page)).stored).toBe('light');
|
||||
|
||||
await clickThemeToggle(page); // → dark
|
||||
const final = await getThemeState(page);
|
||||
expect(final.stored).toBe('dark');
|
||||
expect(final.hasDarkClass).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 2. System Preference Detection (prefers-color-scheme)
|
||||
// ============================================================
|
||||
test.describe('Theme: System Preference Detection', () => {
|
||||
|
||||
test('No stored preference: dark OS → page loads in dark mode', async ({ page }) => {
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await loadPageWithStoredTheme(page, null);
|
||||
|
||||
const state = await getThemeState(page);
|
||||
expect(state.hasDarkClass).toBe(true);
|
||||
expect(state.stored).toBeNull(); // Should NOT store when respecting OS
|
||||
});
|
||||
|
||||
test('No stored preference: light OS → page loads in light mode', async ({ page }) => {
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await loadPageWithStoredTheme(page, null);
|
||||
|
||||
const state = await getThemeState(page);
|
||||
expect(state.hasDarkClass).toBe(false);
|
||||
expect(state.stored).toBeNull();
|
||||
});
|
||||
|
||||
test('Stored preference overrides OS preference (dark stored, light OS)', async ({ page }) => {
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
|
||||
const state = await getThemeState(page);
|
||||
expect(state.hasDarkClass).toBe(true); // Stored wins
|
||||
});
|
||||
|
||||
test('Stored preference overrides OS preference (light stored, dark OS)', async ({ page }) => {
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await loadPageWithStoredTheme(page, 'light');
|
||||
|
||||
const state = await getThemeState(page);
|
||||
expect(state.hasDarkClass).toBe(false); // Stored wins
|
||||
});
|
||||
|
||||
test('System preference change updates theme when no stored preference', async ({ page }) => {
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await loadPageWithStoredTheme(page, null);
|
||||
|
||||
// Verify light initially
|
||||
expect((await getThemeState(page)).hasDarkClass).toBe(false);
|
||||
|
||||
// Simulate OS switching to dark
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await page.waitForTimeout(300); // Allow matchMedia listener to fire
|
||||
|
||||
const state = await getThemeState(page);
|
||||
expect(state.hasDarkClass).toBe(true);
|
||||
expect(state.stored).toBeNull(); // Still not stored
|
||||
});
|
||||
|
||||
test('System preference change is ignored when user has stored preference', async ({ page }) => {
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
|
||||
// OS switches to light
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const state = await getThemeState(page);
|
||||
// User's stored 'dark' should still be active — but note: system pref change
|
||||
// listener only runs for initial load not active toggles — document behavior as-is
|
||||
expect(state.stored).toBe('dark');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 3. No Flash of Unstyled Content (FOUC) Prevention
|
||||
// ============================================================
|
||||
test.describe('Theme: FOUC Prevention', () => {
|
||||
|
||||
test('Dark mode class applied before first paint (inline script)', async ({ page }) => {
|
||||
// Set up dark preference before navigating
|
||||
// We need to ensure html.dark is set synchronously before any CSS loads
|
||||
|
||||
// Use CDP to intercept and verify class set early
|
||||
let darkClassAppliedBeforeDOMContentLoaded = false;
|
||||
|
||||
// Listen on DOMContentLoaded and check if class is already present
|
||||
await page.addInitScript(() => {
|
||||
// This script runs AFTER inline scripts but BEFORE DOMContentLoaded
|
||||
// The theme init inline script should have run by now
|
||||
window.__themeClassAtInitScript = document.documentElement.classList.contains('dark');
|
||||
});
|
||||
|
||||
// Navigate with dark stored
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => {
|
||||
try { localStorage.setItem('theme', 'dark'); } catch { /* noop */ }
|
||||
});
|
||||
|
||||
// Reload and capture early state
|
||||
await page.addInitScript(() => {
|
||||
// Capture class state at very start of script execution
|
||||
window.__earlyDarkCheck = document.documentElement.classList.contains('dark');
|
||||
});
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
|
||||
// After reload, html.dark should be present immediately
|
||||
const hasDarkClass = await page.evaluate(() => document.documentElement.classList.contains('dark'));
|
||||
expect(hasDarkClass).toBe(true);
|
||||
});
|
||||
|
||||
test('No body background flash - html.dark applied synchronously', async ({ page }) => {
|
||||
// Navigate with dark preference and verify dark class is set on html element
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => {
|
||||
try { localStorage.setItem('theme', 'dark'); } catch { /* noop */ }
|
||||
});
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
|
||||
// The dark class must be on the html element (not body)
|
||||
const htmlClasses = await page.evaluate(() => document.documentElement.className);
|
||||
expect(htmlClasses).toContain('dark');
|
||||
});
|
||||
|
||||
test('Light mode: no dark class on html element when light is stored', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => {
|
||||
try { localStorage.setItem('theme', 'light'); } catch { /* noop */ }
|
||||
});
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
|
||||
const hasDarkClass = await page.evaluate(() => document.documentElement.classList.contains('dark'));
|
||||
expect(hasDarkClass).toBe(false);
|
||||
});
|
||||
|
||||
test('Theme init script is in <head> not <body>', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Verify the blocking theme script is in <head>
|
||||
const headScripts = await page.evaluate(() => {
|
||||
const scripts = Array.from(document.head.querySelectorAll('script:not([type])'));
|
||||
return scripts.map(s => s.textContent?.substring(0, 100) ?? '');
|
||||
});
|
||||
|
||||
// At least one head script should contain the localStorage theme logic
|
||||
const hasThemeScript = headScripts.some(s =>
|
||||
s.includes('localStorage') && (s.includes('theme') || s.includes('dark'))
|
||||
);
|
||||
expect(hasThemeScript).toBe(true);
|
||||
});
|
||||
|
||||
test('FOUC check: no visible content reflow on dark reload', async ({ page }) => {
|
||||
// If FOUC were present, background-color would transition from white to dark
|
||||
// We verify the html element has the class before DOMContentLoaded fires
|
||||
|
||||
let classBeforeDOMContentLoaded: boolean | null = null;
|
||||
|
||||
page.on('domcontentloaded', async () => {
|
||||
classBeforeDOMContentLoaded = await page.evaluate(
|
||||
() => document.documentElement.classList.contains('dark')
|
||||
).catch(() => null);
|
||||
});
|
||||
|
||||
// Ensure dark is stored
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => { try { localStorage.setItem('theme', 'dark'); } catch {} });
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
|
||||
// At DOMContentLoaded, dark class should be present (set by inline blocking script)
|
||||
const hasDark = await page.evaluate(() => document.documentElement.classList.contains('dark'));
|
||||
expect(hasDark).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 4. ARIA Accessibility & State Synchronization
|
||||
// ============================================================
|
||||
test.describe('Theme: ARIA Accessibility', () => {
|
||||
|
||||
test('Toggle button has role="switch"', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
const role = await page.locator('[data-theme-toggle]').first().getAttribute('role');
|
||||
expect(role).toBe('switch');
|
||||
});
|
||||
|
||||
test('aria-checked="false" in light mode', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'light');
|
||||
const ariaChecked = await page.locator('[data-theme-toggle]').first().getAttribute('aria-checked');
|
||||
expect(ariaChecked).toBe('false');
|
||||
});
|
||||
|
||||
test('aria-checked="true" in dark mode', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
const ariaChecked = await page.locator('[data-theme-toggle]').first().getAttribute('aria-checked');
|
||||
expect(ariaChecked).toBe('true');
|
||||
});
|
||||
|
||||
test('aria-label updates after toggling to dark', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'light');
|
||||
await clickThemeToggle(page);
|
||||
|
||||
const state = await getThemeState(page);
|
||||
if (state.hasDarkClass) {
|
||||
expect(state.ariaLabel).toBe('Switch to light mode');
|
||||
expect(state.ariaChecked).toBe('true');
|
||||
} else {
|
||||
expect(state.ariaLabel).toBe('Switch to dark mode');
|
||||
expect(state.ariaChecked).toBe('false');
|
||||
}
|
||||
});
|
||||
|
||||
test('aria-label updates after toggling to light', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
await clickThemeToggle(page);
|
||||
|
||||
const state = await getThemeState(page);
|
||||
expect(state.hasDarkClass).toBe(false);
|
||||
expect(state.ariaLabel).toBe('Switch to dark mode');
|
||||
expect(state.ariaChecked).toBe('false');
|
||||
});
|
||||
|
||||
test('Live region announces theme change on toggle', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'light');
|
||||
|
||||
// Clear live region
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('[data-theme-live]').forEach(el => el.textContent = '');
|
||||
});
|
||||
|
||||
await clickThemeToggle(page);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const liveRegionText = await page.evaluate(() =>
|
||||
document.querySelector('[data-theme-live]')?.textContent ?? ''
|
||||
);
|
||||
expect(['Dark mode enabled', 'Light mode enabled']).toContain(liveRegionText.trim());
|
||||
});
|
||||
|
||||
test('Live region is polite (non-interrupting)', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
const ariaLive = await page.locator('[data-theme-live]').first().getAttribute('aria-live');
|
||||
expect(ariaLive).toBe('polite');
|
||||
});
|
||||
|
||||
test('All theme toggle instances sync aria-checked (desktop + mobile)', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await loadPageWithStoredTheme(page, 'light');
|
||||
|
||||
// Click toggle (finds first visible one on mobile)
|
||||
await clickThemeToggle(page);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// All toggle instances should have same aria-checked
|
||||
const allCheckedValues = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('[data-theme-toggle]')).map(btn =>
|
||||
btn.getAttribute('aria-checked')
|
||||
)
|
||||
);
|
||||
|
||||
// All should be the same value
|
||||
const uniqueValues = [...new Set(allCheckedValues)];
|
||||
expect(uniqueValues).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Toggle is keyboard accessible (Space key)', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'light');
|
||||
|
||||
const before = await getThemeState(page);
|
||||
const wasLight = !before.hasDarkClass;
|
||||
|
||||
// Focus and press Space
|
||||
await page.locator('[data-theme-toggle]').first().focus();
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const after = await getThemeState(page);
|
||||
// Space on a button triggers click - theme should have toggled
|
||||
expect(after.hasDarkClass).toBe(!before.hasDarkClass);
|
||||
});
|
||||
|
||||
test('Toggle is keyboard accessible (Enter key)', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'light');
|
||||
|
||||
const before = await getThemeState(page);
|
||||
|
||||
await page.locator('[data-theme-toggle]').first().focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const after = await getThemeState(page);
|
||||
expect(after.hasDarkClass).toBe(!before.hasDarkClass);
|
||||
});
|
||||
|
||||
test('Toggle button is focusable (tabindex not -1)', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
const tabindex = await page.locator('[data-theme-toggle]').first().getAttribute('tabindex');
|
||||
// Should not be -1 (which would make it unfocusable)
|
||||
expect(tabindex).not.toBe('-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 5. theme-color Meta Tag Synchronization
|
||||
// ============================================================
|
||||
test.describe('Theme: Meta Tag Synchronization', () => {
|
||||
|
||||
test('Dark mode: theme-color meta tags updated to dark color', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
const state = await getThemeState(page);
|
||||
|
||||
// At least one meta should have the dark color
|
||||
const hasStoredDarkColor = state.themeColorMetas.some(m => m.content === '#0f172a');
|
||||
expect(hasStoredDarkColor).toBe(true);
|
||||
});
|
||||
|
||||
test('Light mode: theme-color meta tags updated to light color', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'light');
|
||||
const state = await getThemeState(page);
|
||||
|
||||
// At least one meta should have the light color
|
||||
const hasStoredLightColor = state.themeColorMetas.some(m => m.content === '#0891b2');
|
||||
expect(hasStoredLightColor).toBe(true);
|
||||
});
|
||||
|
||||
test('Toggling theme updates theme-color meta tags', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'light');
|
||||
|
||||
await clickThemeToggle(page);
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const state = await getThemeState(page);
|
||||
if (state.hasDarkClass) {
|
||||
const hasDarkColor = state.themeColorMetas.some(m => m.content === '#0f172a');
|
||||
expect(hasDarkColor).toBe(true);
|
||||
} else {
|
||||
const hasLightColor = state.themeColorMetas.some(m => m.content === '#0891b2');
|
||||
expect(hasLightColor).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('Both theme-color meta tags exist in the document head', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
const metaCount = await page.evaluate(() =>
|
||||
document.querySelectorAll('meta[name="theme-color"]').length
|
||||
);
|
||||
expect(metaCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 6. Visual State (CSS)
|
||||
// ============================================================
|
||||
test.describe('Theme: Visual CSS State', () => {
|
||||
|
||||
test('Dark mode: html element has "dark" class', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
const hasDark = await page.evaluate(() => document.documentElement.classList.contains('dark'));
|
||||
expect(hasDark).toBe(true);
|
||||
});
|
||||
|
||||
test('Light mode: html element does NOT have "dark" class', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'light');
|
||||
const hasDark = await page.evaluate(() => document.documentElement.classList.contains('dark'));
|
||||
expect(hasDark).toBe(false);
|
||||
});
|
||||
|
||||
test('Dark mode: page background is dark-colored', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
const bgColor = await page.evaluate(() =>
|
||||
window.getComputedStyle(document.body).backgroundColor
|
||||
);
|
||||
// Dark surface is #0f172a = rgb(15, 23, 42)
|
||||
// Verify it's distinctly dark (r+g+b should be low)
|
||||
const match = bgColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
if (match) {
|
||||
const brightness = parseInt(match[1]) + parseInt(match[2]) + parseInt(match[3]);
|
||||
expect(brightness).toBeLessThan(200); // Dark background
|
||||
}
|
||||
});
|
||||
|
||||
test('Light mode: page background is light-colored', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'light');
|
||||
const bgColor = await page.evaluate(() =>
|
||||
window.getComputedStyle(document.body).backgroundColor
|
||||
);
|
||||
const match = bgColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
if (match) {
|
||||
const brightness = parseInt(match[1]) + parseInt(match[2]) + parseInt(match[3]);
|
||||
expect(brightness).toBeGreaterThan(550); // Light background
|
||||
}
|
||||
});
|
||||
|
||||
test('Sun icon visible in dark mode', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
const sunOpacity = await page.evaluate(() => {
|
||||
const btn = document.querySelector('[data-theme-toggle]');
|
||||
const sun = btn?.querySelector('.sun-icon') as HTMLElement | null;
|
||||
return sun ? window.getComputedStyle(sun).opacity : null;
|
||||
});
|
||||
expect(sunOpacity).toBe('1');
|
||||
});
|
||||
|
||||
test('Moon icon visible in light mode', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'light');
|
||||
const moonOpacity = await page.evaluate(() => {
|
||||
const btn = document.querySelector('[data-theme-toggle]');
|
||||
const moon = btn?.querySelector('.moon-icon') as HTMLElement | null;
|
||||
return moon ? window.getComputedStyle(moon).opacity : null;
|
||||
});
|
||||
expect(moonOpacity).toBe('1');
|
||||
});
|
||||
|
||||
test('Dark mode: icon transitions respect prefers-reduced-motion', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
|
||||
const transitionDuration = await page.evaluate(() => {
|
||||
const btn = document.querySelector('[data-theme-toggle]');
|
||||
const sun = btn?.querySelector('.sun-icon') as HTMLElement | null;
|
||||
return sun ? window.getComputedStyle(sun).transitionDuration : null;
|
||||
});
|
||||
|
||||
// With reduced motion, transition should be 0s or none
|
||||
if (transitionDuration) {
|
||||
expect(['0s', '0ms']).toContain(transitionDuration);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 7. JavaScript Disabled Behavior
|
||||
// ============================================================
|
||||
test.describe('Theme: JavaScript Disabled', () => {
|
||||
|
||||
test('Page renders without JS (no crash, content visible)', async ({ browser }) => {
|
||||
// Create context with JS disabled
|
||||
const context = await browser.newContext({ javaScriptEnabled: false });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Page should still render content (SSR)
|
||||
const bodyText = await page.locator('body').innerText();
|
||||
expect(bodyText.trim().length).toBeGreaterThan(50);
|
||||
|
||||
// Header should be present
|
||||
await expect(page.locator('#main-header')).toBeAttached();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('Without JS: page defaults to light mode (no dark class)', async ({ browser }) => {
|
||||
const context = await browser.newContext({ javaScriptEnabled: false });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Without JS, the inline theme script cannot run
|
||||
// Page should default to light mode (no dark class on html)
|
||||
const hasDark = await page.evaluate(() => document.documentElement.classList.contains('dark'));
|
||||
// Note: This tests the degraded state — dark class won't be applied without JS
|
||||
// This is expected behavior (progressive enhancement)
|
||||
// The OS-level theme-color media queries still work for browser chrome
|
||||
expect(typeof hasDark).toBe('boolean'); // Just verify evaluation works
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('Without JS: theme-color meta tags still exist (native OS support)', async ({ browser }) => {
|
||||
const context = await browser.newContext({ javaScriptEnabled: false });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Meta tags are server-rendered so they exist even without JS
|
||||
const metaCount = await page.evaluate(() =>
|
||||
document.querySelectorAll('meta[name="theme-color"]').length
|
||||
);
|
||||
expect(metaCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('Without JS: noscript font fallback renders', async ({ browser }) => {
|
||||
const context = await browser.newContext({ javaScriptEnabled: false });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Noscript font link should be in the document
|
||||
const noscript = await page.evaluate(() => {
|
||||
const noscripts = document.querySelectorAll('noscript');
|
||||
return Array.from(noscripts).some(n => n.innerHTML.includes('fonts.googleapis.com'));
|
||||
});
|
||||
expect(noscript).toBe(true);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 8. Cross-Page Theme Consistency
|
||||
// ============================================================
|
||||
test.describe('Theme: Cross-Page Consistency', () => {
|
||||
|
||||
const ALL_CONTENT_PAGES = ['/', '/about', '/services', '/portfolio', '/contact', '/blog'];
|
||||
|
||||
for (const url of ALL_CONTENT_PAGES) {
|
||||
test(`Dark mode consistent on ${url}`, async ({ page }) => {
|
||||
// Start with dark preference and visit each page
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => { try { localStorage.setItem('theme', 'dark'); } catch {} });
|
||||
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
const hasDark = await page.evaluate(() => document.documentElement.classList.contains('dark'));
|
||||
expect(hasDark).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('Theme toggle present on all content pages', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 }); // Desktop view
|
||||
|
||||
for (const url of ALL_CONTENT_PAGES) {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
const toggleCount = await page.locator('[data-theme-toggle]').count();
|
||||
expect(toggleCount, `Theme toggle missing on ${url}`).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('Theme state consistent in mobile nav', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
|
||||
// Open mobile menu
|
||||
const mobileToggle = page.locator('#mobile-menu-toggle');
|
||||
await expect(mobileToggle).toBeVisible();
|
||||
await mobileToggle.click();
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// There should be a theme toggle in the mobile menu too
|
||||
const togglesInMobileMenu = await page.locator('#mobile-menu [data-theme-toggle]').count();
|
||||
// At least one toggle should exist (in header, possibly in mobile menu too)
|
||||
const allToggles = await page.locator('[data-theme-toggle]').count();
|
||||
expect(allToggles).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('Theme preserved when switching between mobile and desktop viewports', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
|
||||
expect((await getThemeState(page)).hasDarkClass).toBe(true);
|
||||
|
||||
// Resize to desktop
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
expect((await getThemeState(page)).hasDarkClass).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 9. Browser Session Persistence
|
||||
// ============================================================
|
||||
test.describe('Theme: Browser Session Persistence', () => {
|
||||
|
||||
test('Theme preference survives page refresh', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
|
||||
// Hard refresh
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
const state = await getThemeState(page);
|
||||
expect(state.hasDarkClass).toBe(true);
|
||||
expect(state.stored).toBe('dark');
|
||||
});
|
||||
|
||||
test('Theme preference survives navigation and back button', async ({ page }) => {
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
|
||||
await page.goto('/about', { waitUntil: 'domcontentloaded' });
|
||||
expect((await getThemeState(page)).hasDarkClass).toBe(true);
|
||||
|
||||
await page.goBack({ waitUntil: 'domcontentloaded' });
|
||||
expect((await getThemeState(page)).hasDarkClass).toBe(true);
|
||||
});
|
||||
|
||||
test('New page context starts fresh (no cross-session bleed)', async ({ browser }) => {
|
||||
// Session 1: set dark
|
||||
const context1 = await browser.newContext();
|
||||
const page1 = await context1.newPage();
|
||||
await page1.goto('http://localhost:10000/', { waitUntil: 'domcontentloaded' });
|
||||
await page1.evaluate(() => { try { localStorage.setItem('theme', 'dark'); } catch {} });
|
||||
await context1.close();
|
||||
|
||||
// Session 2: fresh context - should not inherit session 1's preference
|
||||
const context2 = await browser.newContext();
|
||||
const page2 = await context2.newPage();
|
||||
await page2.goto('http://localhost:10000/', { waitUntil: 'domcontentloaded' });
|
||||
const stored = await page2.evaluate(() => {
|
||||
try { return localStorage.getItem('theme'); } catch { return null; }
|
||||
});
|
||||
// Fresh context should have no stored preference
|
||||
expect(stored).toBeNull();
|
||||
await context2.close();
|
||||
});
|
||||
|
||||
test('localStorage persists through multiple tabs (same origin)', async ({ context }) => {
|
||||
// Set dark in tab 1
|
||||
const page1 = await context.newPage();
|
||||
await page1.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page1.evaluate(() => { try { localStorage.setItem('theme', 'dark'); } catch {} });
|
||||
|
||||
// Open tab 2 — should pick up dark preference from localStorage
|
||||
const page2 = await context.newPage();
|
||||
await page2.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
const stored = await page2.evaluate(() => {
|
||||
try { return localStorage.getItem('theme'); } catch { return null; }
|
||||
});
|
||||
expect(stored).toBe('dark');
|
||||
|
||||
await page1.close();
|
||||
await page2.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 10. Visual Screenshots (dark & light across browsers)
|
||||
// ============================================================
|
||||
test.describe('Theme: Visual Snapshots', () => {
|
||||
|
||||
const SNAPSHOT_PAGES = [
|
||||
{ name: 'home', url: '/' },
|
||||
{ name: 'about', url: '/about' },
|
||||
{ name: 'services', url: '/services' },
|
||||
{ name: 'contact', url: '/contact' },
|
||||
];
|
||||
|
||||
for (const p of SNAPSHOT_PAGES) {
|
||||
test(`Dark mode screenshot: ${p.name}`, async ({ page, browserName }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await loadPageWithStoredTheme(page, 'dark');
|
||||
await page.goto(p.url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.screenshot({
|
||||
path: `tests/screenshots/${browserName}-${p.name}-dark.png`,
|
||||
fullPage: false,
|
||||
clip: { x: 0, y: 0, width: 1280, height: 800 },
|
||||
});
|
||||
});
|
||||
|
||||
test(`Light mode screenshot: ${p.name}`, async ({ page, browserName }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await loadPageWithStoredTheme(page, 'light');
|
||||
await page.goto(p.url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.screenshot({
|
||||
path: `tests/screenshots/${browserName}-${p.name}-light.png`,
|
||||
fullPage: false,
|
||||
clip: { x: 0, y: 0, width: 1280, height: 800 },
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user