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
1338 lines
51 KiB
TypeScript
1338 lines
51 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
|
|
// All 10 pages to test
|
|
const ALL_PAGES = [
|
|
{ name: 'Home', url: '/', titleFragment: 'Transforming Ideas' },
|
|
{ name: 'About', url: '/about', titleFragment: 'About Us' },
|
|
{ name: 'Services', url: '/services', titleFragment: 'Our Services' },
|
|
{ name: 'Portfolio', url: '/portfolio', titleFragment: 'Our Portfolio' },
|
|
{ name: 'Blog', url: '/blog', titleFragment: 'Blog' },
|
|
{ name: 'Contact', url: '/contact', titleFragment: 'Contact Us' },
|
|
{ name: 'Privacy', url: '/privacy', titleFragment: 'Privacy Policy' },
|
|
{ name: 'Terms', url: '/terms', titleFragment: 'Terms of Service' },
|
|
{ name: 'Sitemap', url: '/sitemap', titleFragment: 'Sitemap' },
|
|
{ name: '404', url: '/nonexistent-page-xyz', titleFragment: '' },
|
|
];
|
|
|
|
// Viewport sizes for responsive testing
|
|
const VIEWPORTS = [
|
|
{ name: 'Mobile', width: 375, height: 667 },
|
|
{ name: 'Tablet', width: 768, height: 1024 },
|
|
{ name: 'Desktop', width: 1280, height: 800 },
|
|
{ name: 'Wide', width: 1920, height: 1080 },
|
|
];
|
|
|
|
// ============================================================
|
|
// 1. Cross-Browser Page Load Testing (runs on all configured browsers)
|
|
// ============================================================
|
|
test.describe('Cross-Browser: All Pages Load', () => {
|
|
for (const p of ALL_PAGES) {
|
|
test(`${p.name} page loads successfully`, async ({ page }) => {
|
|
const consoleErrors: string[] = [];
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
|
});
|
|
page.on('pageerror', (err) => consoleErrors.push(err.message));
|
|
|
|
const response = await page.goto(p.url, { waitUntil: 'domcontentloaded' });
|
|
|
|
if (p.url === '/nonexistent-page-xyz') {
|
|
expect([404, 200]).toContain(response?.status()); // Some frameworks serve 200 with error page
|
|
} else {
|
|
expect(response?.status()).toBe(200);
|
|
}
|
|
|
|
// Verify no critical JS errors (filter out known non-critical browser noise)
|
|
const critical = consoleErrors.filter(
|
|
(e) =>
|
|
!e.includes('favicon') &&
|
|
!e.includes('livereload') &&
|
|
!e.includes('DevTools') &&
|
|
!e.includes('manifest') &&
|
|
!e.includes('net::ERR_ABORTED') // image lazy-load aborts on fast navigation
|
|
);
|
|
if (critical.length > 0) {
|
|
console.warn(`[${p.name}] Console errors:`, critical);
|
|
}
|
|
expect(critical, `Unexpected console errors on ${p.name}`).toHaveLength(0);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// 2. Cross-Browser: Critical Layout Elements Present
|
|
// ============================================================
|
|
test.describe('Cross-Browser: Layout Elements', () => {
|
|
const CONTENT_PAGES = ALL_PAGES.filter((p) => p.url !== '/nonexistent-page-xyz');
|
|
|
|
for (const p of CONTENT_PAGES) {
|
|
test(`${p.name} has header, main content, and footer`, async ({ page }) => {
|
|
await page.goto(p.url, { waitUntil: 'domcontentloaded' });
|
|
|
|
// Fixed header is always present
|
|
await expect(page.locator('#main-header')).toBeAttached();
|
|
|
|
// Footer is always present
|
|
await expect(page.locator('footer')).toBeAttached();
|
|
|
|
// Page has at least one <section> or <main> with content
|
|
const mainContent = page.locator('main, section').first();
|
|
await expect(mainContent).toBeAttached();
|
|
|
|
// No invisible page (must have visible body text)
|
|
const bodyText = await page.locator('body').innerText();
|
|
expect(bodyText.trim().length).toBeGreaterThan(50);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// 3. Responsive Design Testing
|
|
// ============================================================
|
|
test.describe('Responsive Design', () => {
|
|
test.describe('Home page responsive layout', () => {
|
|
for (const vp of VIEWPORTS) {
|
|
test(`renders correctly at ${vp.name} (${vp.width}x${vp.height})`, async ({ page }) => {
|
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
|
|
// Header is always visible
|
|
await expect(page.locator('#main-header')).toBeVisible();
|
|
|
|
// Hero section (first section) is visible
|
|
await expect(page.locator('section').first()).toBeVisible();
|
|
|
|
// Footer visible
|
|
await expect(page.locator('footer')).toBeVisible();
|
|
|
|
// On mobile/tablet: desktop nav is hidden, hamburger is visible
|
|
if (vp.width < 1024) {
|
|
await expect(page.locator('#mobile-menu-toggle')).toBeVisible();
|
|
await expect(page.locator('ul[role="menubar"]')).toBeHidden();
|
|
} else {
|
|
// On desktop: hamburger is hidden, nav is visible
|
|
await expect(page.locator('#mobile-menu-toggle')).toBeHidden();
|
|
await expect(page.locator('ul[role="menubar"]')).toBeVisible();
|
|
}
|
|
|
|
// Screenshot for visual reference
|
|
await page.screenshot({
|
|
path: `tests/screenshots/${vp.name.toLowerCase()}-home.png`,
|
|
fullPage: false,
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
test.describe('Services page responsive layout', () => {
|
|
for (const vp of [VIEWPORTS[0], VIEWPORTS[2]]) {
|
|
// Mobile + Desktop
|
|
test(`renders at ${vp.name}`, async ({ page }) => {
|
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
await page.goto('/services', { waitUntil: 'domcontentloaded' });
|
|
|
|
await expect(page.locator('#main-header')).toBeVisible();
|
|
await expect(page.locator('h1')).toBeVisible();
|
|
|
|
const content = await page.locator('body').innerText();
|
|
expect(content).toContain('Services');
|
|
});
|
|
}
|
|
});
|
|
|
|
test.describe('Portfolio page responsive layout', () => {
|
|
for (const vp of [VIEWPORTS[0], VIEWPORTS[1], VIEWPORTS[2]]) {
|
|
test(`renders at ${vp.name}`, async ({ page }) => {
|
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
await page.goto('/portfolio', { waitUntil: 'domcontentloaded' });
|
|
|
|
// Filter tabs and project grid must be visible
|
|
await expect(page.locator('[role="tablist"]')).toBeVisible();
|
|
await expect(page.locator('#projects-grid')).toBeVisible();
|
|
|
|
// At least one project card must be visible
|
|
const cards = page.locator('.project-card');
|
|
await expect(cards.first()).toBeVisible();
|
|
});
|
|
}
|
|
});
|
|
|
|
test.describe('Contact page responsive layout', () => {
|
|
for (const vp of [VIEWPORTS[0], VIEWPORTS[2]]) {
|
|
test(`form is usable at ${vp.name}`, async ({ page }) => {
|
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
|
|
|
|
await expect(page.locator('#contact-form')).toBeVisible();
|
|
await expect(page.locator('#name')).toBeVisible();
|
|
await expect(page.locator('#email')).toBeVisible();
|
|
await expect(page.locator('#message')).toBeVisible();
|
|
|
|
// Submit button is visible and clickable
|
|
const submitBtn = page.locator('#contact-form button[type="submit"]');
|
|
await expect(submitBtn).toBeVisible();
|
|
await expect(submitBtn).toBeEnabled();
|
|
});
|
|
}
|
|
});
|
|
|
|
test.describe('Blog page responsive layout', () => {
|
|
for (const vp of [VIEWPORTS[0], VIEWPORTS[2]]) {
|
|
test(`renders at ${vp.name}`, async ({ page }) => {
|
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
|
|
|
await expect(page.locator('h1')).toBeVisible();
|
|
await expect(page.locator('footer')).toBeVisible();
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 4. Navigation Testing
|
|
// ============================================================
|
|
test.describe('Navigation', () => {
|
|
test('Desktop nav links navigate to correct pages', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1280, height: 800 });
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
|
|
const navItems = [
|
|
{ label: 'About', path: '/about' },
|
|
{ label: 'Services', path: '/services' },
|
|
{ label: 'Portfolio', path: '/portfolio' },
|
|
{ label: 'Blog', path: '/blog' },
|
|
{ label: 'Contact', path: '/contact' },
|
|
];
|
|
|
|
for (const item of navItems) {
|
|
// Navigate back to home first
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
|
|
// Click the nav link in the desktop menubar
|
|
const navLink = page.locator(`ul[role="menubar"] a[href="${item.path}"]`);
|
|
await expect(navLink).toBeVisible();
|
|
await navLink.click();
|
|
|
|
await page.waitForURL(`**${item.path}**`, { timeout: 5000 });
|
|
expect(page.url()).toContain(item.path);
|
|
}
|
|
});
|
|
|
|
test('Logo navigates to home page', async ({ page }) => {
|
|
await page.goto('/about', { waitUntil: 'domcontentloaded' });
|
|
|
|
const logo = page.locator('#main-header a[aria-label*="WorkRoot"]').first();
|
|
await expect(logo).toBeVisible();
|
|
await logo.click();
|
|
await page.waitForURL('**/', { timeout: 5000 });
|
|
expect(page.url()).toMatch(/\/$|\/$/);
|
|
});
|
|
|
|
test('"Get Started" CTA in header navigates to contact', async ({ page }) => {
|
|
await page.setViewportSize({ width: 1280, height: 800 });
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
|
|
const ctaBtn = page.locator('#main-header a[href="/contact"]').first();
|
|
await expect(ctaBtn).toBeVisible();
|
|
await ctaBtn.click();
|
|
await page.waitForURL('**/contact', { timeout: 5000 });
|
|
expect(page.url()).toContain('/contact');
|
|
});
|
|
|
|
test('Footer privacy link navigates correctly', async ({ page }) => {
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
|
|
const privacyLink = page.locator('footer a[href="/privacy"]').first();
|
|
await expect(privacyLink).toBeVisible();
|
|
await privacyLink.click();
|
|
await page.waitForURL('**/privacy', { timeout: 5000 });
|
|
expect(page.url()).toContain('/privacy');
|
|
});
|
|
|
|
test('Footer terms link navigates correctly', async ({ page }) => {
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
|
|
const termsLink = page.locator('footer a[href="/terms"]').first();
|
|
await expect(termsLink).toBeVisible();
|
|
await termsLink.click();
|
|
await page.waitForURL('**/terms', { timeout: 5000 });
|
|
expect(page.url()).toContain('/terms');
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 5. Mobile Navigation (hamburger menu)
|
|
// ============================================================
|
|
test.describe('Mobile Navigation Menu', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 667 });
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
});
|
|
|
|
test('Hamburger button is visible on mobile', async ({ page }) => {
|
|
await expect(page.locator('#mobile-menu-toggle')).toBeVisible();
|
|
});
|
|
|
|
test('Desktop nav is hidden on mobile', async ({ page }) => {
|
|
await expect(page.locator('ul[role="menubar"]')).toBeHidden();
|
|
});
|
|
|
|
test('Mobile menu panel opens when hamburger is clicked', async ({ page }) => {
|
|
const toggle = page.locator('#mobile-menu-toggle');
|
|
const menu = page.locator('#mobile-menu');
|
|
|
|
// Menu starts closed (translated off-screen)
|
|
await expect(menu).toHaveClass(/translate-x-full/);
|
|
|
|
await toggle.click();
|
|
await page.waitForTimeout(400); // CSS transition
|
|
|
|
// Menu slides in
|
|
await expect(menu).not.toHaveClass(/translate-x-full/);
|
|
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
|
});
|
|
|
|
test('Mobile menu closes when close button is clicked', async ({ page }) => {
|
|
await page.locator('#mobile-menu-toggle').click();
|
|
await page.waitForTimeout(400);
|
|
|
|
const closeBtn = page.locator('#mobile-menu-close');
|
|
await expect(closeBtn).toBeVisible();
|
|
await closeBtn.click();
|
|
await page.waitForTimeout(400);
|
|
|
|
const menu = page.locator('#mobile-menu');
|
|
await expect(menu).toHaveClass(/translate-x-full/);
|
|
await expect(page.locator('#mobile-menu-toggle')).toHaveAttribute('aria-expanded', 'false');
|
|
});
|
|
|
|
test('Mobile menu closes when overlay is clicked', async ({ page }) => {
|
|
await page.locator('#mobile-menu-toggle').click();
|
|
await page.waitForTimeout(400);
|
|
|
|
const overlay = page.locator('#mobile-menu-overlay');
|
|
await overlay.click({ force: true });
|
|
await page.waitForTimeout(400);
|
|
|
|
await expect(page.locator('#mobile-menu')).toHaveClass(/translate-x-full/);
|
|
});
|
|
|
|
test('Mobile menu closes on Escape key', async ({ page }) => {
|
|
await page.locator('#mobile-menu-toggle').click();
|
|
await page.waitForTimeout(400);
|
|
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForTimeout(400);
|
|
|
|
await expect(page.locator('#mobile-menu')).toHaveClass(/translate-x-full/);
|
|
await expect(page.locator('#mobile-menu-toggle')).toHaveAttribute('aria-expanded', 'false');
|
|
});
|
|
|
|
test('Mobile menu links navigate correctly', async ({ page }) => {
|
|
await page.locator('#mobile-menu-toggle').click();
|
|
await page.waitForTimeout(400);
|
|
|
|
// Click "Services" in mobile nav
|
|
const servicesLink = page.locator('#mobile-menu a[href="/services"]');
|
|
await expect(servicesLink).toBeVisible();
|
|
await servicesLink.click();
|
|
|
|
await page.waitForURL('**/services', { timeout: 5000 });
|
|
expect(page.url()).toContain('/services');
|
|
});
|
|
|
|
test('Body scroll is locked while menu is open', async ({ page }) => {
|
|
await page.locator('#mobile-menu-toggle').click();
|
|
await page.waitForTimeout(400);
|
|
|
|
const overflow = await page.evaluate(() => document.body.style.overflow);
|
|
expect(overflow).toBe('hidden');
|
|
|
|
// Scroll re-enabled on close
|
|
await page.locator('#mobile-menu-close').click();
|
|
await page.waitForTimeout(400);
|
|
|
|
const overflowAfter = await page.evaluate(() => document.body.style.overflow);
|
|
expect(overflowAfter).toBe('');
|
|
});
|
|
|
|
test('Menu closes automatically on resize to desktop', async ({ page }) => {
|
|
await page.locator('#mobile-menu-toggle').click();
|
|
await page.waitForTimeout(400);
|
|
|
|
// Resize to desktop
|
|
await page.setViewportSize({ width: 1280, height: 800 });
|
|
await page.waitForTimeout(400);
|
|
|
|
await expect(page.locator('#mobile-menu')).toHaveClass(/translate-x-full/);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 6. Contact Form Testing
|
|
// ============================================================
|
|
test.describe('Contact Form', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
|
|
});
|
|
|
|
test('Form elements are all present', async ({ page }) => {
|
|
await expect(page.locator('#contact-form')).toBeVisible();
|
|
await expect(page.locator('#name')).toBeVisible();
|
|
await expect(page.locator('#email')).toBeVisible();
|
|
await expect(page.locator('#phone')).toBeVisible();
|
|
await expect(page.locator('#subject')).toBeVisible();
|
|
await expect(page.locator('#message')).toBeVisible();
|
|
|
|
// Honeypot field is present but visually hidden
|
|
await expect(page.locator('#website')).toBeAttached();
|
|
await expect(page.locator('#website')).not.toBeVisible();
|
|
});
|
|
|
|
test('Subject dropdown has all expected options', async ({ page }) => {
|
|
const options = await page.locator('#subject option').allTextContents();
|
|
expect(options).toContain('Web Development');
|
|
expect(options).toContain('Mobile Development');
|
|
expect(options).toContain('Cloud Services');
|
|
expect(options).toContain('AI & Machine Learning');
|
|
expect(options).toContain('IT Consulting');
|
|
expect(options).toContain('Technical Support');
|
|
expect(options).toContain('Other');
|
|
});
|
|
|
|
test('Error messages are hidden initially', async ({ page }) => {
|
|
await expect(page.locator('[data-error="name"]')).toBeHidden();
|
|
await expect(page.locator('[data-error="email"]')).toBeHidden();
|
|
await expect(page.locator('[data-error="subject"]')).toBeHidden();
|
|
await expect(page.locator('[data-error="message"]')).toBeHidden();
|
|
});
|
|
|
|
test('Submit button is visible and enabled', async ({ page }) => {
|
|
const btn = page.locator('#contact-form button[type="submit"]');
|
|
await expect(btn).toBeVisible();
|
|
await expect(btn).toBeEnabled();
|
|
await expect(btn).toContainText('Send Message');
|
|
});
|
|
|
|
test('Name field accepts valid input', async ({ page }) => {
|
|
const nameInput = page.locator('#name');
|
|
await nameInput.fill('John Doe');
|
|
expect(await nameInput.inputValue()).toBe('John Doe');
|
|
});
|
|
|
|
test('Email field accepts valid email format', async ({ page }) => {
|
|
const emailInput = page.locator('#email');
|
|
await emailInput.fill('john@example.com');
|
|
expect(await emailInput.inputValue()).toBe('john@example.com');
|
|
});
|
|
|
|
test('Phone field is optional and accepts phone number', async ({ page }) => {
|
|
const phoneInput = page.locator('#phone');
|
|
await phoneInput.fill('+1 (555) 123-4567');
|
|
expect(await phoneInput.inputValue()).toBe('+1 (555) 123-4567');
|
|
});
|
|
|
|
test('Message textarea accepts multi-line text', async ({ page }) => {
|
|
const messageInput = page.locator('#message');
|
|
const testMessage = 'This is a test message.\nWith multiple lines.\nFor our project.';
|
|
await messageInput.fill(testMessage);
|
|
expect(await messageInput.inputValue()).toBe(testMessage);
|
|
});
|
|
|
|
test('Full form can be filled out completely', async ({ page }) => {
|
|
await page.fill('#name', 'Test User');
|
|
await page.fill('#email', 'test@example.com');
|
|
await page.fill('#phone', '+1234567890');
|
|
await page.selectOption('#subject', 'web-development');
|
|
await page.fill('#message', 'This is a complete test message for automated testing purposes.');
|
|
|
|
// All fields should have their values
|
|
expect(await page.locator('#name').inputValue()).toBe('Test User');
|
|
expect(await page.locator('#email').inputValue()).toBe('test@example.com');
|
|
expect(await page.locator('#subject').inputValue()).toBe('web-development');
|
|
expect((await page.locator('#message').inputValue()).length).toBeGreaterThan(10);
|
|
});
|
|
|
|
test('Honeypot field is not visible to users', async ({ page }) => {
|
|
// Real users can't see or interact with the honeypot field
|
|
const honeypot = page.locator('#website');
|
|
await expect(honeypot).toBeAttached();
|
|
|
|
// It should not be focusable via tab (tabindex="-1")
|
|
const tabIndex = await honeypot.getAttribute('tabindex');
|
|
expect(tabIndex).toBe('-1');
|
|
});
|
|
|
|
test('Form is accessible on mobile viewport', async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 667 });
|
|
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
|
|
|
|
// All required fields are visible and tappable on mobile
|
|
await expect(page.locator('#name')).toBeVisible();
|
|
await expect(page.locator('#email')).toBeVisible();
|
|
await expect(page.locator('#subject')).toBeVisible();
|
|
await expect(page.locator('#message')).toBeVisible();
|
|
await expect(page.locator('#contact-form button[type="submit"]')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 7. Portfolio Filter Functionality
|
|
// ============================================================
|
|
test.describe('Portfolio Filters', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto('/portfolio', { waitUntil: 'domcontentloaded' });
|
|
// Wait for project cards to be rendered
|
|
await expect(page.locator('.project-card').first()).toBeVisible();
|
|
});
|
|
|
|
test('All filter buttons are present', async ({ page }) => {
|
|
await expect(page.locator('button[data-filter="all"]')).toBeVisible();
|
|
await expect(page.locator('button[data-filter="web"]')).toBeVisible();
|
|
await expect(page.locator('button[data-filter="mobile"]')).toBeVisible();
|
|
await expect(page.locator('button[data-filter="ai"]')).toBeVisible();
|
|
});
|
|
|
|
test('"All Projects" filter is active by default', async ({ page }) => {
|
|
const allBtn = page.locator('button[data-filter="all"]');
|
|
await expect(allBtn).toHaveClass(/active/);
|
|
await expect(allBtn).toHaveAttribute('aria-selected', 'true');
|
|
|
|
// All 8 project cards should be visible
|
|
const cards = page.locator('.project-card');
|
|
await expect(cards).toHaveCount(8);
|
|
});
|
|
|
|
test('"Web Development" filter shows only web projects', async ({ page }) => {
|
|
const webBtn = page.locator('button[data-filter="web"]');
|
|
await webBtn.click();
|
|
await page.waitForTimeout(500); // CSS animation
|
|
|
|
await expect(webBtn).toHaveAttribute('aria-selected', 'true');
|
|
|
|
const visibleCards = page.locator('.project-card:not(.hidden)');
|
|
const count = await visibleCards.count();
|
|
expect(count).toBeGreaterThan(0);
|
|
|
|
// All visible cards should be web category
|
|
const categories = await visibleCards.evaluateAll((cards) =>
|
|
cards.map((c) => (c as HTMLElement).dataset.category)
|
|
);
|
|
expect(categories.every((cat) => cat === 'web')).toBe(true);
|
|
});
|
|
|
|
test('"Mobile Apps" filter shows only mobile projects', async ({ page }) => {
|
|
await page.locator('button[data-filter="mobile"]').click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const visibleCards = page.locator('.project-card:not(.hidden)');
|
|
const count = await visibleCards.count();
|
|
expect(count).toBeGreaterThan(0);
|
|
|
|
const categories = await visibleCards.evaluateAll((cards) =>
|
|
cards.map((c) => (c as HTMLElement).dataset.category)
|
|
);
|
|
expect(categories.every((cat) => cat === 'mobile')).toBe(true);
|
|
});
|
|
|
|
test('"AI & ML" filter shows only AI projects', async ({ page }) => {
|
|
await page.locator('button[data-filter="ai"]').click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const visibleCards = page.locator('.project-card:not(.hidden)');
|
|
const count = await visibleCards.count();
|
|
expect(count).toBeGreaterThan(0);
|
|
|
|
const categories = await visibleCards.evaluateAll((cards) =>
|
|
cards.map((c) => (c as HTMLElement).dataset.category)
|
|
);
|
|
expect(categories.every((cat) => cat === 'ai')).toBe(true);
|
|
});
|
|
|
|
test('Switching back to "All" restores all projects', async ({ page }) => {
|
|
// Filter to web first
|
|
await page.locator('button[data-filter="web"]').click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Switch back to all
|
|
await page.locator('button[data-filter="all"]').click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const visibleCards = page.locator('.project-card:not(.hidden)');
|
|
await expect(visibleCards).toHaveCount(8);
|
|
});
|
|
|
|
test('Only one filter button is active at a time', async ({ page }) => {
|
|
await page.locator('button[data-filter="web"]').click();
|
|
await page.waitForTimeout(300);
|
|
|
|
const activeButtons = page.locator('button[data-filter][aria-selected="true"]');
|
|
await expect(activeButtons).toHaveCount(1);
|
|
await expect(activeButtons.first()).toHaveAttribute('data-filter', 'web');
|
|
});
|
|
|
|
test('Case study modal opens when "View Case Study" is clicked', async ({ page }) => {
|
|
const firstDetailsBtn = page.locator('.view-details-btn').first();
|
|
await firstDetailsBtn.click();
|
|
await page.waitForTimeout(400);
|
|
|
|
const modal = page.locator('#case-study-modal');
|
|
await expect(modal).not.toHaveClass(/hidden/);
|
|
await expect(modal).toBeVisible();
|
|
|
|
// Modal has a title
|
|
await expect(page.locator('#modal-title')).toBeVisible();
|
|
});
|
|
|
|
test('Case study modal closes when close button is clicked', async ({ page }) => {
|
|
await page.locator('.view-details-btn').first().click();
|
|
await page.waitForTimeout(400);
|
|
|
|
await page.locator('#close-modal-btn').click();
|
|
await page.waitForTimeout(400);
|
|
|
|
await expect(page.locator('#case-study-modal')).toHaveClass(/hidden/);
|
|
});
|
|
|
|
test('Case study modal closes on Escape key', async ({ page }) => {
|
|
await page.locator('.view-details-btn').first().click();
|
|
await page.waitForTimeout(400);
|
|
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForTimeout(400);
|
|
|
|
await expect(page.locator('#case-study-modal')).toHaveClass(/hidden/);
|
|
});
|
|
|
|
test('Portfolio filters work on mobile viewport', async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 667 });
|
|
await page.goto('/portfolio', { waitUntil: 'domcontentloaded' });
|
|
await expect(page.locator('.project-card').first()).toBeVisible();
|
|
|
|
// Filter tabs should be visible (flex-wrap layout)
|
|
await expect(page.locator('[role="tablist"]')).toBeVisible();
|
|
|
|
await page.locator('button[data-filter="web"]').click();
|
|
await page.waitForTimeout(500);
|
|
|
|
const visibleCards = page.locator('.project-card:not(.hidden)');
|
|
const count = await visibleCards.count();
|
|
expect(count).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 8. Blog Markdown Rendering
|
|
// ============================================================
|
|
test.describe('Blog Rendering', () => {
|
|
test('Blog listing page displays articles', async ({ page }) => {
|
|
await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
|
|
|
await expect(page.locator('h1')).toBeVisible();
|
|
|
|
// Blog listing should have links to posts or a "no posts" message
|
|
const postLinks = page.locator('a[href^="/blog/"]');
|
|
const articleCards = page.locator('article');
|
|
const totalContent = (await postLinks.count()) + (await articleCards.count());
|
|
|
|
// Either posts exist or page loads cleanly
|
|
const bodyText = await page.locator('body').innerText();
|
|
expect(bodyText.trim().length).toBeGreaterThan(50);
|
|
});
|
|
|
|
test('Navigating to a blog post renders markdown content', 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 blog posts in content collection - skip gracefully
|
|
return;
|
|
}
|
|
|
|
await firstPostLink.click();
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
// Page must have an h1 title
|
|
await expect(page.locator('h1')).toBeVisible();
|
|
|
|
// Page must have substantial content (markdown rendered to paragraphs)
|
|
const paragraphs = page.locator('p');
|
|
const pCount = await paragraphs.count();
|
|
expect(pCount).toBeGreaterThan(0);
|
|
|
|
// Should have prose content area
|
|
const content = await page.locator('body').innerText();
|
|
expect(content.trim().length).toBeGreaterThan(100);
|
|
});
|
|
|
|
test('Blog post page has proper heading hierarchy', 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');
|
|
|
|
// Must have exactly one h1
|
|
const h1Count = await page.locator('h1').count();
|
|
expect(h1Count).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test('Blog post code blocks render with pre/code tags', 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');
|
|
|
|
const htmlContent = await page.content();
|
|
// Code blocks use <pre> tags in markdown rendering - acceptable if absent (post may have no code)
|
|
const hasPreTag = htmlContent.includes('<pre') || htmlContent.includes('<code');
|
|
// Just verify the page rendered properly (code blocks optional per post content)
|
|
expect(htmlContent.length).toBeGreaterThan(500);
|
|
|
|
await page.screenshot({
|
|
path: 'tests/screenshots/blog-post-rendered.png',
|
|
fullPage: true,
|
|
});
|
|
});
|
|
|
|
test('Blog listing renders on mobile viewport', async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 667 });
|
|
await page.goto('/blog', { waitUntil: 'domcontentloaded' });
|
|
|
|
await expect(page.locator('h1')).toBeVisible();
|
|
await expect(page.locator('#main-header')).toBeVisible();
|
|
await expect(page.locator('footer')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 9. Console Error Monitoring (comprehensive - all pages, all known errors)
|
|
// ============================================================
|
|
test.describe('Console Error Monitoring', () => {
|
|
test('No critical JavaScript errors across all pages', async ({ page }) => {
|
|
const errorLog: { url: string; error: string }[] = [];
|
|
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') {
|
|
errorLog.push({ url: page.url(), error: msg.text() });
|
|
}
|
|
});
|
|
|
|
page.on('pageerror', (err) => {
|
|
errorLog.push({ url: page.url(), error: err.message });
|
|
});
|
|
|
|
const testPages = ALL_PAGES.filter((p) => p.url !== '/nonexistent-page-xyz');
|
|
|
|
for (const p of testPages) {
|
|
await page.goto(p.url, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForTimeout(500); // Allow async scripts to run
|
|
}
|
|
|
|
const critical = errorLog.filter(
|
|
(e) =>
|
|
!e.error.includes('favicon') &&
|
|
!e.error.includes('livereload') &&
|
|
!e.error.includes('DevTools') &&
|
|
!e.error.includes('manifest') &&
|
|
!e.error.includes('net::ERR_ABORTED')
|
|
);
|
|
|
|
if (critical.length > 0) {
|
|
console.error('CRITICAL CONSOLE ERRORS FOUND:');
|
|
critical.forEach((e) => console.error(` [${e.url}] ${e.error}`));
|
|
}
|
|
|
|
expect(critical, `Found ${critical.length} critical console error(s)`).toHaveLength(0);
|
|
});
|
|
|
|
test('No failed network requests (4xx/5xx) for critical assets', async ({ page }) => {
|
|
const failedRequests: { url: string; status: number }[] = [];
|
|
|
|
page.on('response', (response) => {
|
|
const status = response.status();
|
|
const url = response.url();
|
|
|
|
// Only track failures for page-relative assets (JS, CSS, fonts, images from same origin)
|
|
if (
|
|
status >= 400 &&
|
|
(url.includes('.js') ||
|
|
url.includes('.css') ||
|
|
url.includes('.woff') ||
|
|
url.includes('.woff2'))
|
|
) {
|
|
failedRequests.push({ url, status });
|
|
}
|
|
});
|
|
|
|
await page.goto('/', { waitUntil: 'networkidle' });
|
|
|
|
if (failedRequests.length > 0) {
|
|
console.warn('Failed asset requests:', failedRequests);
|
|
}
|
|
|
|
expect(failedRequests).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 10. Header Scroll Behavior
|
|
// ============================================================
|
|
test.describe('Header Scroll Behavior', () => {
|
|
test('Header gets shadow class after scrolling down', async ({ page }) => {
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
|
|
const header = page.locator('#main-header');
|
|
await expect(header).not.toHaveClass(/header-scrolled/);
|
|
|
|
// Scroll down
|
|
await page.evaluate(() => window.scrollTo(0, 100));
|
|
await page.waitForTimeout(200);
|
|
|
|
await expect(header).toHaveClass(/header-scrolled/);
|
|
});
|
|
|
|
test('Header shadow is removed when scrolled back to top', async ({ page }) => {
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
|
|
await page.evaluate(() => window.scrollTo(0, 200));
|
|
await page.waitForTimeout(200);
|
|
|
|
await page.evaluate(() => window.scrollTo(0, 0));
|
|
await page.waitForTimeout(200);
|
|
|
|
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);
|
|
});
|
|
}
|
|
});
|